Compare commits

...

34 Commits

Author SHA1 Message Date
UpstreamData
bbc88e175c version: bump version number. 2023-02-22 16:14:23 -07:00
UpstreamData
ff54bea0ed feature: add miner load balancer. 2023-02-22 16:13:48 -07:00
UpstreamData
bcfa807e12 version: bump version number. 2023-02-22 13:00:29 -07:00
UpstreamData
728e94f120 bug: fix OSError.winerror missing on some systems with errno. 2023-02-22 13:00:09 -07:00
UpstreamData
5f2832d632 version: bump version number. 2023-02-22 12:11:37 -07:00
UpstreamData
59e5be280f bug: fix a division by 0 error edge case in MinerData. 2023-02-22 12:10:53 -07:00
UpstreamData
e6424acf5f version: bump version number. 2023-02-22 11:43:42 -07:00
UpstreamData
34389e9133 feature: add M30S++ VH70 chip count. 2023-02-22 11:43:22 -07:00
UpstreamData
b0f7629607 version: bump version number. 2023-02-22 11:22:29 -07:00
UpstreamData
6e50f1f769 feature: add chip counts for M30S++VH50. 2023-02-22 11:21:49 -07:00
Upstream Data
708b506f6f version: bump version number. 2023-02-18 10:10:42 -07:00
Upstream Data
673d63daf0 bug: fix from_dict loading of MinerConfig for new version of BOSMiner. 2023-02-18 10:10:27 -07:00
Upstream Data
ea55fed8d1 version: bump version number. 2023-02-18 09:45:59 -07:00
Upstream Data
f5fd539eba bug: fix weird configuration format with BOS+ machines. 2023-02-18 09:45:38 -07:00
Upstream Data
51a56f441c version: bump version number. 2023-02-18 09:34:29 -07:00
Upstream Data
54310b1d79 bug: fix bad capitalization on S99Bosminer. 2023-02-18 09:34:03 -07:00
Upstream Data
cb30b761dc version: bump version number. 2023-02-16 18:36:38 -07:00
Upstream Data
cb50a7cac8 feature: add support for configuring BOS+ BBB, and add support for new BOS+ config version 2.0. 2023-02-16 18:36:03 -07:00
UpstreamData
738af245cb version: bump version number. 2023-02-16 13:35:45 -07:00
UpstreamData
71e9af1b91 format: improve warning locations to remove warnings when connections are refused. 2023-02-16 13:35:20 -07:00
UpstreamData
1cd2566d0a version: bump version number. 2023-02-16 12:23:29 -07:00
UpstreamData
1f1e5f23a2 bug: fix a bug where not all errors could be handled when scanning. 2023-02-16 12:22:58 -07:00
UpstreamData
3394234e4f version: bump version number. 2023-02-16 08:58:17 -07:00
UpstreamData
e9882124ff formatting: removed print statements. 2023-02-16 08:57:51 -07:00
UpstreamData
1e5b19c149 version: bump version number. 2023-02-16 08:47:15 -07:00
UpstreamData
c9339fec2f bug: fix issues with new versions of braiins OS, and fix bugs with innosilicon miners not returning much data at all. 2023-02-16 08:46:32 -07:00
UpstreamData
018c09e84f version: bump version number. 2023-02-15 14:34:08 -07:00
UpstreamData
46e7f9a569 bug: remove a missed print statement. 2023-02-15 14:31:32 -07:00
UpstreamData
996ab58252 version: bump version number. 2023-02-15 14:19:57 -07:00
UpstreamData
04974d5287 bug: fix reboot and restart on btminer not returning data. 2023-02-15 14:17:57 -07:00
UpstreamData
1a8a5ccb0e version: bump version number. 2023-02-14 10:33:46 -07:00
UpstreamData
4c61c4c345 bug: add MAC address support for stock S9s. 2023-02-14 10:33:14 -07:00
UpstreamData
a6d6bfe73d version: bump version number. 2023-02-14 10:19:20 -07:00
UpstreamData
f159e9ccb4 bug: add additional X19 MAC address check. 2023-02-14 10:18:52 -07:00
16 changed files with 555 additions and 207 deletions

View File

