feature: add BTMiner V3 configuration

This commit is contained in:
Brett Rowan
2025-08-15 14:30:59 -06:00
parent 56ad6cbc6f
commit debd4d2d4d
16 changed files with 128 additions and 27 deletions

View File

@@ -85,6 +85,13 @@ class MinerConfig(BaseModel):
**self.temperature.as_wm(), **self.temperature.as_wm(),
} }
def as_btminer_v3(self, user_suffix: str | None = None) -> dict:
"""Generates the configuration in the format suitable for Whatsminers running BTMiner V3."""
return {
"set.miner.pools": self.pools.as_btminer_v3()
** self.mining_mode.as_btminer_v3()
}
def as_am_old(self, user_suffix: str | None = None) -> dict: def as_am_old(self, user_suffix: str | None = None) -> dict:
"""Generates the configuration in the format suitable for old versions of Antminers.""" """Generates the configuration in the format suitable for old versions of Antminers."""
return { return {
@@ -355,3 +362,14 @@ class MinerConfig(BaseModel):
@classmethod @classmethod
def from_hammer(cls, *args, **kwargs) -> "MinerConfig": def from_hammer(cls, *args, **kwargs) -> "MinerConfig":
return cls.from_am_modern(*args, **kwargs) return cls.from_am_modern(*args, **kwargs)
@classmethod
def from_btminer_v3(
cls, rpc_pools: dict, rpc_settings: dict, rpc_device_info: dict
) -> "MinerConfig":
return cls(
pools=PoolConfig.from_btminer_v3(rpc_pools=rpc_pools["msg"]),
mining_mode=MiningModeConfig.from_btminer_v3(
rpc_device_info=rpc_device_info, rpc_settings=rpc_settings
),
)

View File

@@ -107,6 +107,9 @@ class MinerConfigValue(BaseModel):
def as_wm(self) -> dict: def as_wm(self) -> dict:
return {} return {}
def as_btminer_v3(self) -> dict:
return {}
def as_inno(self) -> dict: def as_inno(self) -> dict:
return {} return {}

View File

@@ -63,6 +63,9 @@ class MiningModeNormal(MinerConfigValue):
def as_wm(self) -> dict: def as_wm(self) -> dict:
return {"mode": self.mode} return {"mode": self.mode}
def as_btminer_v3(self) -> dict:
return {"set.miner.service": "start", "set.miner.power_mode": self.mode}
def as_auradine(self) -> dict: def as_auradine(self) -> dict:
return {"mode": {"mode": self.mode}} return {"mode": {"mode": self.mode}}
@@ -109,6 +112,9 @@ class MiningModeSleep(MinerConfigValue):
def as_wm(self) -> dict: def as_wm(self) -> dict:
return {"mode": self.mode} return {"mode": self.mode}
def as_btminer_v3(self) -> dict:
return {"set.miner.service": "stop"}
def as_auradine(self) -> dict: def as_auradine(self) -> dict:
return {"mode": {"sleep": "on"}} return {"mode": {"sleep": "on"}}
@@ -149,6 +155,9 @@ class MiningModeLPM(MinerConfigValue):
def as_wm(self) -> dict: def as_wm(self) -> dict:
return {"mode": self.mode} return {"mode": self.mode}
def as_btminer_v3(self) -> dict:
return {"set.miner.service": "start", "set.miner.power_mode": self.mode}
def as_auradine(self) -> dict: def as_auradine(self) -> dict:
return {"mode": {"mode": "eco"}} return {"mode": {"mode": "eco"}}
@@ -179,6 +188,9 @@ class MiningModeHPM(MinerConfigValue):
def as_wm(self) -> dict: def as_wm(self) -> dict:
return {"mode": self.mode} return {"mode": self.mode}
def as_btminer_v3(self) -> dict:
return {"set.miner.service": "start", "set.miner.power_mode": self.mode}
def as_auradine(self) -> dict: def as_auradine(self) -> dict:
return {"mode": {"mode": "turbo"}} return {"mode": {"mode": "turbo"}}
@@ -222,6 +234,9 @@ class MiningModePowerTune(MinerConfigValue):
return {"mode": self.mode, self.mode: {"wattage": self.power}} return {"mode": self.mode, self.mode: {"wattage": self.power}}
return {} return {}
def as_btminer_v3(self) -> dict:
return {"set.miner.service": "start", "set.miner.power_limit": self.power}
def as_bosminer(self) -> dict: def as_bosminer(self) -> dict:
tuning_cfg = {"enabled": True, "mode": "power_target"} tuning_cfg = {"enabled": True, "mode": "power_target"}
if self.power is not None: if self.power is not None:
@@ -789,6 +804,26 @@ class MiningModeConfig(MinerConfigOption):
except LookupError: except LookupError:
return cls.default() return cls.default()
@classmethod
def from_btminer_v3(cls, rpc_device_info: dict, rpc_settings: dict):
try:
is_mining = rpc_device_info["msg"]["miner"]["working"] == "true"
if not is_mining:
return cls.sleep()
power_limit = rpc_settings["msg"]["power-limit"]
if not power_limit == 0:
return cls.power_tuning(power=power_limit)
power_mode = rpc_settings["msg"]["power-mode"]
if power_mode == "normal":
return cls.normal()
if power_mode == "high":
return cls.high()
if power_mode == "low":
return cls.low()
except LookupError:
return cls.default()
@classmethod @classmethod
def from_mara(cls, web_config: dict): def from_mara(cls, web_config: dict):
try: try:

View File

@@ -64,6 +64,13 @@ class Pool(MinerConfigValue):
f"passwd_{idx}": self.password, f"passwd_{idx}": self.password,
} }
def as_btminer_v3(self, user_suffix: str | None = None) -> dict:
return {
f"pool": self.url,
f"worker": f"{self.user}{user_suffix or ''}",
f"passwd": self.password,
}
def as_am_old(self, idx: int = 1, user_suffix: str | None = None) -> dict: def as_am_old(self, idx: int = 1, user_suffix: str | None = None) -> dict:
return { return {
f"_ant_pool{idx}url": self.url, f"_ant_pool{idx}url": self.url,
@@ -148,6 +155,10 @@ class Pool(MinerConfigValue):
def from_api(cls, api_pool: dict) -> "Pool": def from_api(cls, api_pool: dict) -> "Pool":
return cls(url=api_pool["URL"], user=api_pool["User"], password="x") return cls(url=api_pool["URL"], user=api_pool["User"], password="x")
@classmethod
def from_btminer_v3(cls, api_pool: dict) -> "Pool":
return cls(url=api_pool["url"], user=api_pool["account"], password="x")
@classmethod @classmethod
def from_epic(cls, api_pool: dict) -> "Pool": def from_epic(cls, api_pool: dict) -> "Pool":
return cls( return cls(
@@ -296,6 +307,9 @@ class PoolGroup(MinerConfigValue):
idx += 1 idx += 1
return pools return pools
def as_btminer_v3(self, user_suffix: str | None = None) -> list:
return [pool.as_btminer_v3(user_suffix) for pool in self.pools[:3]]
def as_am_old(self, user_suffix: str | None = None) -> dict: def as_am_old(self, user_suffix: str | None = None) -> dict:
pools = {} pools = {}
idx = 0 idx = 0
@@ -385,6 +399,13 @@ class PoolGroup(MinerConfigValue):
pools.append(Pool.from_api(pool)) pools.append(Pool.from_api(pool))
return cls(pools=pools) return cls(pools=pools)
@classmethod
def from_btminer_v3(cls, api_pool_list: list) -> "PoolGroup":
pools = []
for pool in api_pool_list:
pools.append(Pool.from_btminer_v3(pool))
return cls(pools=pools)
@classmethod @classmethod
def from_epic(cls, api_pool_list: list) -> "PoolGroup": def from_epic(cls, api_pool_list: list) -> "PoolGroup":
pools = [] pools = []
@@ -513,6 +534,11 @@ class PoolConfig(MinerConfigValue):
return {"pools": self.groups[0].as_wm(user_suffix=user_suffix)} return {"pools": self.groups[0].as_wm(user_suffix=user_suffix)}
return {"pools": PoolGroup().as_wm()} return {"pools": PoolGroup().as_wm()}
def as_btminer_v3(self, user_suffix: str | None = None) -> dict:
if len(self.groups) > 0:
return {"pools": self.groups[0].as_btminer_v3(user_suffix=user_suffix)}
return {"pools": PoolGroup().as_btminer_v3()}
def as_am_old(self, user_suffix: str | None = None) -> dict: def as_am_old(self, user_suffix: str | None = None) -> dict:
if len(self.groups) > 0: if len(self.groups) > 0:
return self.groups[0].as_am_old(user_suffix=user_suffix) return self.groups[0].as_am_old(user_suffix=user_suffix)
@@ -598,6 +624,16 @@ class PoolConfig(MinerConfigValue):
return cls(groups=[PoolGroup.from_api(pool_data)]) return cls(groups=[PoolGroup.from_api(pool_data)])
@classmethod
def from_btminer_v3(cls, rpc_pools: dict) -> "PoolConfig":
try:
pool_data = rpc_pools["pools"]
except KeyError:
return PoolConfig.default()
pool_data = sorted(pool_data, key=lambda x: int(x["id"]))
return cls(groups=[PoolGroup.from_btminer_v3(pool_data)])
@classmethod @classmethod
def from_epic(cls, web_conf: dict) -> "PoolConfig": def from_epic(cls, web_conf: dict) -> "PoolConfig":
pool_data = web_conf["StratumConfigs"] pool_data = web_conf["StratumConfigs"]

View File

@@ -77,7 +77,6 @@ class HashBoard(BaseModel):
raise KeyError(f"{item}") raise KeyError(f"{item}")
def as_influxdb(self, key_root: str, level_delimiter: str = ".") -> str: def as_influxdb(self, key_root: str, level_delimiter: str = ".") -> str:
def serialize_int(key: str, value: int) -> str: def serialize_int(key: str, value: int) -> str:
return f"{key}={value}" return f"{key}={value}"

View File

@@ -94,7 +94,6 @@ class PoolMetrics(BaseModel):
return (value / total) * 100 return (value / total) * 100
def as_influxdb(self, key_root: str, level_delimiter: str = ".") -> str: def as_influxdb(self, key_root: str, level_delimiter: str = ".") -> str:
def serialize_int(key: str, value: int) -> str: def serialize_int(key: str, value: int) -> str:
return f"{key}={value}" return f"{key}={value}"

View File

@@ -167,7 +167,6 @@ class CGMinerAvalonNano3(AvalonMiner, AvalonNano3):
class CGMinerAvalonNano3s(AvalonMiner, AvalonNano3s): class CGMinerAvalonNano3s(AvalonMiner, AvalonNano3s):
data_locations = AVALON_NANO3S_DATA_LOC data_locations = AVALON_NANO3S_DATA_LOC
async def _get_wattage(self, rpc_estats: dict = None) -> Optional[int]: async def _get_wattage(self, rpc_estats: dict = None) -> Optional[int]:
@@ -212,7 +211,6 @@ class CGMinerAvalonNano3s(AvalonMiner, AvalonNano3s):
pass pass
if rpc_estats is not None: if rpc_estats is not None:
try: try:
unparsed_estats = rpc_estats["STATS"][0]["MM ID0"] unparsed_estats = rpc_estats["STATS"][0]["MM ID0"]
parsed_estats = self.parse_estats(unparsed_estats) parsed_estats = self.parse_estats(unparsed_estats)

View File

@@ -287,7 +287,6 @@ class AvalonMiner(CGMiner):
return hashboards return hashboards
for board in range(self.expected_hashboards): for board in range(self.expected_hashboards):
try: try:
board_hr = parsed_estats["STATS"][0]["MM ID0"]["MGHS"] board_hr = parsed_estats["STATS"][0]["MM ID0"]["MGHS"]
if isinstance(board_hr, list): if isinstance(board_hr, list):

View File

@@ -13,14 +13,14 @@
# See the License for the specific language governing permissions and - # See the License for the specific language governing permissions and -
# limitations under the License. - # limitations under the License. -
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
import semver import semver
from pyasic.config import MinerConfig, MiningModeConfig from pyasic.config import MinerConfig, MiningModeConfig, PoolConfig
from pyasic.data import Fan, HashBoard from pyasic.data import Fan, HashBoard
from pyasic.data.error_codes import MinerErrorData, WhatsminerError from pyasic.data.error_codes import MinerErrorData, WhatsminerError
from pyasic.data.pools import PoolMetrics, PoolUrl from pyasic.data.pools import PoolMetrics, PoolUrl
@@ -47,14 +47,14 @@ class BTMiner(StockFirmware):
) )
except ValueError: except ValueError:
return BTMinerV2 return BTMinerV2
if semantic.major > 2024 and semantic.minor > 11: if semantic > semver.Version(major=2024, minor=11, patch=0):
return BTMinerV3 return BTMinerV3
return BTMinerV2 return BTMinerV2
inject = get_new(version) inject = get_new(version)
if inject not in bases: bases = (inject,) + bases
bases = (inject,) + bases
cls = type(cls.__name__, bases, {})(ip=ip, version=version) cls = type(cls.__name__, bases, {})(ip=ip, version=version)
return cls return cls
@@ -859,6 +859,34 @@ class BTMinerV3(StockFirmware):
supports_autotuning = True supports_autotuning = True
supports_power_modes = True supports_power_modes = True
async def get_config(self) -> MinerConfig:
pools = None
settings = None
device_info = None
try:
pools = await self.rpc.get_miner_status_pools()
settings = await self.rpc.get_miner_setting()
device_info = await self.rpc.get_device_info()
except APIError as e:
logging.warning(e)
except LookupError:
pass
self.config = MinerConfig.from_btminer_v3(
rpc_pools=pools, rpc_settings=settings, rpc_device_info=device_info
)
return self.config
async def send_config(self, config: MinerConfig, user_suffix: str = None) -> None:
self.config = config
conf = config.as_btminer_v3(user_suffix=user_suffix)
await asyncio.gather(
*[self.rpc.send_command(k, parameters=v) for k, v in conf.values()]
)
async def fault_light_off(self) -> bool: async def fault_light_off(self) -> bool:
try: try:
data = await self.rpc.set_system_led() data = await self.rpc.set_system_led()

View File

@@ -78,7 +78,6 @@ GOLDSHELL_BYTE_DATA_LOC = DataLocations(
class GoldshellByte(GoldshellMiner, Byte): class GoldshellByte(GoldshellMiner, Byte):
data_locations = GOLDSHELL_BYTE_DATA_LOC data_locations = GOLDSHELL_BYTE_DATA_LOC
cgdev: dict | None = None cgdev: dict | None = None
@@ -101,11 +100,9 @@ class GoldshellByte(GoldshellMiner, Byte):
total_uptime_mins = 0 total_uptime_mins = 0
for minfo in self.cgdev.get("minfos", []): for minfo in self.cgdev.get("minfos", []):
algo_name = minfo.get("name") algo_name = minfo.get("name")
for info in minfo.get("infos", []): for info in minfo.get("infos", []):
self.expected_hashboards += 1 self.expected_hashboards += 1
self.expected_fans += 1 self.expected_fans += 1
@@ -177,7 +174,6 @@ class GoldshellByte(GoldshellMiner, Byte):
if rpc_devs is not None: if rpc_devs is not None:
for board in rpc_devs.get("DEVS", []): for board in rpc_devs.get("DEVS", []):
algo_name = board.get("pool") algo_name = board.get("pool")
if algo_name == ALGORITHM_SCRYPT_NAME: if algo_name == ALGORITHM_SCRYPT_NAME:

View File

@@ -1223,6 +1223,9 @@ class BTMinerV3RPCAPI(BaseMinerRPCAPI):
async def get_miner_status_edevs(self) -> dict | None: async def get_miner_status_edevs(self) -> dict | None:
return await self.send_command("get.miner.status", parameters="edevs") return await self.send_command("get.miner.status", parameters="edevs")
async def get_miner_status_pools(self) -> dict | None:
return await self.send_command("get.miner.status", parameters="pools")
async def get_miner_history(self) -> dict | None: async def get_miner_history(self) -> dict | None:
data = await self.send_command( data = await self.send_command(
"get.miner.history", "get.miner.history",

View File

@@ -62,7 +62,6 @@ class AntminerModernWebAPI(BaseWebAPI):
auth = httpx.DigestAuth(self.username, self.pwd) auth = httpx.DigestAuth(self.username, self.pwd)
try: try:
async with httpx.AsyncClient(transport=settings.transport()) as client: async with httpx.AsyncClient(transport=settings.transport()) as client:
if parameters: if parameters:
data = await client.post( data = await client.post(
url, url,

View File

@@ -52,7 +52,6 @@ class ApiVersionServiceStub(betterproto.ServiceStub):
class ApiVersionServiceBase(ServiceBase): class ApiVersionServiceBase(ServiceBase):
async def get_api_version( async def get_api_version(
self, api_version_request: "ApiVersionRequest" self, api_version_request: "ApiVersionRequest"
) -> "ApiVersion": ) -> "ApiVersion":

View File

@@ -2485,7 +2485,6 @@ class NetworkServiceStub(betterproto.ServiceStub):
class ActionsServiceBase(ServiceBase): class ActionsServiceBase(ServiceBase):
async def start(self, start_request: "StartRequest") -> "StartResponse": async def start(self, start_request: "StartRequest") -> "StartResponse":
raise grpclib.GRPCError(grpclib.const.Status.UNIMPLEMENTED) raise grpclib.GRPCError(grpclib.const.Status.UNIMPLEMENTED)
@@ -2630,7 +2629,6 @@ class ActionsServiceBase(ServiceBase):
class AuthenticationServiceBase(ServiceBase): class AuthenticationServiceBase(ServiceBase):
async def login(self, login_request: "LoginRequest") -> "LoginResponse": async def login(self, login_request: "LoginRequest") -> "LoginResponse":
raise grpclib.GRPCError(grpclib.const.Status.UNIMPLEMENTED) raise grpclib.GRPCError(grpclib.const.Status.UNIMPLEMENTED)
@@ -2671,7 +2669,6 @@ class AuthenticationServiceBase(ServiceBase):
class CoolingServiceBase(ServiceBase): class CoolingServiceBase(ServiceBase):
async def get_cooling_state( async def get_cooling_state(
self, get_cooling_state_request: "GetCoolingStateRequest" self, get_cooling_state_request: "GetCoolingStateRequest"
) -> "GetCoolingStateResponse": ) -> "GetCoolingStateResponse":
@@ -2716,7 +2713,6 @@ class CoolingServiceBase(ServiceBase):
class PerformanceServiceBase(ServiceBase): class PerformanceServiceBase(ServiceBase):
async def get_tuner_state( async def get_tuner_state(
self, get_tuner_state_request: "GetTunerStateRequest" self, get_tuner_state_request: "GetTunerStateRequest"
) -> "GetTunerStateResponse": ) -> "GetTunerStateResponse":
@@ -2986,7 +2982,6 @@ class PerformanceServiceBase(ServiceBase):
class PoolServiceBase(ServiceBase): class PoolServiceBase(ServiceBase):
async def get_pool_groups( async def get_pool_groups(
self, get_pool_groups_request: "GetPoolGroupsRequest" self, get_pool_groups_request: "GetPoolGroupsRequest"
) -> "GetPoolGroupsResponse": ) -> "GetPoolGroupsResponse":
@@ -3088,7 +3083,6 @@ class PoolServiceBase(ServiceBase):
class ConfigurationServiceBase(ServiceBase): class ConfigurationServiceBase(ServiceBase):
async def get_miner_configuration( async def get_miner_configuration(
self, get_miner_configuration_request: "GetMinerConfigurationRequest" self, get_miner_configuration_request: "GetMinerConfigurationRequest"
) -> "GetMinerConfigurationResponse": ) -> "GetMinerConfigurationResponse":
@@ -3133,7 +3127,6 @@ class ConfigurationServiceBase(ServiceBase):
class LicenseServiceBase(ServiceBase): class LicenseServiceBase(ServiceBase):
async def get_license_state( async def get_license_state(
self, get_license_state_request: "GetLicenseStateRequest" self, get_license_state_request: "GetLicenseStateRequest"
) -> "GetLicenseStateResponse": ) -> "GetLicenseStateResponse":
@@ -3159,7 +3152,6 @@ class LicenseServiceBase(ServiceBase):
class MinerServiceBase(ServiceBase): class MinerServiceBase(ServiceBase):
async def get_miner_status( async def get_miner_status(
self, get_miner_status_request: "GetMinerStatusRequest" self, get_miner_status_request: "GetMinerStatusRequest"
) -> AsyncIterator[GetMinerStatusResponse]: ) -> AsyncIterator[GetMinerStatusResponse]:
@@ -3325,7 +3317,6 @@ class MinerServiceBase(ServiceBase):
class NetworkServiceBase(ServiceBase): class NetworkServiceBase(ServiceBase):
async def get_network_configuration( async def get_network_configuration(
self, get_network_configuration_request: "GetNetworkConfigurationRequest" self, get_network_configuration_request: "GetNetworkConfigurationRequest"
) -> "GetNetworkConfigurationResponse": ) -> "GetNetworkConfigurationResponse":

View File

@@ -60,7 +60,6 @@ class ElphapexWebAPI(BaseWebAPI):
auth = httpx.DigestAuth(self.username, self.pwd) auth = httpx.DigestAuth(self.username, self.pwd)
try: try:
async with httpx.AsyncClient(transport=settings.transport()) as client: async with httpx.AsyncClient(transport=settings.transport()) as client:
if parameters: if parameters:
data = await client.post( data = await client.post(
url, url,

View File

@@ -60,7 +60,6 @@ class HammerWebAPI(BaseWebAPI):
auth = httpx.DigestAuth(self.username, self.pwd) auth = httpx.DigestAuth(self.username, self.pwd)
try: try:
async with httpx.AsyncClient(transport=settings.transport()) as client: async with httpx.AsyncClient(transport=settings.transport()) as client:
if parameters: if parameters:
data = await client.post( data = await client.post(
url, url,