diff --git a/pyasic/config/__init__.py b/pyasic/config/__init__.py index acdfdd9a..10dcf082 100644 --- a/pyasic/config/__init__.py +++ b/pyasic/config/__init__.py @@ -138,6 +138,15 @@ class MinerConfig: **self.power_scaling.as_auradine(), } + def as_mara(self, user_suffix: str = None) -> dict: + return { + **self.fan_mode.as_mara(), + **self.temperature.as_mara(), + **self.mining_mode.as_mara(), + **self.pools.as_mara(user_suffix=user_suffix), + **self.power_scaling.as_mara(), + } + @classmethod def from_dict(cls, dict_conf: dict) -> "MinerConfig": """Constructs a MinerConfig object from a dictionary.""" @@ -228,3 +237,11 @@ class MinerConfig: fan_mode=FanModeConfig.from_auradine(web_conf["fan"]), mining_mode=MiningModeConfig.from_auradine(web_conf["mode"]), ) + + @classmethod + def from_mara(cls, web_miner_config: dict) -> "MinerConfig": + return cls( + pools=PoolConfig.from_mara(web_miner_config), + fan_mode=FanModeConfig.from_mara(web_miner_config), + mining_mode=MiningModeConfig.from_mara(web_miner_config), + ) diff --git a/pyasic/config/base.py b/pyasic/config/base.py index ac666f8e..d4bbd68b 100644 --- a/pyasic/config/base.py +++ b/pyasic/config/base.py @@ -57,6 +57,9 @@ class MinerConfigOption(Enum): def as_auradine(self) -> dict: return self.value.as_auradine() + def as_mara(self) -> dict: + return self.value.as_mara() + def __call__(self, *args, **kwargs): return self.value(*args, **kwargs) @@ -106,3 +109,6 @@ class MinerConfigValue: def as_auradine(self) -> dict: return {} + + def as_mara(self) -> dict: + return {} diff --git a/pyasic/config/fans.py b/pyasic/config/fans.py index 0cb23452..ed0ef002 100644 --- a/pyasic/config/fans.py +++ b/pyasic/config/fans.py @@ -71,6 +71,15 @@ class FanModeNormal(MinerConfigValue): } } + def as_mara(self) -> dict: + return { + "general-config": {"environment-profile": "AirCooling"}, + "advance-config": { + "override-fan-control": False, + "fan-fixed-percent": 0, + }, + } + @dataclass class FanModeManual(MinerConfigValue): @@ -120,6 +129,15 @@ class FanModeManual(MinerConfigValue): def as_epic(self) -> dict: return {"fans": {"Manual": {"speed": self.speed}}} + def as_mara(self) -> dict: + return { + "general-config": {"environment-profile": "AirCooling"}, + "advance-config": { + "override-fan-control": True, + "fan-fixed-percent": self.speed, + }, + } + @dataclass class FanModeImmersion(MinerConfigValue): @@ -140,6 +158,9 @@ class FanModeImmersion(MinerConfigValue): def as_auradine(self) -> dict: return {"fan": {"percentage": 0}} + def as_mara(self) -> dict: + return {"general-config": {"environment-profile": "OilImmersionCooling"}} + class FanModeConfig(MinerConfigOption): normal = FanModeNormal @@ -255,4 +276,18 @@ class FanModeConfig(MinerConfigOption): fan_1_target = fan_data["Target"] return cls.manual(speed=round((fan_1_target / fan_1_max) * 100)) except LookupError: - return cls.default() + pass + return cls.default() + + @classmethod + def from_mara(cls, web_config: dict): + try: + mode = web_config["general-config"]["environment-profile"] + if mode == "AirCooling": + if web_config["advance-config"]["override-fan-control"]: + return cls.manual(web_config["advance-config"]["fan-fixed-percent"]) + return cls.normal() + return cls.immersion() + except LookupError: + pass + return cls.default() diff --git a/pyasic/config/mining.py b/pyasic/config/mining.py index f7ad9f7d..2c8719db 100644 --- a/pyasic/config/mining.py +++ b/pyasic/config/mining.py @@ -56,6 +56,13 @@ class MiningModeNormal(MinerConfigValue): def as_goldshell(self) -> dict: return {"settings": {"level": 0}} + def as_mara(self) -> dict: + return { + "mode": { + "work-mode-selector": "Stock", + } + } + @dataclass class MiningModeSleep(MinerConfigValue): @@ -82,6 +89,13 @@ class MiningModeSleep(MinerConfigValue): def as_goldshell(self) -> dict: return {"settings": {"level": 3}} + def as_mara(self) -> dict: + return { + "mode": { + "work-mode-selector": "Sleep", + } + } + @dataclass class MiningModeLPM(MinerConfigValue): @@ -219,6 +233,17 @@ class MiningModePowerTune(MinerConfigValue): def as_auradine(self) -> dict: return {"mode": {"mode": "custom", "tune": "power", "power": self.power}} + def as_mara(self) -> dict: + return { + "mode": { + "work-mode-selector": "Auto", + "concorde": { + "mode-select": "PowerTarget", + "power-target": self.power, + }, + } + } + @dataclass class MiningModeHashrateTune(MinerConfigValue): @@ -269,6 +294,17 @@ class MiningModeHashrateTune(MinerConfigValue): def as_epic(self) -> dict: return {"ptune": {"algo": self.algo.as_epic(), "target": self.hashrate}} + def as_mara(self) -> dict: + return { + "mode": { + "work-mode-selector": "Auto", + "concorde": { + "mode-select": "Hashrate", + "hash-target": self.hashrate, + }, + } + } + @dataclass class ManualBoardSettings(MinerConfigValue): @@ -320,6 +356,17 @@ class MiningModeManual(MinerConfigValue): } return cls(global_freq=freq, global_volt=voltage, boards=boards) + def as_mara(self) -> dict: + return { + "mode": { + "work-mode-selector": "Fixed", + "fixed": { + "frequency": str(self.global_freq), + "voltage": self.global_volt, + }, + } + } + class MiningModeConfig(MinerConfigOption): normal = MiningModeNormal @@ -468,3 +515,28 @@ class MiningModeConfig(MinerConfigOption): return cls.power_tuning(mode_data["Power"]) except LookupError: return cls.default() + + @classmethod + def from_mara(cls, web_config: dict): + try: + mode = web_config["mode"]["work-mode-selector"] + if mode == "Fixed": + fixed_conf = web_config["mode"]["fixed"] + return cls.manual( + global_freq=int(fixed_conf["frequency"]), + global_volt=fixed_conf["voltage"], + ) + elif mode == "Stock": + return cls.normal() + elif mode == "Sleep": + return cls.sleep() + elif mode == "Auto": + auto_conf = web_config["mode"]["concorde"] + auto_mode = auto_conf["mode-select"] + if auto_mode == "Hashrate": + return cls.hashrate_tuning(hashrate=auto_conf["hash-target"]) + elif auto_mode == "PowerTarget": + return cls.power_tuning(power=auto_conf["power-target"]) + except LookupError: + pass + return cls.default() diff --git a/pyasic/config/pools.py b/pyasic/config/pools.py index a54b6186..19c88262 100644 --- a/pyasic/config/pools.py +++ b/pyasic/config/pools.py @@ -118,6 +118,15 @@ class Pool(MinerConfigValue): } return {"pool": self.url, "login": self.user, "password": self.password} + def as_mara(self, user_suffix: str = None) -> dict: + if user_suffix is not None: + return { + "url": self.url, + "user": f"{self.user}{user_suffix}", + "pass": self.password, + } + return {"url": self.url, "user": self.user, "pass": self.password} + @classmethod def from_dict(cls, dict_conf: dict | None) -> "Pool": return cls( @@ -177,6 +186,14 @@ class Pool(MinerConfigValue): password=grpc_pool["password"], ) + @classmethod + def from_mara(cls, web_pool: dict) -> "Pool": + return cls( + url=web_pool["url"], + user=web_pool["user"], + password=web_pool["pass"], + ) + @dataclass class PoolGroup(MinerConfigValue): @@ -264,9 +281,12 @@ class PoolGroup(MinerConfigValue): def as_auradine(self, user_suffix: str = None) -> list: return [p.as_auradine(user_suffix=user_suffix) for p in self.pools] - def as_epic(self, user_suffix: str = None) -> dict: + def as_epic(self, user_suffix: str = None) -> list: return [p.as_epic(user_suffix=user_suffix) for p in self.pools] + def as_mara(self, user_suffix: str = None) -> list: + return [p.as_mara(user_suffix=user_suffix) for p in self.pools] + @classmethod def from_dict(cls, dict_conf: dict | None) -> "PoolGroup": cls_conf = {} @@ -336,6 +356,10 @@ class PoolGroup(MinerConfigValue): except LookupError: return cls() + @classmethod + def from_mara(cls, web_config_pools: dict) -> "PoolGroup": + return cls(pools=[Pool.from_mara(pool_conf) for pool_conf in web_config_pools]) + @dataclass class PoolConfig(MinerConfigValue): @@ -427,6 +451,11 @@ class PoolConfig(MinerConfigValue): } } + def as_mara(self, user_suffix: str = None) -> dict: + if len(self.groups) > 0: + return {"pools": self.groups[0].as_mara(user_suffix=user_suffix)} + return {"pools": []} + @classmethod def from_api(cls, api_pools: dict) -> "PoolConfig": try: @@ -481,3 +510,7 @@ class PoolConfig(MinerConfigValue): ) except LookupError: return cls() + + @classmethod + def from_mara(cls, web_config: dict) -> "PoolConfig": + return cls(groups=[PoolGroup.from_mara(web_config["pools"])]) diff --git a/pyasic/miners/backends/marathon.py b/pyasic/miners/backends/marathon.py index 84c3c8f9..2acdd77f 100644 --- a/pyasic/miners/backends/marathon.py +++ b/pyasic/miners/backends/marathon.py @@ -1,71 +1,69 @@ -from typing import Optional +from typing import List, Optional +from pyasic import MinerConfig +from pyasic.config import MiningModeConfig +from pyasic.data import Fan, HashBoard from pyasic.errors import APIError -from pyasic.miners.backends import AntminerModern -from pyasic.miners.data import ( - DataFunction, - DataLocations, - DataOptions, - RPCAPICommand, - WebAPICommand, -) +from pyasic.miners.base import BaseMiner +from pyasic.miners.data import DataFunction, DataLocations, DataOptions, WebAPICommand +from pyasic.misc import merge_dicts from pyasic.web.marathon import MaraWebAPI MARA_DATA_LOC = DataLocations( **{ str(DataOptions.MAC): DataFunction( "_get_mac", - [WebAPICommand("web_get_system_info", "get_system_info")], - ), - str(DataOptions.API_VERSION): DataFunction( - "_get_api_ver", - [RPCAPICommand("rpc_version", "version")], + [WebAPICommand("web_overview", "overview")], ), str(DataOptions.FW_VERSION): DataFunction( "_get_fw_ver", - [RPCAPICommand("rpc_version", "version")], + [WebAPICommand("web_overview", "overview")], ), str(DataOptions.HOSTNAME): DataFunction( "_get_hostname", - [WebAPICommand("web_get_system_info", "get_system_info")], + [WebAPICommand("web_network_config", "network_config")], ), str(DataOptions.HASHRATE): DataFunction( "_get_hashrate", - [RPCAPICommand("rpc_summary", "summary")], + [WebAPICommand("web_brief", "brief")], ), str(DataOptions.EXPECTED_HASHRATE): DataFunction( "_get_expected_hashrate", - [RPCAPICommand("rpc_stats", "stats")], + [WebAPICommand("web_brief", "brief")], ), - str(DataOptions.FANS): DataFunction( - "_get_fans", - [RPCAPICommand("rpc_stats", "stats")], - ), - str(DataOptions.ERRORS): DataFunction( - "_get_errors", - [WebAPICommand("web_summary", "summary")], - ), - str(DataOptions.FAULT_LIGHT): DataFunction( - "_get_fault_light", - [WebAPICommand("web_get_blink_status", "get_blink_status")], - ), - str(DataOptions.IS_MINING): DataFunction( - "_is_mining", - [WebAPICommand("web_get_conf", "get_miner_conf")], - ), - str(DataOptions.UPTIME): DataFunction( - "_get_uptime", - [RPCAPICommand("rpc_stats", "stats")], + str(DataOptions.HASHBOARDS): DataFunction( + "_get_hashboards", + [WebAPICommand("web_hashboards", "hashboards")], ), str(DataOptions.WATTAGE): DataFunction( "_get_wattage", [WebAPICommand("web_brief", "brief")], ), + str(DataOptions.WATTAGE_LIMIT): DataFunction( + "_get_wattage_limit", + [WebAPICommand("web_miner_config", "miner_config")], + ), + str(DataOptions.FANS): DataFunction( + "_get_fans", + [WebAPICommand("web_fans", "fans")], + ), + str(DataOptions.FAULT_LIGHT): DataFunction( + "_get_fault_light", + [WebAPICommand("web_locate_miner", "locate_miner")], + ), + str(DataOptions.IS_MINING): DataFunction( + "_is_mining", + [WebAPICommand("web_brief", "brief")], + ), + str(DataOptions.UPTIME): DataFunction( + "_get_uptime", + [WebAPICommand("web_brief", "brief")], + ), } ) -class MaraMiner(AntminerModern): +class MaraMiner(BaseMiner): _web_cls = MaraWebAPI web: MaraWebAPI @@ -73,6 +71,52 @@ class MaraMiner(AntminerModern): firmware = "MaraFW" + async def fault_light_off(self) -> bool: + res = await self.web.set_locate_miner(blinking=False) + return res.get("blinking") is False + + async def fault_light_on(self) -> bool: + res = await self.web.set_locate_miner(blinking=True) + return res.get("blinking") is True + + async def get_config(self) -> MinerConfig: + data = await self.web.get_miner_config() + if data: + self.config = MinerConfig.from_mara(data) + return self.config + + async def send_config(self, config: MinerConfig, user_suffix: str = None) -> None: + data = await self.web.get_miner_config() + cfg_data = config.as_mara(user_suffix=user_suffix) + merged_cfg = merge_dicts(data, cfg_data) + await self.web.set_miner_config(**merged_cfg) + + async def set_power_limit(self, wattage: int) -> bool: + cfg = await self.get_config() + cfg.mining_mode = MiningModeConfig.power_tuning(wattage) + await self.send_config(cfg) + return True + + async def stop_mining(self) -> bool: + data = await self.web.get_miner_config() + data["mode"]["work-mode-selector"] = "Sleep" + await self.web.set_miner_config(**data) + return True + + async def resume_mining(self) -> bool: + data = await self.web.get_miner_config() + data["mode"]["work-mode-selector"] = "Auto" + await self.web.set_miner_config(**data) + return True + + async def reboot(self) -> bool: + await self.web.reboot() + return True + + async def restart_backend(self) -> bool: + await self.web.reload() + return True + async def _get_wattage(self, web_brief: dict = None) -> Optional[int]: if web_brief is None: try: @@ -85,3 +129,170 @@ class MaraMiner(AntminerModern): return web_brief["power_consumption_estimated"] except LookupError: pass + + async def _is_mining(self, web_brief: dict = None) -> Optional[bool]: + if web_brief is None: + try: + web_brief = await self.web.brief() + except APIError: + pass + + if web_brief is not None: + try: + return web_brief["status"] == "Mining" + except LookupError: + pass + + async def _get_uptime(self, web_brief: dict = None) -> Optional[int]: + if web_brief is None: + try: + web_brief = await self.web.brief() + except APIError: + pass + + if web_brief is not None: + try: + return web_brief["elapsed"] + except LookupError: + pass + + async def _get_hashboards(self, web_hashboards: dict = None) -> List[HashBoard]: + hashboards = [ + HashBoard(slot=i, expected_chips=self.expected_chips) + for i in range(self.expected_hashboards) + ] + + if web_hashboards is None: + try: + web_hashboards = await self.web.hashboards() + except APIError: + pass + + if web_hashboards is not None: + try: + for hb in web_hashboards["hashboards"]: + idx = hb["index"] + hashboards[idx].hashrate = hb["hashrate_average"] + hashboards[idx].temp = round( + sum(hb["temperature_pcb"]) / len(hb["temperature_pcb"]), 2 + ) + hashboards[idx].chip_temp = round( + sum(hb["temperature_chip"]) / len(hb["temperature_chip"]), 2 + ) + hashboards[idx].chips = hb["asic_num"] + hashboards[idx].serial_number = hb["serial_number"] + hashboards[idx].missing = False + except LookupError: + pass + return hashboards + + async def _get_mac(self, web_overview: dict = None) -> Optional[str]: + if web_overview is None: + try: + web_overview = await self.web.overview() + except APIError: + pass + + if web_overview is not None: + try: + return web_overview["mac"].upper() + except LookupError: + pass + + async def _get_fw_ver(self, web_overview: dict = None) -> Optional[str]: + if web_overview is None: + try: + web_overview = await self.web.overview() + except APIError: + pass + + if web_overview is not None: + try: + return web_overview["version_firmware"] + except LookupError: + pass + + async def _get_hostname(self, web_network_config: dict = None) -> Optional[str]: + if web_network_config is None: + try: + web_network_config = await self.web.get_network_config() + except APIError: + pass + + if web_network_config is not None: + try: + return web_network_config["hostname"] + except LookupError: + pass + + async def _get_hashrate(self, web_brief: dict = None) -> Optional[float]: + if web_brief is None: + try: + web_brief = await self.web.brief() + except APIError: + pass + + if web_brief is not None: + try: + return round(web_brief["hashrate_realtime"], 2) + except LookupError: + pass + + async def _get_fans(self, web_fans: dict = None) -> List[Fan]: + if web_fans is None: + try: + web_fans = await self.web.fans() + except APIError: + pass + + if web_fans is not None: + fans = [] + for n in range(self.expected_fans): + try: + fans.append(Fan(web_fans["fans"][n]["current_speed"])) + except (IndexError, KeyError): + pass + return fans + return [Fan() for _ in range(self.expected_fans)] + + async def _get_fault_light(self, web_locate_miner: dict = None) -> bool: + if web_locate_miner is None: + try: + web_locate_miner = await self.web.get_locate_miner() + except APIError: + pass + + if web_locate_miner is not None: + try: + return web_locate_miner["blinking"] + except LookupError: + pass + return False + + async def _get_expected_hashrate(self, web_brief: dict = None) -> Optional[float]: + if web_brief is None: + try: + web_brief = await self.web.brief() + except APIError: + pass + + if web_brief is not None: + try: + return round(web_brief["hashrate_ideal"], 2) + except LookupError: + pass + + async def _get_wattage_limit( + self, web_miner_config: dict = None + ) -> Optional[float]: + if web_miner_config is None: + try: + web_miner_config = await self.web.get_miner_config() + except APIError: + pass + + if web_miner_config is not None: + try: + return web_miner_config["mode"]["concorde"]["power-target"] + except LookupError: + pass diff --git a/pyasic/miners/factory.py b/pyasic/miners/factory.py index bf9ff5e1..5477b6e0 100644 --- a/pyasic/miners/factory.py +++ b/pyasic/miners/factory.py @@ -553,7 +553,17 @@ class MinerFactory: and self._parse_web_type(x[0], x[1]) is not None, ) if text is not None: - return self._parse_web_type(text, resp) + mtype = self._parse_web_type(text, resp) + if mtype == MinerTypes.ANTMINER: + # could still be mara + auth = httpx.DigestAuth("root", "root") + res = await self.send_web_command( + ip, "/kaonsu/v1/brief", auth=auth + ) + if res is not None: + mtype = MinerTypes.MARATHON + return mtype + @staticmethod async def _web_ping( diff --git a/pyasic/web/marathon.py b/pyasic/web/marathon.py index bccd7176..f4ae90da 100644 --- a/pyasic/web/marathon.py +++ b/pyasic/web/marathon.py @@ -1,30 +1,56 @@ from __future__ import annotations +import asyncio import json from typing import Any import httpx from pyasic import settings -from pyasic.web.antminer import AntminerModernWebAPI +from pyasic.web.base import BaseWebAPI -class MaraWebAPI(AntminerModernWebAPI): +class MaraWebAPI(BaseWebAPI): def __init__(self, ip: str) -> None: - self.am_commands = [ - "get_miner_conf", - "set_miner_conf", - "blink", - "reboot", - "get_system_info", - "get_network_info", - "summary", - "get_blink_status", - "set_network_conf", - ] super().__init__(ip) - async def _send_mara_command( + async def multicommand( + self, *commands: str, ignore_errors: bool = False, allow_warning: bool = True + ) -> dict: + async with httpx.AsyncClient(transport=settings.transport()) as client: + tasks = [ + asyncio.create_task(self._handle_multicommand(client, command)) + for command in commands + ] + all_data = await asyncio.gather(*tasks) + + data = {} + for item in all_data: + data.update(item) + + data["multicommand"] = True + return data + + async def _handle_multicommand( + self, client: httpx.AsyncClient, command: str + ) -> dict: + auth = httpx.DigestAuth(self.username, self.pwd) + + try: + url = f"http://{self.ip}:{self.port}/kaonsu/v1/{command}" + ret = await client.get(url, auth=auth) + except httpx.HTTPError: + pass + else: + if ret.status_code == 200: + try: + json_data = ret.json() + return {command: json_data} + except json.decoder.JSONDecodeError: + pass + return {command: {}} + + async def send_command( self, command: str | bytes, ignore_errors: bool = False, @@ -56,76 +82,66 @@ class MaraWebAPI(AntminerModernWebAPI): except json.decoder.JSONDecodeError: pass - async def _send_am_command( - self, - command: str | bytes, - ignore_errors: bool = False, - allow_warning: bool = True, - privileged: bool = False, - **parameters: Any, - ): - url = f"http://{self.ip}:{self.port}/cgi-bin/{command}.cgi" - auth = httpx.DigestAuth(self.username, self.pwd) - try: - async with httpx.AsyncClient( - transport=settings.transport(), - ) as client: - if parameters: - data = await client.post( - url, - auth=auth, - timeout=settings.get("api_function_timeout", 3), - json=parameters, - ) - else: - data = await client.get(url, auth=auth) - except httpx.HTTPError: - pass - else: - if data.status_code == 200: - try: - return data.json() - except json.decoder.JSONDecodeError: - pass - - async def send_command( - self, - command: str | bytes, - ignore_errors: bool = False, - allow_warning: bool = True, - privileged: bool = False, - **parameters: Any, - ) -> dict: - if command in self.am_commands: - return await self._send_am_command( - command, - ignore_errors=ignore_errors, - allow_warning=allow_warning, - privileged=privileged, - **parameters, - ) - return await self._send_mara_command( - command, - ignore_errors=ignore_errors, - allow_warning=allow_warning, - privileged=privileged, - **parameters, - ) - async def brief(self): return await self.send_command("brief") + async def ping(self): + return await self.send_command("ping") + + async def get_locate_miner(self): + return await self.send_command("locate_miner") + + async def set_locate_miner(self, blinking: bool): + return await self.send_command("locate_miner", blinking=blinking) + + async def reboot(self): + return await self.send_command("maintenance", type="reboot") + + async def reset(self): + return await self.send_command("maintenance", type="reset") + + async def reload(self): + return await self.send_command("maintenance", type="reload") + + async def set_password(self, new_pwd: str): + return await self.send_command( + "maintenance", + type="passwd", + params={"curPwd": self.pwd, "confirmPwd": self.pwd, "newPwd": new_pwd}, + ) + + async def get_network_config(self): + return await self.send_command("network_config") + + async def set_network_config(self, **params): + return await self.send_command("network_config", **params) + + async def get_miner_config(self): + return await self.send_command("miner_config") + + async def set_miner_config(self, **params): + return await self.send_command("miner_config", **params) + + async def fans(self): + return await self.send_command("fans") + + async def log(self): + return await self.send_command("log") + async def overview(self): return await self.send_command("overview") async def connections(self): return await self.send_command("connections") + async def controlboard_info(self): + return await self.send_command("controlboard_info") + async def event_chart(self): return await self.send_command("event_chart") async def hashboards(self): return await self.send_command("hashboards") - async def mara_pools(self): - return await self._send_mara_command("pools") + async def pools(self): + return await self.send_command("pools")