Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d7a13433b | ||
|
|
e14a4791b2 | ||
|
|
0e76d4550b | ||
|
|
2d424025e9 | ||
|
|
bf4903ce4b | ||
|
|
4f7f6bf045 | ||
|
|
824890ec97 | ||
|
|
ce9d7ffb0f | ||
|
|
183b4934c1 | ||
|
|
3d2b260b17 | ||
|
|
f88c1734eb | ||
|
|
b897ca8363 | ||
|
|
dba341fdae | ||
|
|
837794bd57 | ||
|
|
36d16c7235 | ||
|
|
7797023689 |
@@ -62,6 +62,8 @@ def backend_str(backend: MinerTypes) -> str:
|
||||
return "Stock Firmware Volcminers"
|
||||
case MinerTypes.ELPHAPEX:
|
||||
return "Stock Firmware Elphapex Miners"
|
||||
case MinerTypes.MSKMINER:
|
||||
return "MSKMiner Firmware Miners"
|
||||
raise TypeError("Unknown miner backend, cannot generate docs")
|
||||
|
||||
|
||||
|
||||
@@ -716,6 +716,19 @@
|
||||
show_root_heading: false
|
||||
heading_level: 0
|
||||
|
||||
## S19 No PIC (Stock)
|
||||
|
||||
- [ ] Shutdowns
|
||||
- [ ] Power Modes
|
||||
- [ ] Setpoints
|
||||
- [ ] Presets
|
||||
|
||||
::: pyasic.miners.antminer.mskminer.X19.S19.MSKMinerS19NoPIC
|
||||
handler: python
|
||||
options:
|
||||
show_root_heading: false
|
||||
heading_level: 0
|
||||
|
||||
## S19 (LuxOS)
|
||||
|
||||
- [x] Shutdowns
|
||||
|
||||
16
docs/miners/avalonminer/A15X.md
Normal file
16
docs/miners/avalonminer/A15X.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# pyasic
|
||||
## A15X Models
|
||||
|
||||
## Avalon 1566 (Stock)
|
||||
|
||||
- [ ] Shutdowns
|
||||
- [ ] Power Modes
|
||||
- [ ] Setpoints
|
||||
- [ ] Presets
|
||||
|
||||
::: pyasic.miners.avalonminer.cgminer.A15X.A1566.CGMinerAvalon1566
|
||||
handler: python
|
||||
options:
|
||||
show_root_heading: false
|
||||
heading_level: 0
|
||||
|
||||
@@ -554,6 +554,12 @@ details {
|
||||
<li><a href="../avalonminer/nano#avalon-nano-3-stock">Avalon Nano 3 (Stock)</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
<details>
|
||||
<summary>A15X Series:</summary>
|
||||
<ul>
|
||||
<li><a href="../avalonminer/A15X#avalon-1566-stock">Avalon 1566 (Stock)</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</ul>
|
||||
</details>
|
||||
<details>
|
||||
@@ -769,6 +775,17 @@ details {
|
||||
</ul>
|
||||
</details>
|
||||
<details>
|
||||
<summary>MSKMiner Firmware Miners:</summary>
|
||||
<ul>
|
||||
<details>
|
||||
<summary>X19 Series:</summary>
|
||||
<ul>
|
||||
<li><a href="../antminer/X19#s19-no-pic-stock">S19 No PIC (Stock)</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</ul>
|
||||
</details>
|
||||
<details>
|
||||
<summary>LuxOS Firmware Miners:</summary>
|
||||
<ul>
|
||||
<details>
|
||||
|
||||
@@ -56,6 +56,16 @@ class MinerConfig(BaseModel):
|
||||
**self.temperature.as_am_modern(),
|
||||
}
|
||||
|
||||
def as_elphapex(self, user_suffix: str | None = None) -> dict:
|
||||
"""Generates the configuration in the format suitable for modern Elphapex."""
|
||||
return {
|
||||
**self.fan_mode.as_elphapex(),
|
||||
"fc-freq-level": "100",
|
||||
**self.mining_mode.as_elphapex(),
|
||||
**self.pools.as_elphapex(user_suffix=user_suffix),
|
||||
**self.temperature.as_elphapex(),
|
||||
}
|
||||
|
||||
def as_wm(self, user_suffix: str | None = None) -> dict:
|
||||
"""Generates the configuration in the format suitable for Whatsminers."""
|
||||
return {
|
||||
@@ -199,6 +209,15 @@ class MinerConfig(BaseModel):
|
||||
fan_mode=FanModeConfig.from_am_modern(web_conf),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_elphapex(cls, web_conf: dict) -> "MinerConfig":
|
||||
"""Constructs a MinerConfig object from web configuration for modern Antminers."""
|
||||
return cls(
|
||||
pools=PoolConfig.from_elphapex(web_conf),
|
||||
mining_mode=MiningModeConfig.from_elphapex(web_conf),
|
||||
fan_mode=FanModeConfig.from_elphapex(web_conf),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_am_old(cls, web_conf: dict) -> "MinerConfig":
|
||||
"""Constructs a MinerConfig object from web configuration for old versions of Antminers."""
|
||||
|
||||
@@ -67,6 +67,9 @@ class MinerConfigOption(Enum):
|
||||
def as_luxos(self) -> dict:
|
||||
return self.value.as_luxos()
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return self.value.as_elphapex()
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self.value(*args, **kwargs)
|
||||
|
||||
@@ -131,6 +134,9 @@ class MinerConfigValue(BaseModel):
|
||||
def as_luxos(self) -> dict:
|
||||
return {}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {}
|
||||
|
||||
def __getitem__(self, item):
|
||||
try:
|
||||
return getattr(self, item)
|
||||
|
||||
@@ -55,6 +55,9 @@ class FanModeNormal(MinerConfigValue):
|
||||
def as_am_modern(self) -> dict:
|
||||
return {"bitmain-fan-ctrl": False, "bitmain-fan-pwn": "100"}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"fc-fan-ctrl": False, "fc-fan-pwn": "100"}
|
||||
|
||||
def as_bosminer(self) -> dict:
|
||||
return {
|
||||
"temp_control": {"mode": "auto"},
|
||||
@@ -135,6 +138,9 @@ class FanModeManual(MinerConfigValue):
|
||||
def as_am_modern(self) -> dict:
|
||||
return {"bitmain-fan-ctrl": True, "bitmain-fan-pwm": str(self.speed)}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"fc-fan-ctrl": True, "fc-fan-pwm": str(self.speed)}
|
||||
|
||||
def as_bosminer(self) -> dict:
|
||||
return {
|
||||
"temp_control": {"mode": "manual"},
|
||||
@@ -185,6 +191,9 @@ class FanModeImmersion(MinerConfigValue):
|
||||
def as_am_modern(self) -> dict:
|
||||
return {"bitmain-fan-ctrl": True, "bitmain-fan-pwm": "0"}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"fc-fan-ctrl": True, "fc-fan-pwm": "0"}
|
||||
|
||||
def as_bosminer(self) -> dict:
|
||||
return {
|
||||
"fan_control": {"min_fans": 0},
|
||||
@@ -239,6 +248,20 @@ class FanModeConfig(MinerConfigOption):
|
||||
else:
|
||||
return cls.default()
|
||||
|
||||
@classmethod
|
||||
def from_elphapex(cls, web_conf: dict):
|
||||
if web_conf.get("fc-fan-ctrl") is not None:
|
||||
fan_manual = web_conf["fc-fan-ctrl"]
|
||||
if fan_manual:
|
||||
speed = int(web_conf["fc-fan-pwm"])
|
||||
if speed == 0:
|
||||
return cls.immersion()
|
||||
return cls.manual(speed=speed)
|
||||
else:
|
||||
return cls.normal()
|
||||
else:
|
||||
return cls.default()
|
||||
|
||||
@classmethod
|
||||
def from_epic(cls, web_conf: dict):
|
||||
try:
|
||||
|
||||
@@ -52,6 +52,9 @@ class MiningModeNormal(MinerConfigValue):
|
||||
return {"miner-mode": "0"}
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_wm(self) -> dict:
|
||||
return {"mode": self.mode}
|
||||
|
||||
@@ -87,6 +90,9 @@ class MiningModeSleep(MinerConfigValue):
|
||||
return {"miner-mode": "1"}
|
||||
return {"miner-mode": 1}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 1}
|
||||
|
||||
def as_wm(self) -> dict:
|
||||
return {"mode": self.mode}
|
||||
|
||||
@@ -119,6 +125,9 @@ class MiningModeLPM(MinerConfigValue):
|
||||
return {"miner-mode": "3"}
|
||||
return {"miner-mode": 3}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 3}
|
||||
|
||||
def as_wm(self) -> dict:
|
||||
return {"mode": self.mode}
|
||||
|
||||
@@ -141,6 +150,9 @@ class MiningModeHPM(MinerConfigValue):
|
||||
return {"miner-mode": "0"}
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_wm(self) -> dict:
|
||||
return {"mode": self.mode}
|
||||
|
||||
@@ -174,6 +186,9 @@ class MiningModePowerTune(MinerConfigValue):
|
||||
return {"miner-mode": "0"}
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_wm(self) -> dict:
|
||||
if self.power is not None:
|
||||
return {"mode": self.mode, self.mode: {"wattage": self.power}}
|
||||
@@ -273,6 +288,9 @@ class MiningModeHashrateTune(MinerConfigValue):
|
||||
return {"miner-mode": "0"}
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_bosminer(self) -> dict:
|
||||
conf = {"enabled": True, "mode": "hashrate_target"}
|
||||
if self.hashrate is not None:
|
||||
@@ -404,6 +422,9 @@ class ManualBoardSettings(MinerConfigValue):
|
||||
return {"miner-mode": "0"}
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_vnish(self) -> dict:
|
||||
return {"freq": self.freq}
|
||||
|
||||
@@ -428,6 +449,9 @@ class MiningModeManual(MinerConfigValue):
|
||||
return {"miner-mode": "0"}
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_elphapex(self) -> dict:
|
||||
return {"miner-mode": 0}
|
||||
|
||||
def as_vnish(self) -> dict:
|
||||
chains = [b.as_vnish() for b in self.boards.values() if b.freq != 0]
|
||||
return {
|
||||
@@ -525,6 +549,20 @@ class MiningModeConfig(MinerConfigOption):
|
||||
return cls.low()
|
||||
return cls.default()
|
||||
|
||||
@classmethod
|
||||
def from_elphapex(cls, web_conf: dict):
|
||||
if web_conf.get("fc-work-mode") is not None:
|
||||
work_mode = web_conf["fc-work-mode"]
|
||||
if work_mode == "":
|
||||
return cls.default()
|
||||
if int(work_mode) == 0:
|
||||
return cls.normal()
|
||||
elif int(work_mode) == 1:
|
||||
return cls.sleep()
|
||||
elif int(work_mode) == 3:
|
||||
return cls.low()
|
||||
return cls.default()
|
||||
|
||||
@classmethod
|
||||
def from_epic(cls, web_conf: dict):
|
||||
try:
|
||||
|
||||
@@ -24,7 +24,14 @@ class MiningPreset(MinerConfigValue):
|
||||
hashrate = None
|
||||
else:
|
||||
power = hr_power_split[0].replace("watt", "").strip()
|
||||
hashrate = hr_power_split[1].replace("TH", "").replace(" LC", "").strip()
|
||||
hashrate = (
|
||||
hr_power_split[1]
|
||||
.replace("TH", "")
|
||||
.replace("GH", "")
|
||||
.replace("MH", "")
|
||||
.replace(" LC", "")
|
||||
.strip()
|
||||
)
|
||||
tuned = web_preset["status"] == "tuned"
|
||||
modded_psu = web_preset["modded_psu_required"]
|
||||
return cls(
|
||||
|
||||
@@ -43,6 +43,13 @@ class Pool(MinerConfigValue):
|
||||
"pass": self.password,
|
||||
}
|
||||
|
||||
def as_elphapex(self, user_suffix: str | None = None) -> dict:
|
||||
return {
|
||||
"url": self.url,
|
||||
"user": f"{self.user}{user_suffix or ''}",
|
||||
"pass": self.password,
|
||||
}
|
||||
|
||||
def as_wm(self, idx: int = 1, user_suffix: str | None = None) -> dict:
|
||||
return {
|
||||
f"pool_{idx}": self.url,
|
||||
@@ -146,6 +153,12 @@ class Pool(MinerConfigValue):
|
||||
url=web_pool["url"], user=web_pool["user"], password=web_pool["pass"]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_elphapex(cls, web_pool: dict) -> "Pool":
|
||||
return cls(
|
||||
url=web_pool["url"], user=web_pool["user"], password=web_pool["pass"]
|
||||
)
|
||||
|
||||
# TODO: check if this is accurate, user/username, pass/password
|
||||
@classmethod
|
||||
def from_goldshell(cls, web_pool: dict) -> "Pool":
|
||||
@@ -235,6 +248,17 @@ class PoolGroup(MinerConfigValue):
|
||||
idx += 1
|
||||
return pools
|
||||
|
||||
def as_elphapex(self, user_suffix: str | None = None) -> list:
|
||||
pools = []
|
||||
idx = 0
|
||||
while idx < 3:
|
||||
if len(self.pools) > idx:
|
||||
pools.append(self.pools[idx].as_elphapex(user_suffix=user_suffix))
|
||||
else:
|
||||
pools.append(Pool(url="", user="", password="").as_elphapex())
|
||||
idx += 1
|
||||
return pools
|
||||
|
||||
def as_wm(self, user_suffix: str | None = None) -> dict:
|
||||
pools = {}
|
||||
idx = 0
|
||||
@@ -351,6 +375,13 @@ class PoolGroup(MinerConfigValue):
|
||||
pools.append(Pool.from_am_modern(pool))
|
||||
return cls(pools=pools)
|
||||
|
||||
@classmethod
|
||||
def from_elphapex(cls, web_pool_list: list) -> "PoolGroup":
|
||||
pools = []
|
||||
for pool in web_pool_list:
|
||||
pools.append(Pool.from_elphapex(pool))
|
||||
return cls(pools=pools)
|
||||
|
||||
@classmethod
|
||||
def from_goldshell(cls, web_pools: list) -> "PoolGroup":
|
||||
return cls(pools=[Pool.from_goldshell(p) for p in web_pools])
|
||||
@@ -436,6 +467,11 @@ class PoolConfig(MinerConfigValue):
|
||||
return {"pools": self.groups[0].as_am_modern(user_suffix=user_suffix)}
|
||||
return {"pools": PoolGroup().as_am_modern()}
|
||||
|
||||
def as_elphapex(self, user_suffix: str | None = None) -> dict:
|
||||
if len(self.groups) > 0:
|
||||
return {"pools": self.groups[0].as_elphapex(user_suffix=user_suffix)}
|
||||
return {"pools": PoolGroup().as_elphapex()}
|
||||
|
||||
def as_wm(self, user_suffix: str | None = None) -> dict:
|
||||
if len(self.groups) > 0:
|
||||
return {"pools": self.groups[0].as_wm(user_suffix=user_suffix)}
|
||||
@@ -537,6 +573,12 @@ class PoolConfig(MinerConfigValue):
|
||||
|
||||
return cls(groups=[PoolGroup.from_am_modern(pool_data)])
|
||||
|
||||
@classmethod
|
||||
def from_elphapex(cls, web_conf: dict) -> "PoolConfig":
|
||||
pool_data = web_conf["pools"]
|
||||
|
||||
return cls(groups=[PoolGroup.from_elphapex(pool_data)])
|
||||
|
||||
@classmethod
|
||||
def from_goldshell(cls, web_pools: list) -> "PoolConfig":
|
||||
return cls(groups=[PoolGroup.from_goldshell(web_pools)])
|
||||
|
||||
@@ -448,6 +448,7 @@ class AvalonminerModels(MinerModelType):
|
||||
Avalon1166Pro = "Avalon 1166 Pro"
|
||||
Avalon1126Pro = "Avalon 1126 Pro"
|
||||
Avalon1246 = "Avalon 1246"
|
||||
Avalon1566 = "Avalon 1566"
|
||||
AvalonNano3 = "Avalon Nano 3"
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -21,4 +21,5 @@ from .epic import *
|
||||
from .hiveon import *
|
||||
from .luxos import *
|
||||
from .marathon import *
|
||||
from .mskminer import *
|
||||
from .vnish import *
|
||||
|
||||
24
pyasic/miners/antminer/mskminer/X19/S19.py
Normal file
24
pyasic/miners/antminer/mskminer/X19/S19.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
from pyasic.miners.backends.mskminer import MSKMiner
|
||||
from pyasic.miners.device.models import (
|
||||
S19NoPIC,
|
||||
)
|
||||
|
||||
|
||||
class MSKMinerS19NoPIC(MSKMiner, S19NoPIC):
|
||||
pass
|
||||
17
pyasic/miners/antminer/mskminer/X19/__init__.py
Normal file
17
pyasic/miners/antminer/mskminer/X19/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
from .S19 import MSKMinerS19NoPIC
|
||||
17
pyasic/miners/antminer/mskminer/__init__.py
Normal file
17
pyasic/miners/antminer/mskminer/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
from .X19 import *
|
||||
22
pyasic/miners/avalonminer/cgminer/A15X/A1566.py
Normal file
22
pyasic/miners/avalonminer/cgminer/A15X/A1566.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
from pyasic.miners.backends import AvalonMiner
|
||||
from pyasic.miners.device.models import Avalon1566
|
||||
|
||||
|
||||
class CGMinerAvalon1566(AvalonMiner, Avalon1566):
|
||||
pass
|
||||
17
pyasic/miners/avalonminer/cgminer/A15X/__init__.py
Normal file
17
pyasic/miners/avalonminer/cgminer/A15X/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
from .A1566 import CGMinerAvalon1566
|
||||
@@ -20,4 +20,5 @@ from .A9X import *
|
||||
from .A10X import *
|
||||
from .A11X import *
|
||||
from .A12X import *
|
||||
from .A15X import *
|
||||
from .nano import *
|
||||
|
||||
@@ -61,6 +61,10 @@ AVALON_NANO_DATA_LOC = DataLocations(
|
||||
"_get_wattage_limit",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.WATTAGE): DataFunction(
|
||||
"_get_wattage",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.FANS): DataFunction(
|
||||
"_get_fans",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
|
||||
@@ -32,6 +32,7 @@ from .innosilicon import Innosilicon
|
||||
from .luckyminer import LuckyMiner
|
||||
from .luxminer import LUXMiner
|
||||
from .marathon import MaraMiner
|
||||
from .mskminer import MSKMiner
|
||||
from .unknown import UnknownMiner
|
||||
from .vnish import VNish
|
||||
from .whatsminer import M2X, M3X, M5X, M6X, M7X
|
||||
|
||||
@@ -57,6 +57,10 @@ AVALON_DATA_LOC = DataLocations(
|
||||
"_get_wattage_limit",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.WATTAGE): DataFunction(
|
||||
"_get_wattage",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.FANS): DataFunction(
|
||||
"_get_fans",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
@@ -289,6 +293,21 @@ class AvalonMiner(CGMiner):
|
||||
except (IndexError, KeyError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
async def _get_wattage(self, rpc_stats: dict = None) -> Optional[int]:
|
||||
if rpc_stats is None:
|
||||
try:
|
||||
rpc_stats = await self.rpc.stats()
|
||||
except APIError:
|
||||
pass
|
||||
|
||||
if rpc_stats is not None:
|
||||
try:
|
||||
unparsed_stats = rpc_stats["STATS"][0]["MM ID0"]
|
||||
parsed_stats = self.parse_stats(unparsed_stats)
|
||||
return int(parsed_stats["WALLPOWER"][0])
|
||||
except (IndexError, KeyError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
async def _get_fans(self, rpc_stats: dict = None) -> List[Fan]:
|
||||
if rpc_stats is None:
|
||||
try:
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
from typing import List, Optional
|
||||
|
||||
from pyasic import APIError
|
||||
from pyasic import APIError, MinerConfig
|
||||
from pyasic.data import Fan, HashBoard, X19Error
|
||||
from pyasic.data.error_codes import MinerErrorData
|
||||
from pyasic.data.pools import PoolMetrics, PoolUrl
|
||||
from pyasic.device.algorithm import AlgoHashRate
|
||||
from pyasic.miners.data import (
|
||||
DataFunction,
|
||||
@@ -74,6 +75,10 @@ ELPHAPEX_DATA_LOC = DataLocations(
|
||||
"_get_uptime",
|
||||
[WebAPICommand("web_summary", "summary")],
|
||||
),
|
||||
str(DataOptions.POOLS): DataFunction(
|
||||
"_get_pools",
|
||||
[WebAPICommand("web_pools", "pools")],
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -86,6 +91,16 @@ class ElphapexMiner(StockFirmware):
|
||||
|
||||
data_locations = ELPHAPEX_DATA_LOC
|
||||
|
||||
async def get_config(self) -> MinerConfig:
|
||||
data = await self.web.get_miner_conf()
|
||||
if data:
|
||||
self.config = MinerConfig.from_elphapex(data)
|
||||
return self.config
|
||||
|
||||
async def send_config(self, config: MinerConfig, user_suffix: str = None) -> None:
|
||||
self.config = config
|
||||
await self.web.set_miner_conf(config.as_elphapex(user_suffix=user_suffix))
|
||||
|
||||
async def fault_light_on(self) -> bool:
|
||||
data = await self.web.blink(blink=True)
|
||||
if data:
|
||||
@@ -317,3 +332,43 @@ class ElphapexMiner(StockFirmware):
|
||||
pass
|
||||
|
||||
return fans
|
||||
|
||||
async def _get_pools(self, web_pools: list = None) -> List[PoolMetrics]:
|
||||
if web_pools is None:
|
||||
try:
|
||||
web_pools = await self.web.pools()
|
||||
except APIError:
|
||||
return []
|
||||
|
||||
active_pool_index = None
|
||||
highest_priority = float("inf")
|
||||
|
||||
for pool_info in web_pools["POOLS"]:
|
||||
if (
|
||||
pool_info.get("status") == "Alive"
|
||||
and pool_info.get("priority", float("inf")) < highest_priority
|
||||
):
|
||||
highest_priority = pool_info["priority"]
|
||||
active_pool_index = pool_info["index"]
|
||||
|
||||
pools_data = []
|
||||
if web_pools is not None:
|
||||
try:
|
||||
for pool_info in web_pools["POOLS"]:
|
||||
url = pool_info.get("url")
|
||||
pool_url = PoolUrl.from_str(url) if url else None
|
||||
pool_data = PoolMetrics(
|
||||
accepted=pool_info.get("accepted"),
|
||||
rejected=pool_info.get("rejected"),
|
||||
get_failures=pool_info.get("stale"),
|
||||
remote_failures=pool_info.get("discarded"),
|
||||
active=pool_info.get("index") == active_pool_index,
|
||||
alive=pool_info.get("status") == "Alive",
|
||||
url=pool_url,
|
||||
user=pool_info.get("user"),
|
||||
index=pool_info.get("index"),
|
||||
)
|
||||
pools_data.append(pool_data)
|
||||
except LookupError:
|
||||
pass
|
||||
return pools_data
|
||||
|
||||
110
pyasic/miners/backends/mskminer.py
Normal file
110
pyasic/miners/backends/mskminer.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from typing import Optional
|
||||
|
||||
from pyasic import APIError
|
||||
from pyasic.device.algorithm import AlgoHashRate
|
||||
from pyasic.miners.backends import BMMiner
|
||||
from pyasic.miners.data import (
|
||||
DataFunction,
|
||||
DataLocations,
|
||||
DataOptions,
|
||||
RPCAPICommand,
|
||||
WebAPICommand,
|
||||
)
|
||||
from pyasic.web.mskminer import MSKMinerWebAPI
|
||||
|
||||
MSKMINER_DATA_LOC = DataLocations(
|
||||
**{
|
||||
str(DataOptions.API_VERSION): DataFunction(
|
||||
"_get_api_ver",
|
||||
[RPCAPICommand("rpc_version", "version")],
|
||||
),
|
||||
str(DataOptions.FW_VERSION): DataFunction(
|
||||
"_get_fw_ver",
|
||||
[RPCAPICommand("rpc_version", "version")],
|
||||
),
|
||||
str(DataOptions.MAC): DataFunction(
|
||||
"_get_mac",
|
||||
[WebAPICommand("web_info_v1", "info_v1")],
|
||||
),
|
||||
str(DataOptions.HASHRATE): DataFunction(
|
||||
"_get_hashrate",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.EXPECTED_HASHRATE): DataFunction(
|
||||
"_get_expected_hashrate",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.HASHBOARDS): DataFunction(
|
||||
"_get_hashboards",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.WATTAGE): DataFunction(
|
||||
"_get_wattage",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.FANS): DataFunction(
|
||||
"_get_fans",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.UPTIME): DataFunction(
|
||||
"_get_uptime",
|
||||
[RPCAPICommand("rpc_stats", "stats")],
|
||||
),
|
||||
str(DataOptions.POOLS): DataFunction(
|
||||
"_get_pools",
|
||||
[RPCAPICommand("rpc_pools", "pools")],
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MSKMiner(BMMiner):
|
||||
"""Handler for MSKMiner"""
|
||||
|
||||
data_locations = MSKMINER_DATA_LOC
|
||||
|
||||
web: MSKMinerWebAPI
|
||||
_web_cls = MSKMinerWebAPI
|
||||
|
||||
async def _get_hashrate(self, rpc_stats: dict = None) -> Optional[AlgoHashRate]:
|
||||
# get hr from API
|
||||
if rpc_stats is None:
|
||||
try:
|
||||
rpc_stats = await self.rpc.stats()
|
||||
except APIError:
|
||||
pass
|
||||
|
||||
if rpc_stats is not None:
|
||||
try:
|
||||
return self.algo.hashrate(
|
||||
rate=float(rpc_stats["STATS"][0]["total_rate"]),
|
||||
unit=self.algo.unit.GH,
|
||||
).into(self.algo.unit.default)
|
||||
except (LookupError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
async def _get_wattage(self, rpc_stats: dict = None) -> Optional[int]:
|
||||
if rpc_stats is None:
|
||||
try:
|
||||
rpc_stats = await self.rpc.stats()
|
||||
except APIError:
|
||||
pass
|
||||
|
||||
if rpc_stats is not None:
|
||||
try:
|
||||
return rpc_stats["STATS"][0]["total_power"]
|
||||
except (LookupError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
async def _get_mac(self, web_info_v1: dict = None) -> Optional[str]:
|
||||
if web_info_v1 is None:
|
||||
try:
|
||||
web_info_v1 = await self.web.info_v1()
|
||||
except APIError:
|
||||
pass
|
||||
|
||||
if web_info_v1 is not None:
|
||||
try:
|
||||
return web_info_v1["network_info"]["result"]["macaddr"].upper()
|
||||
except (LookupError, ValueError, TypeError):
|
||||
pass
|
||||
@@ -40,6 +40,6 @@ class S21Hydro(AntMinerMake):
|
||||
raw_model = MinerModel.ANTMINER.S21Hydro
|
||||
|
||||
expected_chips = 216
|
||||
expected_hashboards = 2
|
||||
expected_hashboards = 3
|
||||
expected_fans = 0
|
||||
algo = MinerAlgo.SHA256
|
||||
|
||||
27
pyasic/miners/device/models/avalonminer/A15X/A1566.py
Normal file
27
pyasic/miners/device/models/avalonminer/A15X/A1566.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
from pyasic.device.algorithm import MinerAlgo
|
||||
from pyasic.device.models import MinerModel
|
||||
from pyasic.miners.device.makes import AvalonMinerMake
|
||||
|
||||
|
||||
class Avalon1566(AvalonMinerMake):
|
||||
raw_model = MinerModel.AVALONMINER.Avalon1566
|
||||
|
||||
expected_chips = 160
|
||||
expected_fans = 2
|
||||
expected_hashboards = 3
|
||||
algo = MinerAlgo.SHA256
|
||||
17
pyasic/miners/device/models/avalonminer/A15X/__init__.py
Normal file
17
pyasic/miners/device/models/avalonminer/A15X/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
from .A1566 import Avalon1566
|
||||
@@ -20,4 +20,5 @@ from .A9X import *
|
||||
from .A10X import *
|
||||
from .A11X import *
|
||||
from .A12X import *
|
||||
from .A15X import *
|
||||
from .nano import *
|
||||
|
||||
@@ -66,6 +66,7 @@ class MinerTypes(enum.Enum):
|
||||
VOLCMINER = 15
|
||||
LUCKYMINER = 16
|
||||
ELPHAPEX = 17
|
||||
MSKMINER = 18
|
||||
|
||||
|
||||
MINER_CLASSES = {
|
||||
@@ -504,6 +505,7 @@ MINER_CLASSES = {
|
||||
"AVALONMINER 1166PRO": CGMinerAvalon1166Pro,
|
||||
"AVALONMINER 1246": CGMinerAvalon1246,
|
||||
"AVALONMINER NANO3": CGMinerAvalonNano3,
|
||||
"AVALONMINER 15-194": CGMinerAvalon1566,
|
||||
},
|
||||
MinerTypes.INNOSILICON: {
|
||||
None: type("InnosiliconUnknown", (Innosilicon, InnosiliconMake), {}),
|
||||
@@ -600,6 +602,10 @@ MINER_CLASSES = {
|
||||
"ANTMINER S19": HiveonS19,
|
||||
"ANTMINER S19X88": HiveonS19NoPIC,
|
||||
},
|
||||
MinerTypes.MSKMINER: {
|
||||
None: MSKMiner,
|
||||
"S19-88": MSKMinerS19NoPIC,
|
||||
},
|
||||
MinerTypes.LUX_OS: {
|
||||
None: LUXMiner,
|
||||
"ANTMINER S9": LUXMinerS9,
|
||||
@@ -869,6 +875,8 @@ class MinerFactory:
|
||||
return MinerTypes.INNOSILICON
|
||||
if "Miner UI" in web_text:
|
||||
return MinerTypes.AURADINE
|
||||
if "<title>Antminer</title>" in web_text:
|
||||
return MinerTypes.MSKMINER
|
||||
|
||||
async def _get_miner_socket(self, ip: str) -> MinerTypes | None:
|
||||
commands = ["version", "devdetails"]
|
||||
@@ -945,6 +953,8 @@ class MinerFactory:
|
||||
return MinerTypes.HIVEON
|
||||
if "KAONSU" in upper_data:
|
||||
return MinerTypes.MARATHON
|
||||
if "RWGLR" in upper_data:
|
||||
return MinerTypes.MSKMINER
|
||||
if "ANTMINER" in upper_data and "DEVDETAILS" not in upper_data:
|
||||
return MinerTypes.ANTMINER
|
||||
if (
|
||||
@@ -1160,9 +1170,9 @@ class MinerFactory:
|
||||
miner_model = sock_json_data["VERSION"][0]["PROD"].upper()
|
||||
if "-" in miner_model:
|
||||
miner_model = miner_model.split("-")[0]
|
||||
if miner_model in ["AVALONNANO", "AVALON0O"]:
|
||||
nano_subtype = sock_json_data["VERSION"][0]["MODEL"].upper()
|
||||
miner_model = f"AVALONMINER {nano_subtype}"
|
||||
if miner_model in ["AVALONNANO", "AVALON0O", "AVALONMINER 15"]:
|
||||
subtype = sock_json_data["VERSION"][0]["MODEL"].upper()
|
||||
miner_model = f"AVALONMINER {subtype}"
|
||||
return miner_model
|
||||
except (TypeError, LookupError):
|
||||
pass
|
||||
@@ -1410,6 +1420,13 @@ class MinerFactory:
|
||||
except (TypeError, LookupError):
|
||||
pass
|
||||
|
||||
async def get_miner_model_mskminer(self, ip: str) -> str | None:
|
||||
sock_json_data = await self.send_api_command(ip, "version")
|
||||
try:
|
||||
return sock_json_data["VERSION"][0]["Type"].split(" ")[0]
|
||||
except LookupError:
|
||||
pass
|
||||
|
||||
|
||||
miner_factory = MinerFactory()
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ _settings = { # defaults
|
||||
"default_hive_web_password": "root",
|
||||
"default_iceriver_web_password": "12345678",
|
||||
"default_elphapex_web_password": "root",
|
||||
"default_mskminer_web_password": "root",
|
||||
"default_antminer_ssh_password": "miner",
|
||||
"default_bosminer_ssh_password": "root",
|
||||
}
|
||||
|
||||
@@ -214,3 +214,11 @@ class ElphapexWebAPI(BaseWebAPI):
|
||||
dict: A dictionary indicating whether the LED is currently blinking.
|
||||
"""
|
||||
return await self.send_command("get_blink_status")
|
||||
|
||||
async def pools(self) -> dict:
|
||||
"""Check the status of the miner's pools.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the pool status as information.
|
||||
"""
|
||||
return await self.send_command("pools")
|
||||
|
||||
72
pyasic/web/mskminer.py
Normal file
72
pyasic/web/mskminer.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# Copyright 2024 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. -
|
||||
# ------------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from pyasic import settings
|
||||
from pyasic.errors import APIError
|
||||
from pyasic.web.base import BaseWebAPI
|
||||
|
||||
|
||||
class MSKMinerWebAPI(BaseWebAPI):
|
||||
def __init__(self, ip: str) -> None:
|
||||
super().__init__(ip)
|
||||
self.username = "admin"
|
||||
self.pwd = settings.get("default_mskminer_web_password", "root")
|
||||
|
||||
async def multicommand(
|
||||
self, *commands: str, ignore_errors: bool = False, allow_warning: bool = True
|
||||
) -> dict:
|
||||
tasks = {c: asyncio.create_task(getattr(self, c)()) for c in commands}
|
||||
await asyncio.gather(*[t for t in tasks.values()])
|
||||
return {t: tasks[t].result() for t in tasks}
|
||||
|
||||
async def send_command(
|
||||
self,
|
||||
command: str | bytes,
|
||||
ignore_errors: bool = False,
|
||||
allow_warning: bool = True,
|
||||
privileged: bool = False,
|
||||
**parameters: Any,
|
||||
) -> dict:
|
||||
async with httpx.AsyncClient(transport=settings.transport()) as client:
|
||||
try:
|
||||
# auth
|
||||
await client.post(
|
||||
f"http://{self.ip}:{self.port}/admin/login",
|
||||
data={"username": self.username, "password": self.pwd},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
warnings.warn(f"Could not authenticate with miner web: {self}")
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"http://{self.ip}:{self.port}/api/{command}", params=parameters
|
||||
)
|
||||
if not resp.status_code == 200:
|
||||
if not ignore_errors:
|
||||
raise APIError(f"Command failed: {command}")
|
||||
warnings.warn(f"Command failed: {command}")
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
raise APIError(f"Command failed: {command}")
|
||||
|
||||
async def info_v1(self):
|
||||
return await self.send_command("info_v1")
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pyasic"
|
||||
version = "0.71.5"
|
||||
version = "0.71.11"
|
||||
|
||||
description = "A simplified and standardized interface for Bitcoin ASICs."
|
||||
authors = [{name = "UpstreamData", email = "brett@upstreamdata.ca"}]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
from tests.config_tests import TestConfig
|
||||
from tests.miners_tests import MinersTest, TestElphapexMiners, TestHammerMiners
|
||||
from tests.miners_tests import *
|
||||
from tests.network_tests import NetworkTest
|
||||
from tests.rpc_tests import *
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .alphapex_tests import *
|
||||
from .avalonminer_tests import *
|
||||
from .elphapex_tests import *
|
||||
from .hammer_tests import *
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .version_24102401_25462b2_9ddf522 import TestAvalonMiners
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,25 @@ from pyasic.data import Fan, HashBoard
|
||||
from pyasic.device.algorithm.hashrate.unit.scrypt import ScryptUnit
|
||||
from pyasic.miners.elphapex import ElphapexDG1Plus
|
||||
|
||||
POOLS = [
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3333",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3334",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3335",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
data = {
|
||||
ElphapexDG1Plus: {
|
||||
"web_get_system_info": {
|
||||
@@ -190,6 +209,81 @@ data = {
|
||||
"fc-work-mode": 0,
|
||||
"fc-freq": "1850",
|
||||
},
|
||||
"web_pools": {
|
||||
"STATUS": {
|
||||
"STATUS": "S",
|
||||
"when": 5411762,
|
||||
"timestamp": 1738768594,
|
||||
"api_version": "1.0.0",
|
||||
"Msg": "pools",
|
||||
},
|
||||
"Device Total Rejected": 8888,
|
||||
"POOLS": [
|
||||
{
|
||||
"diffs": 0,
|
||||
"diffr": 524288,
|
||||
"index": 0,
|
||||
"user": "pool_username.real_worker",
|
||||
"lsdiff": 524288,
|
||||
"lstime": "00:00:18",
|
||||
"diffa": 524288,
|
||||
"accepted": 798704,
|
||||
"diff1": 0,
|
||||
"stale": 0,
|
||||
"diff": "",
|
||||
"rejected": 3320,
|
||||
"status": "Unreachable",
|
||||
"getworks": 802024,
|
||||
"priority": 0,
|
||||
"url": "stratum+tcp://stratum.pool.io:3333",
|
||||
},
|
||||
{
|
||||
"diffs": 0,
|
||||
"diffr": 524288,
|
||||
"index": 1,
|
||||
"user": "pool_username.real_worker",
|
||||
"lsdiff": 524288,
|
||||
"lstime": "00:00:00",
|
||||
"diffa": 524288,
|
||||
"accepted": 604803,
|
||||
"diff1": 0,
|
||||
"stale": 0,
|
||||
"diff": "",
|
||||
"rejected": 2492,
|
||||
"status": "Alive",
|
||||
"getworks": 607295,
|
||||
"priority": 1,
|
||||
"url": "stratum+tcp://stratum.pool.io:3334",
|
||||
},
|
||||
{
|
||||
"diffs": 0,
|
||||
"diffr": 524288,
|
||||
"index": 2,
|
||||
"user": "pool_username.real_worker",
|
||||
"lsdiff": 524288,
|
||||
"lstime": "00:00:05",
|
||||
"diffa": 524288,
|
||||
"accepted": 691522,
|
||||
"diff1": 0,
|
||||
"stale": 0,
|
||||
"diff": "",
|
||||
"rejected": 3076,
|
||||
"status": "Unreachable",
|
||||
"getworks": 694598,
|
||||
"priority": 2,
|
||||
"url": "stratum+tcp://stratum.pool.io:3335",
|
||||
},
|
||||
],
|
||||
"Device Rejected%": 0.41999999999999998,
|
||||
"Device Total Work": 2103917,
|
||||
"INFO": {
|
||||
"miner_version": "DG1+_SW_V1.0.2",
|
||||
"CompileTime": "",
|
||||
"dev_sn": "28HY245192N000245C23B",
|
||||
"type": "DG1+",
|
||||
"hw_version": "DG1+_HW_V1.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,3 +334,6 @@ class TestElphapexMiners(unittest.IsolatedAsyncioTestCase):
|
||||
[Fan(speed=5340), Fan(speed=5400), Fan(speed=5400), Fan(speed=5400)],
|
||||
)
|
||||
self.assertEqual(result.total_chips, result.expected_chips)
|
||||
self.assertEqual(
|
||||
set([str(p.url) for p in result.pools]), set(p["url"] for p in POOLS)
|
||||
)
|
||||
@@ -16,12 +16,12 @@ POOLS = [
|
||||
"pwd": "123",
|
||||
},
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3333",
|
||||
"url": "stratum+tcp://stratum.pool.io:3334",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3333",
|
||||
"url": "stratum+tcp://stratum.pool.io:3335",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .version_2_6_0_39 import TestMSKMiners
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Tests for MSK miner firmware version 2.6.0.39"""
|
||||
|
||||
import unittest
|
||||
from dataclasses import fields
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyasic import APIError, MinerData
|
||||
from pyasic.data import Fan, HashBoard
|
||||
from pyasic.device.algorithm import SHA256Unit
|
||||
from pyasic.miners.antminer import MSKMinerS19NoPIC
|
||||
|
||||
POOLS = [
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3333",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3334",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
{
|
||||
"url": "stratum+tcp://stratum.pool.io:3335",
|
||||
"user": "pool_username.real_worker",
|
||||
"pwd": "123",
|
||||
},
|
||||
]
|
||||
|
||||
data = {
|
||||
MSKMinerS19NoPIC: {
|
||||
"web_info_v1": {
|
||||
# needs updates with real data
|
||||
"network_info": {
|
||||
"result": {
|
||||
"address": "192.168.1.10",
|
||||
"macaddr": "12:34:56:78:90:12",
|
||||
"netmask": "255.255.255.0",
|
||||
}
|
||||
}
|
||||
},
|
||||
"rpc_version": {
|
||||
"STATUS": [
|
||||
{
|
||||
"STATUS": "S",
|
||||
"When": 1738856891,
|
||||
"Code": 22,
|
||||
"Msg": "BMMiner versions",
|
||||
"Description": "bmminer 1.0.0",
|
||||
}
|
||||
],
|
||||
"VERSION": [
|
||||
{
|
||||
"BMMiner": "4.11.1 rwglr",
|
||||
"API": "3.1",
|
||||
"Miner": "0.0.1.3",
|
||||
"CompileTime": "10 Dec 2024 14:34:31 GMT",
|
||||
"Type": "S19-88 v.2.6.0.39 ",
|
||||
}
|
||||
],
|
||||
"id": 1,
|
||||
},
|
||||
"rpc_stats": {
|
||||
"STATUS": [
|
||||
{
|
||||
"STATUS": "S",
|
||||
"When": 1738856891,
|
||||
"Code": 70,
|
||||
"Msg": "BMMiner stats",
|
||||
"Description": "bmminer 1.0.0",
|
||||
}
|
||||
],
|
||||
"STATS": [
|
||||
{
|
||||
"BMMiner": "4.11.1 rwglr",
|
||||
"Miner": "0.0.1.3",
|
||||
"CompileTime": "10 Dec 2024 14:34:31 GMT",
|
||||
"Type": "S19-88 v.2.6.0.39 ",
|
||||
},
|
||||
{
|
||||
"STATS": 0,
|
||||
"ID": "BC50",
|
||||
"Elapsed": 1926,
|
||||
"Calls": 0,
|
||||
"Wait": 0.000000,
|
||||
"Max": 0.000000,
|
||||
"Min": 99999999.000000,
|
||||
"GHS 5s": 99989.59,
|
||||
"GHS av": 99761.40,
|
||||
"miner_count": 3,
|
||||
"frequency": "",
|
||||
"fan_num": 4,
|
||||
"fan1": 5010,
|
||||
"fan2": 5160,
|
||||
"fan3": 5070,
|
||||
"fan4": 5040,
|
||||
"fan5": 0,
|
||||
"fan6": 0,
|
||||
"fan7": 0,
|
||||
"fan8": 0,
|
||||
"temp_num": 3,
|
||||
"temp1": 45,
|
||||
"temp2": 45,
|
||||
"temp3": 47,
|
||||
"temp4": 0,
|
||||
"temp5": 0,
|
||||
"temp6": 0,
|
||||
"temp7": 0,
|
||||
"temp8": 0,
|
||||
"temp9": 0,
|
||||
"temp10": 0,
|
||||
"temp11": 0,
|
||||
"temp12": 0,
|
||||
"temp13": 0,
|
||||
"temp14": 0,
|
||||
"temp15": 0,
|
||||
"temp16": 0,
|
||||
"temp2_1": 59,
|
||||
"temp2_2": 57,
|
||||
"temp2_3": 58,
|
||||
"temp2_4": 0,
|
||||
"temp2_5": 0,
|
||||
"temp2_6": 0,
|
||||
"temp2_7": 0,
|
||||
"temp2_8": 0,
|
||||
"temp2_9": 0,
|
||||
"temp2_10": 0,
|
||||
"temp2_11": 0,
|
||||
"temp2_12": 0,
|
||||
"temp2_13": 0,
|
||||
"temp2_14": 0,
|
||||
"temp2_15": 0,
|
||||
"temp2_16": 0,
|
||||
"temp3_1": 59,
|
||||
"temp3_2": 56,
|
||||
"temp3_3": 57,
|
||||
"temp3_4": 0,
|
||||
"temp3_5": 0,
|
||||
"temp3_6": 0,
|
||||
"temp3_7": 0,
|
||||
"temp3_8": 0,
|
||||
"temp3_9": 0,
|
||||
"temp3_10": 0,
|
||||
"temp3_11": 0,
|
||||
"temp3_12": 0,
|
||||
"temp3_13": 0,
|
||||
"temp3_14": 0,
|
||||
"temp3_15": 0,
|
||||
"temp3_16": 0,
|
||||
"temp_pcb1": "45-42-45-42",
|
||||
"temp_pcb2": "45-42-45-42",
|
||||
"temp_pcb3": "47-43-47-43",
|
||||
"temp_pcb4": "0-0-0-0",
|
||||
"temp_pcb5": "0-0-0-0",
|
||||
"temp_pcb6": "0-0-0-0",
|
||||
"temp_pcb7": "0-0-0-0",
|
||||
"temp_pcb8": "0-0-0-0",
|
||||
"temp_pcb9": "0-0-0-0",
|
||||
"temp_pcb10": "0-0-0-0",
|
||||
"temp_pcb11": "0-0-0-0",
|
||||
"temp_pcb12": "0-0-0-0",
|
||||
"temp_pcb13": "0-0-0-0",
|
||||
"temp_pcb14": "0-0-0-0",
|
||||
"temp_pcb15": "0-0-0-0",
|
||||
"temp_pcb16": "0-0-0-0",
|
||||
"temp_chip1": "59-59-59-59",
|
||||
"temp_chip2": "57-56-57-56",
|
||||
"temp_chip3": "58-57-58-57",
|
||||
"temp_chip4": "0-0-0-0",
|
||||
"temp_chip5": "0-0-0-0",
|
||||
"temp_chip6": "0-0-0-0",
|
||||
"temp_chip7": "0-0-0-0",
|
||||
"temp_chip8": "0-0-0-0",
|
||||
"temp_chip9": "0-0-0-0",
|
||||
"temp_chip10": "0-0-0-0",
|
||||
"temp_chip11": "0-0-0-0",
|
||||
"temp_chip12": "0-0-0-0",
|
||||
"temp_chip13": "0-0-0-0",
|
||||
"temp_chip14": "0-0-0-0",
|
||||
"temp_chip15": "0-0-0-0",
|
||||
"temp_chip16": "0-0-0-0",
|
||||
"total_rateideal": 99674.88,
|
||||
"total_freqavg": 0.00,
|
||||
"total_acn": 264,
|
||||
"total_rate": 99989.59,
|
||||
"chain_rateideal1": 33677.28,
|
||||
"chain_rateideal2": 32788.06,
|
||||
"chain_rateideal3": 33209.54,
|
||||
"chain_rateideal4": 31436.24,
|
||||
"chain_rateideal5": 31436.24,
|
||||
"chain_rateideal6": 31436.24,
|
||||
"chain_rateideal7": 31436.24,
|
||||
"chain_rateideal8": 31436.24,
|
||||
"chain_rateideal9": 31436.24,
|
||||
"chain_rateideal10": 31436.24,
|
||||
"chain_rateideal11": 31436.24,
|
||||
"chain_rateideal12": 31436.24,
|
||||
"chain_rateideal13": 31436.24,
|
||||
"chain_rateideal14": 31436.24,
|
||||
"chain_rateideal15": 31436.24,
|
||||
"chain_rateideal16": 31436.24,
|
||||
"temp_max": 47,
|
||||
"no_matching_work": 0,
|
||||
"chain_acn1": 88,
|
||||
"chain_acn2": 88,
|
||||
"chain_acn3": 88,
|
||||
"chain_acn4": 0,
|
||||
"chain_acn5": 0,
|
||||
"chain_acn6": 0,
|
||||
"chain_acn7": 0,
|
||||
"chain_acn8": 0,
|
||||
"chain_acn9": 0,
|
||||
"chain_acn10": 0,
|
||||
"chain_acn11": 0,
|
||||
"chain_acn12": 0,
|
||||
"chain_acn13": 0,
|
||||
"chain_acn14": 0,
|
||||
"chain_acn15": 0,
|
||||
"chain_acn16": 0,
|
||||
"chain_acs1": " oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo",
|
||||
"chain_acs2": " oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo",
|
||||
"chain_acs3": " oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo oo",
|
||||
"chain_acs4": "",
|
||||
"chain_acs5": "",
|
||||
"chain_acs6": "",
|
||||
"chain_acs7": "",
|
||||
"chain_acs8": "",
|
||||
"chain_acs9": "",
|
||||
"chain_acs10": "",
|
||||
"chain_acs11": "",
|
||||
"chain_acs12": "",
|
||||
"chain_acs13": "",
|
||||
"chain_acs14": "",
|
||||
"chain_acs15": "",
|
||||
"chain_acs16": "",
|
||||
"chain_hw1": 0,
|
||||
"chain_hw2": 0,
|
||||
"chain_hw3": 0,
|
||||
"chain_hw4": 0,
|
||||
"chain_hw5": 0,
|
||||
"chain_hw6": 0,
|
||||
"chain_hw7": 0,
|
||||
"chain_hw8": 0,
|
||||
"chain_hw9": 0,
|
||||
"chain_hw10": 0,
|
||||
"chain_hw11": 0,
|
||||
"chain_hw12": 0,
|
||||
"chain_hw13": 0,
|
||||
"chain_hw14": 0,
|
||||
"chain_hw15": 0,
|
||||
"chain_hw16": 0,
|
||||
"chain_rate1": 34084.86,
|
||||
"chain_rate2": 32303.65,
|
||||
"chain_rate3": 33601.08,
|
||||
"chain_rate4": 0.00,
|
||||
"chain_rate5": 0.00,
|
||||
"chain_rate6": 0.00,
|
||||
"chain_rate7": 0.00,
|
||||
"chain_rate8": 0.00,
|
||||
"chain_rate9": 0.00,
|
||||
"chain_rate10": 0.00,
|
||||
"chain_rate11": 0.00,
|
||||
"chain_rate12": 0.00,
|
||||
"chain_rate13": 0.00,
|
||||
"chain_rate14": 0.00,
|
||||
"chain_rate15": 0.00,
|
||||
"chain_rate16": 0.00,
|
||||
"chain_xtime1": "{}",
|
||||
"chain_xtime2": "{}",
|
||||
"chain_xtime3": "{}",
|
||||
"chain_offside_1": "",
|
||||
"chain_offside_2": "",
|
||||
"chain_offside_3": "",
|
||||
"chain_opencore_0": "1",
|
||||
"chain_opencore_1": "1",
|
||||
"chain_opencore_2": "1",
|
||||
"freq1": 744,
|
||||
"freq2": 724,
|
||||
"freq3": 734,
|
||||
"freq4": 0,
|
||||
"freq5": 0,
|
||||
"freq6": 0,
|
||||
"freq7": 0,
|
||||
"freq8": 0,
|
||||
"freq9": 0,
|
||||
"freq10": 0,
|
||||
"freq11": 0,
|
||||
"freq12": 0,
|
||||
"freq13": 0,
|
||||
"freq14": 0,
|
||||
"freq15": 0,
|
||||
"freq16": 0,
|
||||
"chain_avgrate1": 33585.34,
|
||||
"chain_avgrate2": 32788.97,
|
||||
"chain_avgrate3": 33336.44,
|
||||
"chain_avgrate4": 0.00,
|
||||
"chain_avgrate5": 0.00,
|
||||
"chain_avgrate6": 0.00,
|
||||
"chain_avgrate7": 0.00,
|
||||
"chain_avgrate8": 0.00,
|
||||
"chain_avgrate9": 0.00,
|
||||
"chain_avgrate10": 0.00,
|
||||
"chain_avgrate11": 0.00,
|
||||
"chain_avgrate12": 0.00,
|
||||
"chain_avgrate13": 0.00,
|
||||
"chain_avgrate14": 0.00,
|
||||
"chain_avgrate15": 0.00,
|
||||
"chain_avgrate16": 0.00,
|
||||
"miner_version": "0.0.1.3",
|
||||
"miner_id": "",
|
||||
"chain_power1": 1135,
|
||||
"chain_power2": 1103,
|
||||
"chain_power3": 1118,
|
||||
"total_power": 3358,
|
||||
"chain_voltage1": 15.70,
|
||||
"chain_voltage2": 15.70,
|
||||
"chain_voltage3": 15.70,
|
||||
"chain_voltage4": 15.70,
|
||||
"chain_voltage5": 15.70,
|
||||
"chain_voltage6": 15.70,
|
||||
"chain_voltage7": 15.70,
|
||||
"chain_voltage8": 15.70,
|
||||
"chain_voltage9": 15.70,
|
||||
"chain_voltage10": 15.70,
|
||||
"chain_voltage11": 15.70,
|
||||
"chain_voltage12": 15.70,
|
||||
"chain_voltage13": 15.70,
|
||||
"chain_voltage14": 15.70,
|
||||
"chain_voltage15": 15.70,
|
||||
"chain_voltage16": 15.70,
|
||||
"fan_pwm": 82,
|
||||
"bringup_temp": 16,
|
||||
"has_pic": "1",
|
||||
"tune_running": "0",
|
||||
"psu_status": "PSU OK",
|
||||
"downscale_mode": "0",
|
||||
"has_hotel_fee": "0",
|
||||
},
|
||||
],
|
||||
"id": 1,
|
||||
},
|
||||
"rpc_pools": {
|
||||
"STATUS": [
|
||||
{
|
||||
"Code": 7,
|
||||
"Description": "cgminer 1.0.0",
|
||||
"Msg": "3 Pool(s)",
|
||||
"STATUS": "S",
|
||||
"When": 1732121693,
|
||||
}
|
||||
],
|
||||
"POOLS": [
|
||||
{
|
||||
"Accepted": 10000,
|
||||
"Best Share": 1000000000.0,
|
||||
"Diff": "100K",
|
||||
"Diff1 Shares": 0,
|
||||
"Difficulty Accepted": 1000000000.0,
|
||||
"Difficulty Rejected": 1000000.0,
|
||||
"Difficulty Stale": 0.0,
|
||||
"Discarded": 100000,
|
||||
"Get Failures": 3,
|
||||
"Getworks": 9000,
|
||||
"Has GBT": False,
|
||||
"Has Stratum": True,
|
||||
"Last Share Difficulty": 100000.0,
|
||||
"Last Share Time": "0:00:02",
|
||||
"Long Poll": "N",
|
||||
"POOL": 0,
|
||||
"Pool Rejected%": 0.0,
|
||||
"Pool Stale%%": 0.0,
|
||||
"Priority": 0,
|
||||
"Proxy": "",
|
||||
"Proxy Type": "",
|
||||
"Quota": 1,
|
||||
"Rejected": 100,
|
||||
"Remote Failures": 0,
|
||||
"Stale": 0,
|
||||
"Status": "Alive",
|
||||
"Stratum Active": True,
|
||||
"Stratum URL": "stratum.pool.io",
|
||||
"URL": "stratum+tcp://stratum.pool.io:3333",
|
||||
"User": "pool_username.real_worker",
|
||||
},
|
||||
{
|
||||
"Accepted": 10000,
|
||||
"Best Share": 1000000000.0,
|
||||
"Diff": "100K",
|
||||
"Diff1 Shares": 0,
|
||||
"Difficulty Accepted": 1000000000.0,
|
||||
"Difficulty Rejected": 1000000.0,
|
||||
"Difficulty Stale": 0.0,
|
||||
"Discarded": 100000,
|
||||
"Get Failures": 3,
|
||||
"Getworks": 9000,
|
||||
"Has GBT": False,
|
||||
"Has Stratum": True,
|
||||
"Last Share Difficulty": 100000.0,
|
||||
"Last Share Time": "0:00:02",
|
||||
"Long Poll": "N",
|
||||
"POOL": 1,
|
||||
"Pool Rejected%": 0.0,
|
||||
"Pool Stale%%": 0.0,
|
||||
"Priority": 0,
|
||||
"Proxy": "",
|
||||
"Proxy Type": "",
|
||||
"Quota": 1,
|
||||
"Rejected": 100,
|
||||
"Remote Failures": 0,
|
||||
"Stale": 0,
|
||||
"Status": "Alive",
|
||||
"Stratum Active": True,
|
||||
"Stratum URL": "stratum.pool.io",
|
||||
"URL": "stratum+tcp://stratum.pool.io:3333",
|
||||
"User": "pool_username.real_worker",
|
||||
},
|
||||
{
|
||||
"Accepted": 10000,
|
||||
"Best Share": 1000000000.0,
|
||||
"Diff": "100K",
|
||||
"Diff1 Shares": 0,
|
||||
"Difficulty Accepted": 1000000000.0,
|
||||
"Difficulty Rejected": 1000000.0,
|
||||
"Difficulty Stale": 0.0,
|
||||
"Discarded": 100000,
|
||||
"Get Failures": 3,
|
||||
"Getworks": 9000,
|
||||
"Has GBT": False,
|
||||
"Has Stratum": True,
|
||||
"Last Share Difficulty": 100000.0,
|
||||
"Last Share Time": "0:00:02",
|
||||
"Long Poll": "N",
|
||||
"POOL": 2,
|
||||
"Pool Rejected%": 0.0,
|
||||
"Pool Stale%%": 0.0,
|
||||
"Priority": 0,
|
||||
"Proxy": "",
|
||||
"Proxy Type": "",
|
||||
"Quota": 1,
|
||||
"Rejected": 100,
|
||||
"Remote Failures": 0,
|
||||
"Stale": 0,
|
||||
"Status": "Alive",
|
||||
"Stratum Active": True,
|
||||
"Stratum URL": "stratum.pool.io",
|
||||
"URL": "stratum+tcp://stratum.pool.io:3333",
|
||||
"User": "pool_username.real_worker",
|
||||
},
|
||||
],
|
||||
"id": 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestMSKMiners(unittest.IsolatedAsyncioTestCase):
|
||||
@patch("pyasic.rpc.base.BaseMinerRPCAPI._send_bytes")
|
||||
async def test_all_data_gathering(self, mock_send_bytes):
|
||||
mock_send_bytes.raises = APIError()
|
||||
for m_type in data:
|
||||
gathered_data = {}
|
||||
miner = m_type("127.0.0.1")
|
||||
for data_name in fields(miner.data_locations):
|
||||
if data_name.name == "config":
|
||||
# skip
|
||||
continue
|
||||
data_func = getattr(miner.data_locations, data_name.name)
|
||||
fn_args = data_func.kwargs
|
||||
args_to_send = {k.name: data[m_type][k.name] for k in fn_args}
|
||||
function = getattr(miner, data_func.cmd)
|
||||
gathered_data[data_name.name] = await function(**args_to_send)
|
||||
|
||||
result = MinerData(
|
||||
ip=str(miner.ip),
|
||||
device_info=miner.device_info,
|
||||
expected_chips=(
|
||||
miner.expected_chips * miner.expected_hashboards
|
||||
if miner.expected_chips is not None
|
||||
else 0
|
||||
),
|
||||
expected_hashboards=miner.expected_hashboards,
|
||||
expected_fans=miner.expected_fans,
|
||||
hashboards=[
|
||||
HashBoard(slot=i, expected_chips=miner.expected_chips)
|
||||
for i in range(miner.expected_hashboards)
|
||||
],
|
||||
)
|
||||
for item in gathered_data:
|
||||
if gathered_data[item] is not None:
|
||||
setattr(result, item, gathered_data[item])
|
||||
|
||||
self.assertEqual(result.mac, "12:34:56:78:90:12")
|
||||
self.assertEqual(result.api_ver, "3.1")
|
||||
self.assertEqual(result.fw_ver, "10 Dec 2024 14:34:31 GMT")
|
||||
self.assertEqual(round(result.hashrate.into(SHA256Unit.TH)), 100)
|
||||
self.assertEqual(
|
||||
result.fans,
|
||||
[Fan(speed=5010), Fan(speed=5160), Fan(speed=5070), Fan(speed=5040)],
|
||||
)
|
||||
self.assertEqual(result.total_chips, result.expected_chips)
|
||||
Reference in New Issue
Block a user