@@ -162,7 +162,7 @@ If you are sure you want to use this command please use API.send_command("{comma
reader, writer = await asyncio.open_connection(str(self.ip), self.port)
# handle OSError 121
except OSError as e:
if getattr(e, "winerror") == "121":
if e.errno == 121:
logging.warning(
f"{self} - ([Hidden] Send Bytes) - Semaphore timeout expired."
)
@@ -173,9 +173,14 @@ If you are sure you want to use this command please use API.send_command("{comma
writer.write(data)
logging.debug(f"{self} - ([Hidden] Send Bytes) - Draining")
await writer.drain()
# instantiate data
ret_data = b""
ret_data = await asyncio.wait_for(reader.read(4096), timeout=timeout)
try:
# Fix for stupid whatsminer bug, reboot/restart seem to not load properly in the loop
# have to receive, save the data, check if there is more data by reading with a short timeout
# append that data if there is more, and then onto the main loop.
ret_data += await asyncio.wait_for(reader.read(1), timeout=1)
except asyncio.TimeoutError:
return ret_data
# loop to receive all the data
logging.debug(f"{self} - ([Hidden] Send Bytes) - Receiving")
@@ -244,6 +249,8 @@ If you are sure you want to use this command please use API.send_command("{comma
str_data = str_data.replace("}{", "},{")
# fix an error with a bmminer return having a specific comma that breaks json.loads()
str_data = str_data.replace("[,{", "[{")
# fix an error with a btminer return having a missing comma. (2023-01-06 version)
str_data = str_data.replace('""temp0', '","temp0')
# fix an error with Avalonminers returning inf and nan
str_data = str_data.replace("info", "1nfo")
str_data = str_data.replace("inf", "0")

View File

@@ -247,21 +247,9 @@ class BTMinerAPI(BaseMinerAPI):
try:
data = await self._send_bytes(enc_command, timeout)
except (asyncio.CancelledError, asyncio.TimeoutError) as e:
if command["cmd"] in ["reboot", "restart_btminer", "power_on", "power_off"]:
logging.info(
f"{self} - (reboot/restart_btminer/power_on/power_off) - Whatsminers currently break this. "
f"Ignoring exception. Command probably worked."
)
# FAKING IT HERE
data = (
b'{"STATUS": "S", "When": 1670966423, "Code": 131, "Msg": "API command OK", "Description": "'
+ command["cmd"].encode("utf-8")
+ b'"}'
)
else:
if ignore_errors:
return {}
raise APIError("No data was returned from the API.")
if ignore_errors:
return {}
raise APIError("No data was returned from the API.")
if not data:
if ignore_errors:

View File

@@ -245,7 +245,9 @@ class MinerConfig:
fan_speed: Manual fan speed to run the fan at (only if temp_mode == "manual").
asicboost: Whether or not to enable asicboost.
autotuning_enabled: Whether or not to enable autotuning.
autotuning_mode: Autotuning mode, either "wattage" or "hashrate".
autotuning_wattage: The wattage to use when autotuning.
autotuning_hashrate: The hashrate to use when autotuning.
dps_enabled: Whether or not to enable dynamic power scaling.
dps_power_step: The amount of power to reduce autotuning by when the miner reaches dangerous temp.
dps_min_power: The minimum power to reduce autotuning to.
@@ -266,7 +268,9 @@ class MinerConfig:
asicboost: bool = None
autotuning_enabled: bool = True
autotuning_wattage: int = 900
autotuning_mode: Literal["power", "hashrate"] = None
autotuning_wattage: int = None
autotuning_hashrate: int = None
dps_enabled: bool = None
dps_power_step: int = None
@@ -349,14 +353,20 @@ class MinerConfig:
self.autotuning_enabled = data[key][_key]
elif _key == "psu_power_limit":
self.autotuning_wattage = data[key][_key]
elif _key == "power_target":
self.autotuning_wattage = data[key][_key]
elif _key == "hashrate_target":
self.autotuning_hashrate = data[key][_key]
elif _key == "mode":
self.autotuning_mode = data[key][_key].replace("_target", "")
if key == "power_scaling":
if key in ["power_scaling", "performance_scaling"]:
for _key in data[key].keys():
if _key == "enabled":
self.dps_enabled = data[key][_key]
elif _key == "power_step":
self.dps_power_step = data[key][_key]
elif _key == "min_psu_power_limit":
elif _key in ["min_psu_power_limit", "min_power_target"]:
self.dps_min_power = data[key][_key]
elif _key == "shutdown_enabled":
self.dps_shutdown_enabled = data[key][_key]
@@ -391,8 +401,8 @@ class MinerConfig:
pool_groups = []
for group in data["pool_groups"]:
pool_groups.append(_PoolGroup().from_dict(group))
for key in data.keys():
if getattr(self, key) and not key == "pool_groups":
for key in data:
if hasattr(self, key) and not key == "pool_groups":
setattr(self, key, data[key])
self.pool_groups = pool_groups
return self
@@ -481,8 +491,8 @@ class MinerConfig:
cfg = {
"format": {
"version": "1.2+",
"model": f"Antminer {model}",
"generator": "Upstream Config Utility",
"model": f"Antminer {model.replace('j', 'J')}",
"generator": "pyasic",
"timestamp": int(time.time()),
},
"group": [
@@ -499,9 +509,19 @@ class MinerConfig:
if self.autotuning_enabled or self.autotuning_wattage:
cfg["autotuning"] = {}
if self.autotuning_enabled:
cfg["autotuning"]["enabled"] = self.autotuning_enabled
if self.autotuning_wattage:
cfg["autotuning"]["psu_power_limit"] = self.autotuning_wattage
cfg["autotuning"]["enabled"] = True
else:
cfg["autotuning"]["enabled"] = False
if self.autotuning_mode:
cfg["format"]["version"] = "2.0"
cfg["autotuning"]["mode"] = self.autotuning_mode + "_target"
if self.autotuning_wattage:
cfg["autotuning"]["power_target"] = self.autotuning_wattage
elif self.autotuning_hashrate:
cfg["autotuning"]["hashrate_target"] = self.autotuning_hashrate
else:
if self.autotuning_wattage:
cfg["autotuning"]["psu_power_limit"] = self.autotuning_wattage
if self.asicboost:
cfg["hash_chain_global"] = {}
@@ -525,7 +545,10 @@ class MinerConfig:
if self.dps_power_step:
cfg["power_scaling"]["power_step"] = self.dps_power_step
if self.dps_min_power:
cfg["power_scaling"]["min_psu_power_limit"] = self.dps_min_power
if cfg["format"]["version"] == "2.0":
cfg["power_scaling"]["min_power_target"] = self.dps_min_power
else:
cfg["power_scaling"]["min_psu_power_limit"] = self.dps_min_power
if self.dps_shutdown_enabled:
cfg["power_scaling"]["shutdown_enabled"] = self.dps_shutdown_enabled
if self.dps_shutdown_duration:

View File

@@ -403,6 +403,8 @@ class MinerData:
@property
def percent_ideal(self): # noqa - Skip PyCharm inspection
if self.total_chips == 0 or self.ideal_chips == 0:
return 0
return round((self.total_chips / self.ideal_chips) * 100)
@percent_ideal.setter

View File

@@ -29,6 +29,20 @@ class APIError(Exception):
return "Incorrect API parameters."
class PhaseBalancingError(Exception):
def __init__(self, *args):
if args:
self.message = args[0]
else:
self.message = None
def __str__(self):
if self.message:
return f"{self.message}"
else:
return "Failed to balance phase."
class APIWarning(Warning):
def __init__(self, *args):
if args:

336
pyasic/load/__init__.py Normal file
View File

@@ -0,0 +1,336 @@
# ------------------------------------------------------------------------------
# Copyright 2022 Upstream Data Inc -
# -
# Licensed under the Apache License, Version 2.0 (the "License"); -
# you may not use this file except in compliance with the License. -
# You may obtain a copy of the License at -
# -
# http://www.apache.org/licenses/LICENSE-2.0 -
# -
# Unless required by applicable law or agreed to in writing, software -
# distributed under the License is distributed on an "AS IS" BASIS, -
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -
# See the License for the specific language governing permissions and -
# limitations under the License. -
# ------------------------------------------------------------------------------
import asyncio
import logging
from typing import List, Union
# from pyasic.errors import PhaseBalancingError
from pyasic.errors import APIError
from pyasic.miners import AnyMiner
from pyasic.miners._backends import X19, BOSMiner, BTMiner
from pyasic.miners._types import S9, S17, T17, S17e, S17Plus, S17Pro, T17e, T17Plus
# from pprint import pprint as print
FAN_USAGE = 50 # 50 W per fan
class MinerLoadBalancer:
"""A load balancer for miners. Can be passed a list of `AnyMiner`, or a list of phases (lists of `AnyMiner`)."""
def __init__(
self,
phases: Union[List[List[AnyMiner]], None] = None,
):
self.phases = [_MinerPhaseBalancer(phase) for phase in phases]
async def balance(self, wattage: int) -> int:
phase_wattage = wattage // len(self.phases)
setpoints = await asyncio.gather(
*[phase.get_balance_setpoints(phase_wattage) for phase in self.phases]
)
tasks = []
total_wattage = 0
for setpoint in setpoints:
wattage_set = 0
for miner in setpoint:
if setpoint[miner]["set"] == "on":
wattage_set += setpoint[miner]["max"]
tasks.append(setpoint[miner]["miner"].resume_mining())
elif setpoint[miner]["set"] == "off":
wattage_set += setpoint[miner]["min"]
tasks.append(setpoint[miner]["miner"].stop_mining())
else:
wattage_set += setpoint[miner]["set"]
tasks.append(
setpoint[miner]["miner"].set_power_limit(setpoint[miner]["set"])
)
total_wattage += wattage_set
await asyncio.gather(*tasks)
return total_wattage
class _MinerPhaseBalancer:
def __init__(self, miners: List[AnyMiner]):
self.miners = {
str(miner.ip): {
"miner": miner,
"set": 0,
"min": miner.fan_count * FAN_USAGE,
}
for miner in miners
}
for miner in miners:
if (
isinstance(miner, BTMiner)
and not (miner.model.startswith("M2") if miner.model else True)
) or isinstance(miner, BOSMiner):
if isinstance(miner, S9):
self.miners[str(miner.ip)]["tune"] = True
self.miners[str(miner.ip)]["shutdown"] = True
self.miners[str(miner.ip)]["max"] = 1400
elif True in [
isinstance(miner, x)
for x in [S17, S17Plus, S17Pro, S17e, T17, T17Plus, T17e]
]:
self.miners[str(miner.ip)]["tune"] = True
self.miners[str(miner.ip)]["shutdown"] = True
self.miners[str(miner.ip)]["max"] = 2400
else:
self.miners[str(miner.ip)]["tune"] = True
self.miners[str(miner.ip)]["shutdown"] = True
self.miners[str(miner.ip)]["max"] = 3600
elif isinstance(miner, X19):
self.miners[str(miner.ip)]["tune"] = False
self.miners[str(miner.ip)]["shutdown"] = True
self.miners[str(miner.ip)]["max"] = 3600
elif isinstance(miner, BTMiner):
self.miners[str(miner.ip)]["tune"] = False
self.miners[str(miner.ip)]["shutdown"] = True
self.miners[str(miner.ip)]["max"] = 3600
if miner.model:
if miner.model.startswith("M2"):
self.miners[str(miner.ip)]["tune"] = False
self.miners[str(miner.ip)]["shutdown"] = True
self.miners[str(miner.ip)]["max"] = 2400
else:
self.miners[str(miner.ip)]["tune"] = False
self.miners[str(miner.ip)]["shutdown"] = False
self.miners[str(miner.ip)]["max"] = 3600
self.miners[str(miner.ip)]["min"] = 3600
async def balance(self, wattage: int) -> int:
setpoint = await self.get_balance_setpoints(wattage)
wattage_set = 0
tasks = []
for miner in setpoint:
if setpoint[miner]["set"] == "on":
wattage_set += setpoint[miner]["max"]
tasks.append(setpoint[miner]["miner"].resume_mining())
elif setpoint[miner]["set"] == "off":
wattage_set += setpoint[miner]["min"]
tasks.append(setpoint[miner]["miner"].stop_mining())
else:
wattage_set += setpoint[miner]["set"]
tasks.append(
setpoint[miner]["miner"].set_power_limit(setpoint[miner]["set"])
)
await asyncio.gather(*tasks)
return wattage_set
async def get_balance_setpoints(self, wattage: int) -> dict:
# gather data needed to optimize shutdown only miners
dp = ["hashrate", "wattage", "wattage_limit"]
data = await asyncio.gather(
*[
self.miners[miner]["miner"].get_data(data_to_get=dp)
for miner in self.miners
]
)
for data_point in data:
if (not self.miners[data_point.ip]["tune"]) and (
not self.miners[data_point.ip]["shutdown"]
):
# cant do anything with it so need to find a semi-accurate power limit
if not data_point.wattage_limit == -1:
self.miners[data_point.ip]["max"] = int(data_point.wattage_limit)
self.miners[data_point.ip]["min"] = int(data_point.wattage_limit)
elif not data_point.wattage == -1:
self.miners[data_point.ip]["max"] = int(data_point.wattage)
self.miners[data_point.ip]["min"] = int(data_point.wattage)
max_tune_wattage = sum(
[miner["max"] for miner in self.miners.values() if miner["tune"]]
)
max_shutdown_wattage = sum(
[
miner["max"]
for miner in self.miners.values()
if (not miner["tune"]) and (miner["shutdown"])
]
)
max_other_wattage = sum(
[
miner["max"]
for miner in self.miners.values()
if (not miner["tune"]) and (not miner["shutdown"])
]
)
min_tune_wattage = sum(
[miner["min"] for miner in self.miners.values() if miner["tune"]]
)
min_shutdown_wattage = sum(
[
miner["min"]
for miner in self.miners.values()
if (not miner["tune"]) and (miner["shutdown"])
]
)
# min_other_wattage = sum([miner["min"] for miner in self.miners.values() if (not miner["tune"]) and (not miner["shutdown"])])
# make sure wattage isnt set too high
if wattage > (max_tune_wattage + max_shutdown_wattage + max_other_wattage):
raise APIError(
f"Wattage setpoint is too high, setpoint: {wattage}W, max: {max_tune_wattage + max_shutdown_wattage + max_other_wattage}W"
) # PhaseBalancingError(f"Wattage setpoint is too high, setpoint: {wattage}W, max: {max_tune_wattage + max_shutdown_wattage + max_other_wattage}W")
# should now know wattage limits and which can be tuned/shutdown
# check if 1/2 max of the miners which can be tuned is low enough
if (max_tune_wattage / 2) + max_shutdown_wattage + max_other_wattage < wattage:
useable_wattage = wattage - (max_other_wattage + max_shutdown_wattage)
useable_miners = len(
[m for m in self.miners.values() if (m["set"] == 0) and (m["tune"])]
)
if not useable_miners == 0:
watts_per_miner = useable_wattage // useable_miners
# loop through and set useable miners to wattage
for miner in self.miners:
if (self.miners[miner]["set"] == 0) and (
self.miners[miner]["tune"]
):
self.miners[miner]["set"] = watts_per_miner
elif self.miners[miner]["set"] == 0 and (
self.miners[miner]["shutdown"]
):
self.miners[miner]["set"] = "on"
# check if shutting down miners will help
elif (
max_tune_wattage / 2
) + min_shutdown_wattage + max_other_wattage < wattage:
# tuneable inclusive since could be S9 BOS+ and S19 Stock, would rather shut down the S9, tuneable should always support shutdown
useable_wattage = wattage - (
min_tune_wattage + max_other_wattage + min_shutdown_wattage
)
for miner in sorted(
[miner for miner in self.miners.values() if miner["shutdown"]],
key=lambda x: x["max"],
reverse=True,
):
if miner["tune"]:
miner_min_watt_use = miner["max"] / 2
useable_wattage -= miner_min_watt_use - miner["min"]
if useable_wattage < 0:
useable_wattage += miner_min_watt_use - miner["min"]
self.miners[str(miner["miner"].ip)]["set"] = "off"
else:
miner_min_watt_use = miner["max"]
useable_wattage -= miner_min_watt_use - miner["min"]
if useable_wattage < 0:
useable_wattage += miner_min_watt_use - miner["min"]
self.miners[str(miner["miner"].ip)]["set"] = "off"
new_shutdown_wattage = sum(
[
miner["max"] if miner["set"] == 0 else miner["min"]
for miner in self.miners.values()
if miner["shutdown"] and not miner["tune"]
]
)
new_tune_wattage = sum(
[
miner["min"]
for miner in self.miners.values()
if miner["tune"] and miner["set"] == "off"
]
)
useable_wattage = wattage - (
new_tune_wattage + max_other_wattage + new_shutdown_wattage
)
useable_miners = len(
[m for m in self.miners.values() if (m["set"] == 0) and (m["tune"])]
)
if not useable_miners == 0:
watts_per_miner = useable_wattage // useable_miners
# loop through and set useable miners to wattage
for miner in self.miners:
if (self.miners[miner]["set"] == 0) and (
self.miners[miner]["tune"]
):
self.miners[miner]["set"] = watts_per_miner
elif self.miners[miner]["set"] == 0 and (
self.miners[miner]["shutdown"]
):
self.miners[miner]["set"] = "on"
# check if shutting down tuneable miners will do it
elif min_tune_wattage + min_shutdown_wattage + max_other_wattage < wattage:
# all miners that can be shutdown need to be
for miner in self.miners:
if (not self.miners[miner]["tune"]) and (
self.miners[miner]["shutdown"]
):
self.miners[miner]["set"] = "off"
# calculate wattage usable by tuneable miners
useable_wattage = wattage - (
min_tune_wattage + max_other_wattage + min_shutdown_wattage
)
# loop through miners to see how much is actually useable
# sort the largest first
for miner in sorted(
[
miner
for miner in self.miners.values()
if miner["tune"] and miner["shutdown"]
],
key=lambda x: x["max"],
reverse=True,
):
# add min to useable wattage since it was removed earlier, and remove 1/2 tuner max
useable_wattage -= (miner["max"] / 2) - miner["min"]
if useable_wattage < 0:
useable_wattage += (miner["max"] / 2) - miner["min"]
self.miners[str(miner["miner"].ip)]["set"] = "off"
new_tune_wattage = sum(
[
miner["min"]
for miner in self.miners.values()
if miner["tune"] and miner["set"] == "off"
]
)
useable_wattage = wattage - (
new_tune_wattage + max_other_wattage + min_shutdown_wattage
)
useable_miners = len(
[m for m in self.miners.values() if (m["set"] == 0) and (m["tune"])]
)
if not useable_miners == 0:
watts_per_miner = useable_wattage // useable_miners
# loop through and set useable miners to wattage
for miner in self.miners:
if (self.miners[miner]["set"] == 0) and (
self.miners[miner]["tune"]
):
self.miners[miner]["set"] = watts_per_miner
elif self.miners[miner]["set"] == 0 and (
self.miners[miner]["shutdown"]
):
self.miners[miner]["set"] = "on"
else:
raise APIError(
f"Wattage setpoint is too low, setpoint: {wattage}W, min: {min_tune_wattage + min_shutdown_wattage + max_other_wattage}W"
) # PhaseBalancingError(f"Wattage setpoint is too low, setpoint: {wattage}W, min: {min_tune_wattage + min_shutdown_wattage + max_other_wattage}W")
return self.miners

View File

@@ -126,6 +126,13 @@ class X19(BMMiner):
except KeyError:
pass
try:
data = await self.send_web_command("get_network_info")
if data:
return data["macaddr"]
except KeyError:
pass
async def get_errors(self) -> List[MinerErrorData]:
errors = []
data = await self.send_web_command("summary")

View File

@@ -198,11 +198,8 @@ class BOSMiner(BaseMiner):
return self.config
if conn:
async with conn:
logging.debug(f"{self}: Opening SFTP connection.")
async with conn.start_sftp_client() as sftp:
logging.debug(f"{self}: Reading config file.")
async with sftp.open("/etc/bosminer.toml") as file:
toml_data = toml.loads(await file.read())
# good ol' BBB compatibility :/
toml_data = toml.loads((await conn.run("cat /etc/bosminer.toml")).stdout)
logging.debug(f"{self}: Converting config file.")
cfg = MinerConfig().from_raw(toml_data)
self.config = cfg
@@ -219,14 +216,28 @@ class BOSMiner(BaseMiner):
except (asyncssh.Error, OSError):
return None
async with conn:
await conn.run("/etc/init.d/bosminer stop")
logging.debug(f"{self}: Opening SFTP connection.")
async with conn.start_sftp_client() as sftp:
logging.debug(f"{self}: Opening config file.")
async with sftp.open("/etc/bosminer.toml", "w+") as file:
await file.write(toml_conf)
logging.debug(f"{self}: Restarting BOSMiner")
await conn.run("/etc/init.d/bosminer start")
# BBB check because bitmain suxx
bbb_check = await conn.run("if [ ! -f /etc/init.d/bosminer ]; then echo '1'; else echo '0'; fi;")
bbb = bbb_check.stdout.strip() == "1"
if not bbb:
await conn.run("/etc/init.d/bosminer stop")
logging.debug(f"{self}: Opening SFTP connection.")
async with conn.start_sftp_client() as sftp:
logging.debug(f"{self}: Opening config file.")
async with sftp.open("/etc/bosminer.toml", "w+") as file:
await file.write(toml_conf)
logging.debug(f"{self}: Restarting BOSMiner")
await conn.run("/etc/init.d/bosminer start")
# I really hate BBB, please get rid of it if you have it
else:
await conn.run("/etc/init.d/S99bosminer stop")
logging.debug(f"{self}: BBB sending config")
await conn.run("echo '" + toml_conf + "' > /etc/bosminer.toml")
logging.debug(f"{self}: BBB restarting bosminer.")
await conn.run("/etc/init.d/S99bosminer start")
async def set_power_limit(self, wattage: int) -> bool:
try:
@@ -483,10 +494,9 @@ class BOSMiner(BaseMiner):
api_devs = d["devs"][0]
except (KeyError, IndexError):
api_devs = None
if api_temps:
try:
offset = 6 if api_temps["TEMPS"][0]["ID"] in [6, 7, 8] else 0
offset = 6 if api_temps["TEMPS"][0]["ID"] in [6, 7, 8] else 1
for board in api_temps["TEMPS"]:
_id = board["ID"] - offset
@@ -499,7 +509,7 @@ class BOSMiner(BaseMiner):
if api_devdetails:
try:
offset = 6 if api_devdetails["DEVDETAILS"][0]["ID"] in [6, 7, 8] else 0
offset = 6 if api_devdetails["DEVDETAILS"][0]["ID"] in [6, 7, 8] else 1
for board in api_devdetails["DEVDETAILS"]:
_id = board["ID"] - offset
@@ -511,7 +521,7 @@ class BOSMiner(BaseMiner):
if api_devs:
try:
offset = 6 if api_devs["DEVS"][0]["ID"] in [6, 7, 8] else 0
offset = 6 if api_devs["DEVS"][0]["ID"] in [6, 7, 8] else 1
for board in api_devs["DEVS"]:
_id = board["ID"] - offset

View File

@@ -174,10 +174,7 @@ class M30SPlusPlusVH50(WhatsMiner): # noqa - ignore ABC method implementation
super().__init__()
self.ip = ip
self.model = "M30S++ VH50"
self.nominal_chips = 0
warnings.warn(
"Unknown chip count for miner type M30S++ VH50, please open an issue on GitHub (https://github.com/UpstreamData/pyasic)."
)
self.nominal_chips = 74
self.fan_count = 2
@@ -195,10 +192,7 @@ class M30SPlusPlusVH70(WhatsMiner): # noqa - ignore ABC method implementation
super().__init__()
self.ip = ip
self.model = "M30S++ VH70"
self.nominal_chips = 0
warnings.warn(
"Unknown chip count for miner type M30S++ VH70, please open an issue on GitHub (https://github.com/UpstreamData/pyasic)."
)
self.nominal_chips = 70
self.fan_count = 2

View File

@@ -14,9 +14,54 @@
# limitations under the License. -
# ------------------------------------------------------------------------------
import json
from typing import Optional, Union
import httpx
from pyasic.miners._backends import BMMiner # noqa - Ignore access to _module
from pyasic.miners._types import S9 # noqa - Ignore access to _module
from pyasic.settings import PyasicSettings
class BMMinerS9(BMMiner, S9):
pass
def __init__(self, ip: str, api_ver: str = "0.0.0") -> None:
super().__init__(ip, api_ver=api_ver)
self.ip = ip
self.uname = "root"
self.pwd = PyasicSettings().global_x19_password
async def send_web_command(
self, command: str, params: dict = None
) -> Optional[dict]:
url = f"http://{self.ip}/cgi-bin/{command}.cgi"
auth = httpx.DigestAuth(self.uname, self.pwd)
try:
async with httpx.AsyncClient() as client:
if params:
data = await client.post(url, data=params, auth=auth)
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 get_mac(self) -> Union[str, None]:
try:
data = await self.send_web_command("get_system_info")
if data:
return data["macaddr"]
except KeyError:
pass
try:
data = await self.send_web_command("get_network_info")
if data:
return data["macaddr"]
except KeyError:
pass

View File

@@ -162,7 +162,7 @@ class BaseMiner(ABC):
@abstractmethod
async def resume_mining(self) -> bool:
"""Stop the mining process of the miner.
"""Resume the mining process of the miner.
Returns:
A boolean value of the success of resuming the mining process.

View File

@@ -136,10 +136,10 @@ class CGMinerInnosiliconT3HPlus(CGMiner, InnosiliconT3HPlus):
async def get_mac(
self,
web_getAll: dict = None,
web_getAll: dict = None, # noqa
web_overview: dict = None, # noqa: named this way for automatic functionality
) -> Optional[str]:
web_all_data = web_getAll
web_all_data = web_getAll.get("all")
if not web_all_data and not web_overview:
try:
web_overview = await self.send_web_command("overview")
@@ -183,7 +183,7 @@ class CGMinerInnosiliconT3HPlus(CGMiner, InnosiliconT3HPlus):
api_summary: dict = None,
web_getAll: dict = None, # noqa: named this way for automatic functionality
) -> Optional[float]:
web_all_data = web_getAll
web_all_data = web_getAll.get("all")
if not api_summary and not web_all_data:
try:
api_summary = await self.api.summary()
@@ -209,7 +209,7 @@ class CGMinerInnosiliconT3HPlus(CGMiner, InnosiliconT3HPlus):
api_stats: dict = None,
web_getAll: dict = None, # noqa: named this way for automatic functionality
) -> List[HashBoard]:
web_all_data = web_getAll
web_all_data = web_getAll.get("all")
hashboards = [
HashBoard(slot=i, expected_chips=self.nominal_chips)
for i in range(self.ideal_hashboards)
@@ -231,8 +231,9 @@ class CGMinerInnosiliconT3HPlus(CGMiner, InnosiliconT3HPlus):
if api_stats:
if api_stats.get("STATS"):
for idx, board in enumerate(api_stats["STATS"]):
for board in api_stats["STATS"]:
try:
idx = board["Chain ID"]
chips = board["Num active chips"]
except KeyError:
pass
@@ -242,25 +243,31 @@ class CGMinerInnosiliconT3HPlus(CGMiner, InnosiliconT3HPlus):
if web_all_data:
if web_all_data.get("chain"):
for idx, board in enumerate(web_all_data["chain"]):
temp = board.get("Temp min")
if temp:
hashboards[idx].temp = round(temp)
for board in web_all_data["chain"]:
idx = board.get("ASC")
if idx is not None:
temp = board.get("Temp min")
if temp:
hashboards[idx].temp = round(temp)
hashrate = board.get("Hash Rate H")
if hashrate:
hashboards[idx].hashrate = round(hashrate / 1000000000000, 2)
hashrate = board.get("Hash Rate H")
if hashrate:
hashboards[idx].hashrate = round(
hashrate / 1000000000000, 2
)
chip_temp = board.get("Temp max")
if chip_temp:
hashboards[idx].chip_temp = round(chip_temp)
chip_temp = board.get("Temp max")
if chip_temp:
hashboards[idx].chip_temp = round(chip_temp)
return hashboards
async def get_wattage(
self, web_getAll: dict = None
) -> Optional[int]: # noqa: named this way for automatic functionality
web_all_data = web_getAll
self,
web_getAll: dict = None,
api_stats: dict = None, # noqa: named this way for automatic functionality
) -> Optional[int]:
web_all_data = web_getAll.get("all")
if not web_all_data:
try:
web_all_data = await self.send_web_command("getAll")
@@ -275,11 +282,28 @@ class CGMinerInnosiliconT3HPlus(CGMiner, InnosiliconT3HPlus):
except KeyError:
pass
if not api_stats:
try:
api_stats = await self.api.stats()
except APIError:
pass
if api_stats:
if api_stats.get("STATS"):
for board in api_stats["STATS"]:
try:
wattage = board["power"]
except KeyError:
pass
else:
wattage = int(wattage)
return wattage
async def get_fans(
self,
web_getAll: dict = None, # noqa: named this way for automatic functionality
) -> List[Fan]:
web_all_data = web_getAll
web_all_data = web_getAll.get("all")
if not web_all_data:
try:
web_all_data = await self.send_web_command("getAll")
@@ -350,3 +374,24 @@ class CGMinerInnosiliconT3HPlus(CGMiner, InnosiliconT3HPlus):
if not err == 0:
errors.append(InnosiliconError(error_code=err))
return errors
async def get_wattage_limit(self, web_getAll: dict = None) -> Optional[int]:
web_all_data = web_getAll.get("all")
if not web_all_data:
try:
web_all_data = await self.send_web_command("getAll")
except APIError:
pass
else:
web_all_data = web_all_data["all"]
if web_all_data:
try:
level = web_all_data["running_mode"]["level"]
except KeyError:
pass
else:
# this is very possibly not correct.
level = int(level)
limit = 1250 + (250 * level)
return limit

View File

@@ -108,10 +108,6 @@ class MinerNetwork:
# clear cached miners
MinerFactory().clear_cached_miners()
# create a list of tasks and miner IPs
scan_tasks = []
miners = []
limit = asyncio.Semaphore(PyasicSettings().network_scan_threads)
miners = await asyncio.gather(
*[self.ping_and_get_miner(host, limit) for host in local_network.hosts()]
@@ -140,8 +136,6 @@ class MinerNetwork:
local_network = self.get_network()
# create a list of scan tasks
scan_tasks = []
limit = asyncio.Semaphore(PyasicSettings().network_scan_threads)
miners = asyncio.as_completed(
[
@@ -213,14 +207,13 @@ async def ping_miner(
except asyncio.exceptions.TimeoutError:
# ping failed if we time out
continue
except ConnectionRefusedError:
except (ConnectionRefusedError, OSError):
# handle for other connection errors
logging.debug(f"{str(ip)}: Connection Refused.")
raise ConnectionRefusedError
# ping failed, likely with an exception
except Exception as e:
logging.warning(f"{str(ip)}: {e}")
continue
logging.warning(f"{str(ip)}: Ping And Get Miner Exception: {e}")
raise ConnectionRefusedError
return
@@ -228,8 +221,8 @@ async def ping_and_get_miner(
ip: ipaddress.ip_address, port=4028
) -> Union[None, AnyMiner]:
for i in range(PyasicSettings().network_ping_retries):
connection_fut = asyncio.open_connection(str(ip), port)
try:
connection_fut = asyncio.open_connection(str(ip), port)
# get the read and write streams from the connection
reader, writer = await asyncio.wait_for(
connection_fut, timeout=PyasicSettings().network_ping_timeout
@@ -243,13 +236,11 @@ async def ping_and_get_miner(
except asyncio.exceptions.TimeoutError:
# ping failed if we time out
continue
except ConnectionRefusedError as e:
except (ConnectionRefusedError, OSError):
# handle for other connection errors
logging.debug(f"{str(ip)}: Connection Refused.")
raise e
# ping failed, likely with an exception
raise ConnectionRefusedError
except Exception as e:
logging.warning(f"{str(ip)}: Ping And Get Miner Exception: {e}")
raise e
continue
raise ConnectionRefusedError
return

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "pyasic"
version = "0.28.0"
version = "0.30.0"
description = "A set of modules for interfacing with many common types of ASIC bitcoin miners, using both their API and SSH."
authors = ["UpstreamData <brett@upstreamdata.ca>"]
repository = "https://github.com/UpstreamData/pyasic"

View File

@@ -16,7 +16,6 @@
import unittest
from tests.config_tests import ConfigTest
from tests.miners_tests import MinerFactoryTest, MinersTest
from tests.network_tests import NetworkTest

View File

@@ -1,113 +0,0 @@
# ------------------------------------------------------------------------------
# Copyright 2022 Upstream Data Inc -
# -
# Licensed under the Apache License, Version 2.0 (the "License"); -
# you may not use this file except in compliance with the License. -
# You may obtain a copy of the License at -
# -
# http://www.apache.org/licenses/LICENSE-2.0 -
# -
# Unless required by applicable law or agreed to in writing, software -
# distributed under the License is distributed on an "AS IS" BASIS, -
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -
# See the License for the specific language governing permissions and -
# limitations under the License. -
# ------------------------------------------------------------------------------
import unittest
from pyasic.config import MinerConfig, _Pool, _PoolGroup # noqa
from tests.test_data import (
bosminer_api_pools,
bosminer_config_pools,
x19_api_pools,
x19_web_pools,
)
class ConfigTest(unittest.TestCase):
def setUp(self) -> None:
self.test_config = MinerConfig(
pool_groups=[
_PoolGroup(
quota=1,
group_name="TEST",
pools=[
_Pool(
url="stratum+tcp://pyasic.testpool_1.pool:3333",
username="pyasic.test",
password="123",
),
_Pool(
url="stratum+tcp://pyasic.testpool_2.pool:3333",
username="pyasic.test",
password="123",
),
],
)
],
temp_mode="auto",
temp_target=70.0,
temp_hot=80.0,
temp_dangerous=100.0,
fan_speed=None,
autotuning_enabled=True,
autotuning_wattage=900,
)
def test_config_from_raw(self):
bos_config = MinerConfig().from_raw(bosminer_config_pools)
bos_config.pool_groups[0].group_name = "TEST"
with self.subTest(
msg="Testing BOSMiner config file config.", bos_config=bos_config
):
self.assertEqual(bos_config, self.test_config)
x19_cfg = MinerConfig().from_raw(x19_web_pools)
x19_cfg.pool_groups[0].group_name = "TEST"
with self.subTest(msg="Testing X19 API config.", x19_cfg=x19_cfg):
self.assertEqual(x19_cfg, self.test_config)
def test_config_from_api(self):
bos_cfg = MinerConfig().from_api(bosminer_api_pools["POOLS"])
bos_cfg.pool_groups[0].group_name = "TEST"
with self.subTest(msg="Testing BOSMiner API config.", bos_cfg=bos_cfg):
self.assertEqual(bos_cfg, self.test_config)
x19_cfg = MinerConfig().from_api(x19_api_pools["POOLS"])
x19_cfg.pool_groups[0].group_name = "TEST"
with self.subTest(msg="Testing X19 API config.", x19_cfg=x19_cfg):
self.assertEqual(x19_cfg, self.test_config)
def test_config_as_types(self):
cfg = MinerConfig().from_api(bosminer_api_pools["POOLS"])
cfg.pool_groups[0].group_name = "TEST"
commands = [
func
for func in
# each function in self
dir(cfg)
if callable(getattr(cfg, func)) and
# no __ methods
not func.startswith("__")
]
for command in [cmd for cmd in commands if cmd.startswith("as_")]:
with self.subTest():
output = getattr(cfg, command)()
self.assertEqual(output, getattr(self.test_config, command)())
if f"from_{command.split('as_')[1]}" in commands:
self.assertEqual(
getattr(MinerConfig(), f"from_{command.split('as_')[1]}")(
output
),
self.test_config,
)
if __name__ == "__main__":
unittest.main()