diff --git a/pyasic/data/__init__.py b/pyasic/data/__init__.py index a97937873..d3262bc1e 100644 --- a/pyasic/data/__init__.py +++ b/pyasic/data/__init__.py @@ -50,6 +50,7 @@ class MinerData(BaseModel): api_ver: The current api version on the miner as a str. fw_ver: The current firmware version on the miner as a str. hostname: The network hostname of the miner as a str. + psu_serial_number: The serial number of the PSU if provided by the miner. hashrate: The hashrate of the miner in TH/s as a float. Calculated automatically. expected_hashrate: The factory nominal hashrate of the miner in TH/s as a float. sticker_hashrate: The factory sticker hashrate of the miner as a float. @@ -86,6 +87,7 @@ class MinerData(BaseModel): # about device_info: DeviceInfo | None = None serial_number: str | None = None + psu_serial_number: str | None = None mac: str | None = None api_ver: str | None = None fw_ver: str | None = None diff --git a/pyasic/miners/backends/braiins_os.py b/pyasic/miners/backends/braiins_os.py index e460ebb7f..d456fba1f 100644 --- a/pyasic/miners/backends/braiins_os.py +++ b/pyasic/miners/backends/braiins_os.py @@ -710,6 +710,14 @@ async def upgrade_firmware( BOSER_DATA_LOC = DataLocations( **{ + str(DataOptions.SERIAL_NUMBER): DataFunction( + "_get_serial_number", + [WebAPICommand("grpc_miner_details", "get_miner_details")], + ), + str(DataOptions.PSU_SERIAL_NUMBER): DataFunction( + "_get_psu_serial_number", + [WebAPICommand("grpc_miner_details", "get_miner_details")], + ), str(DataOptions.MAC): DataFunction( "_get_mac", [WebAPICommand("grpc_miner_details", "get_miner_details")], @@ -929,6 +937,34 @@ async def _get_hostname(self, grpc_miner_details: dict | None = None) -> str | N pass return None + async def _get_serial_number( + self, grpc_miner_details: dict | None = None + ) -> str | None: + if grpc_miner_details is None: + try: + grpc_miner_details = await self.web.get_miner_details() + except APIError: + return None + + if grpc_miner_details is not None: + return grpc_miner_details.get("serialNumber") + return None + + async def _get_psu_serial_number( + self, grpc_miner_details: dict | None = None + ) -> str | None: + if grpc_miner_details is None: + try: + grpc_miner_details = await self.web.get_miner_details() + except APIError: + return None + + if grpc_miner_details is not None: + psu_info = grpc_miner_details.get("psuInfo") + if psu_info: + return psu_info.get("serialNumber") + return None + async def _get_hashrate( self, rpc_summary: dict | None = None ) -> AlgoHashRateType | None: @@ -1011,6 +1047,8 @@ async def _get_hashboards( ).into( self.algo.unit.default # type: ignore[attr-defined] ) + if board.get("serialNumber") is not None: + hashboards[idx].serial_number = board["serialNumber"] hashboards[idx].missing = False return hashboards diff --git a/pyasic/miners/base.py b/pyasic/miners/base.py index eae6fb7c2..1b374f73f 100644 --- a/pyasic/miners/base.py +++ b/pyasic/miners/base.py @@ -218,6 +218,14 @@ async def get_serial_number(self) -> str | None: """ return await self._get_serial_number() + async def get_psu_serial_number(self) -> str | None: + """Get the PSU serial number reported by the miner. + + Returns: + A string representing the PSU serial number of the miner. + """ + return await self._get_psu_serial_number() + async def get_mac(self) -> str | None: """Get the MAC address of the miner and return it as a string. @@ -392,6 +400,9 @@ async def get_pools(self) -> list[PoolMetrics]: async def _get_serial_number(self) -> str | None: pass + async def _get_psu_serial_number(self) -> str | None: + return None + async def _get_mac(self) -> str | None: return None diff --git a/pyasic/miners/data.py b/pyasic/miners/data.py index 818824db8..2ef90fcd8 100644 --- a/pyasic/miners/data.py +++ b/pyasic/miners/data.py @@ -20,6 +20,7 @@ class DataOptions(Enum): SERIAL_NUMBER = "serial_number" + PSU_SERIAL_NUMBER = "psu_serial_number" MAC = "mac" API_VERSION = "api_ver" FW_VERSION = "fw_ver" diff --git a/pyasic/web/braiins_os/proto/braiins/bos/v1/__init__.py b/pyasic/web/braiins_os/proto/braiins/bos/v1/__init__.py index a56327129..5b13821ae 100644 --- a/pyasic/web/braiins_os/proto/braiins/bos/v1/__init__.py +++ b/pyasic/web/braiins_os/proto/braiins/bos/v1/__init__.py @@ -84,6 +84,17 @@ class BosMode(betterproto.Enum): EMMC = 5 +class ControlBoardSocFamily(betterproto.Enum): + """Control board SoC family""" + + UNSPECIFIED = 0 + CVITEK = 1 + BBB = 2 + AML = 3 + ZYNQ = 4 + BRAIINS = 5 + + class MinerBrand(betterproto.Enum): UNSPECIFIED = 0 ANTMINER = 1 @@ -1417,6 +1428,35 @@ class GetMinerStatusResponse(betterproto.Message): status: "MinerStatus" = betterproto.enum_field(1) +@dataclass(eq=False, repr=False) +class PsuInfo(betterproto.Message): + version: Optional[int] = betterproto.message_field( + 1, wraps=betterproto.TYPE_UINT32, optional=True + ) + """PSU version""" + + fw_version: Optional[int] = betterproto.message_field( + 2, wraps=betterproto.TYPE_UINT32, optional=True + ) + """PSU firmware version""" + + serial_number: Optional[str] = betterproto.string_field(3, optional=True) + """PSU serial number""" + + model_name: Optional[str] = betterproto.string_field(4, optional=True) + """PSU model name""" + + min_voltage: Optional["Voltage"] = betterproto.message_field( + 5, optional=True + ) + """Minimum supported PSU voltage""" + + max_voltage: Optional["Voltage"] = betterproto.message_field( + 6, optional=True + ) + """Maximum supported PSU voltage""" + + @dataclass(eq=False, repr=False) class GetMinerDetailsRequest(betterproto.Message): pass @@ -1465,6 +1505,15 @@ class GetMinerDetailsResponse(betterproto.Message): kernel_version: str = betterproto.string_field(13) """Kernel version""" + psu_info: "PsuInfo" = betterproto.message_field(14) + """Information about connected PSU""" + + control_board_soc_family: "ControlBoardSocFamily" = betterproto.enum_field(15) + """Control board SoC family""" + + serial_number: Optional[str] = betterproto.string_field(16, optional=True) + """Miner serial number""" + def __post_init__(self) -> None: super().__post_init__() if self.is_set("system_uptime"): @@ -1544,6 +1593,21 @@ class Hashboard(betterproto.Message): model: Optional[str] = betterproto.string_field(9, optional=True) """Hashboard model""" + lowest_inlet_temp: "Temperature" = betterproto.message_field(10) + """Lowest inlet temperature across the board""" + + highest_outlet_temp: "Temperature" = betterproto.message_field(11) + """Highest outlet temperature across the board""" + + serial_number: Optional[str] = betterproto.string_field(12, optional=True) + """Hashboard serial number""" + + board_name: Optional[str] = betterproto.string_field(13, optional=True) + """Hashboard board name""" + + chip_type: Optional[str] = betterproto.string_field(14, optional=True) + """Chip type identifier""" + @dataclass(eq=False, repr=False) class GetSupportArchiveRequest(betterproto.Message): diff --git a/tests/local_tests/README.md b/tests/local_tests/README.md new file mode 100644 index 000000000..4fbbcb3a9 --- /dev/null +++ b/tests/local_tests/README.md @@ -0,0 +1,80 @@ + +# Lokales Testen mit einem echten Braiins OS Miner + +Dieses Verzeichnis enthält Test-Skripte für manuelle Tests mit +echter Hardware (Braiins OS Miner). + +## 📋 Dateien + +- **test_braiins_serials.py** - Test-Skript für Serial Numbers +- **README.md** - Diese Dokumentation + +## 🚀 Schnellanleitung + +### Mit Standard-Zugangsdaten (root/root) + +```bash +cd /workspaces/pyasic +python tests/local_tests/test_braiins_serials.py 192.168.1.100 +``` + +### Mit benutzerdefinierten Zugangsdaten + +```bash +python tests/local_tests/test_braiins_serials.py 192.168.1.100 \ + admin meinpasswort +``` + +## ✅ Was wird getestet + +Das Skript `test_braiins_serials.py` prüft: + +1. **Miner-Verbindung**: Automatische Typ-Erkennung +2. **Miner Serial Number** (`get_serial_number()`) + - Ruft die Seriennummer des Miners ab + - Verfügbar ab Firmware 25.03 + +3. **PSU Serial Number** (`get_psu_serial_number()`) + - Ruft die Seriennummer des Power Supply Units ab + - Verfügbar ab Firmware 25.03 + +4. **Hashboard Serial Numbers** + - Seriennummern aller Hashboards + - Details pro Hashboard (Slot, Chips, Temperatur) + +5. **Zusatzdaten** + - IP, MAC Address + - Firmware Version, API Version + +## 📋 Voraussetzungen + +- Python 3.8+ +- Braiins OS Plus Miner mit Firmware 25.03 oder neuer +- Netzwerkzugriff zum Miner +- Miner-IP-Adresse und Zugangsdaten (Standard: root/root) + +## 💡 Hinweise + +- **Für ältere Firmware**: Serial Numbers benötigen Firmware >= 25.03 +- **Standard-Zugangsdaten**: Meist root/root als Standard +- **Netzwerk**: SSH (Port 22) und gRPC (Port 50051) erforderlich +- **Timeout**: Bis zu 30 Sekunden je nach Miner-Auslastung + +## 🔧 Debuggen bei Fehlern + +Falls das Skript Fehler meldet: + +1. **Verbindungsfehler**: IP-Adresse und Netzwerk überprüfen +2. **Authentifizierung fehlgeschlagen**: Username/Passwort prüfen +3. **Firmware zu alt**: Firmware-Version prüfen (mind. 25.03) +4. **Service nicht erreichbar**: Miner-Status überprüfen + +## 📚 Weitere Informationen + +Die neuen Features sind implementiert in: + +- `pyasic/miners/backends/braiins_os.py` (BOSer-Backend) +- `pyasic/miners/base.py` (Basis-Interface) +- `pyasic/data/__init__.py` (Datenmodell) + +Siehe GitHub PR #402 für vollständige Details. diff --git a/tests/local_tests/__init__.py b/tests/local_tests/__init__.py new file mode 100644 index 000000000..c1343cfdf --- /dev/null +++ b/tests/local_tests/__init__.py @@ -0,0 +1,6 @@ +"""Local manual tests for hardware testing. + +This module contains test scripts for manual testing with real hardware. +These tests require actual ASIC miners on the network and are not part of +the automated test suite. +""" diff --git a/tests/local_tests/test_braiins_serials.py b/tests/local_tests/test_braiins_serials.py new file mode 100755 index 000000000..54a18904d --- /dev/null +++ b/tests/local_tests/test_braiins_serials.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# mypy: ignore-errors +""" +Test script to verify Braiins serial number support. + +Usage: + python test_braiins_serials.py [username] [password] + +Example: + python test_braiins_serials.py 192.168.1.100 + python test_braiins_serials.py 192.168.1.100 root root +""" + +import asyncio +import sys + +from pyasic.data import MinerData +from pyasic.miners.base import BaseMiner +from pyasic.miners.factory import miner_factory + + +async def _connect_to_miner(ip: str, username: str, password: str) -> BaseMiner | None: + """Connect to miner and set credentials.""" + print(f"Connecting to miner at {ip}...") + miner = await miner_factory.get_miner(ip) + + if not isinstance(miner, BaseMiner): + print(f"❌ Failed to detect miner type at {ip}") + return None + + print(f"✅ Detected miner: {miner.model}") + print(f" Firmware: {miner.firmware}\n") + + # Set credentials if needed (for BOSer) + if miner.web is not None: + miner.web.pwd = password + miner.web.username = username + if miner.rpc is not None: + miner.rpc.pwd = password + + return miner + + +async def _test_miner_serial(miner: BaseMiner) -> None: + """Test miner serial number retrieval.""" + print("1️⃣ Retrieving Miner Serial Number...") + try: + serial = await miner.get_serial_number() + if serial: + print(f" ✅ Miner Serial: {serial}\n") + else: + print(" ⚠️ Miner Serial: Not available\n") + except Exception as e: + print(f" ❌ Error: {e}\n") + + +async def _test_psu_serial(miner: BaseMiner) -> None: + """Test PSU serial number retrieval.""" + print("2️⃣ Retrieving PSU Serial Number...") + try: + psu_serial = await miner.get_psu_serial_number() + if psu_serial: + print(f" ✅ PSU Serial: {psu_serial}\n") + else: + print(" ⚠️ PSU Serial: Not available\n") + except Exception as e: + print(f" ❌ Error: {e}\n") + + +def _display_miner_data(data: MinerData) -> None: + """Display miner data including hashboard serials.""" + print(" ✅ Data retrieved successfully\n") + print(f" Miner IP: {data.ip}") + print(f" Serial Number: {data.serial_number or 'N/A'}") + print(f" PSU Serial Number: {data.psu_serial_number or 'N/A'}") + print(f" MAC Address: {data.mac or 'N/A'}") + print(f" Firmware: {data.fw_ver or 'N/A'}") + print(f" API Version: {data.api_ver or 'N/A'}") + + # Display hashboard serials + if data.hashboards: + print(f"\n Hashboards ({len(data.hashboards)}):") + for hb in data.hashboards: + serial = f"SN: {hb.serial_number}" if hb.serial_number else "SN: N/A" + chips = f"{hb.chips or '?'}" + temp = f"{hb.temp or '?'}°C" + print(f" Slot {hb.slot}: {serial} | Chips: {chips} | Temp: {temp}") + + +async def test_braiins_serials(ip: str, username: str, password: str) -> bool: + """Test serial number retrieval from a Braiins OS miner.""" + print(f"\n{'=' * 70}") + print(f"Testing Braiins Serial Numbers on {ip}") + print(f"{'=' * 70}\n") + + try: + miner = await _connect_to_miner(ip, username, password) + if miner is None: + return False + + await _test_miner_serial(miner) + await _test_psu_serial(miner) + + # Test 3: Get full data including hashboard serials + print("3️⃣ Retrieving Full Miner Data (with hashboard serials)...") + try: + data = await miner.get_data() + _display_miner_data(data) + print(f"\n{'=' * 70}\n") + return True + except Exception as e: + print(f" ❌ Error: {e}\n") + return False + + except Exception as e: + print(f"❌ Connection failed: {e}\n") + return False + + +def main(): + """Main entry point.""" + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + ip = sys.argv[1] + username = sys.argv[2] if len(sys.argv) > 2 else "root" + password = sys.argv[3] if len(sys.argv) > 3 else "root" + + # Run async test + success = asyncio.run(test_braiins_serials(ip, username, password)) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/miners_tests/__init__.py b/tests/miners_tests/__init__.py index a9fd29f73..6438cf995 100644 --- a/tests/miners_tests/__init__.py +++ b/tests/miners_tests/__init__.py @@ -93,6 +93,7 @@ def test_miner_data_map_keys(self): "hostname", "is_mining", "serial_number", + "psu_serial_number", "mac", "expected_hashrate", "uptime",