From fda7571bb030c1af70647be55c990b457bec03c0 Mon Sep 17 00:00:00 2001 From: b-rowan <121075405+b-rowan@users.noreply.github.com> Date: Thu, 28 Aug 2025 22:05:49 -0600 Subject: [PATCH 01/43] feature: add pyasic miner adapter --- .../domain/miner/controllers/pyasic.py | 168 ++++++++++++++++++ edge_mining/domain/miner/common.py | 1 + edge_mining/shared/adapter_configs/miner.py | 33 +++- pyproject.toml | 3 + 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 edge_mining/adapters/domain/miner/controllers/pyasic.py diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py new file mode 100644 index 0000000..eb69b6b --- /dev/null +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -0,0 +1,168 @@ +""" +pyasic adapter (Implementation of Port) +that controls a miner via pyasic. +""" +import asyncio +from typing import Dict, Optional + +import pyasic + +from edge_mining.domain.common import Watts +from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError +from edge_mining.domain.miner.ports import MinerControlPort +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.shared.adapter_configs.miner import MinerControllerPyASICConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import MinerControllerAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class PyASICMinerControllerAdapterFactory(MinerControllerAdapterFactory): + """ + Create a factory for pyasic Miner Controller Adapter. + This factory is used to create instances of the adapter. + """ + + def __init__(self): + self._miner: Optional[Miner] = None + + def from_miner(self, miner: Miner): + """Set the miner for this controller.""" + self._miner = miner + + def create( + self, + config: Optional[Configuration] = None, + logger: Optional[LoggerPort] = None, + external_service: Optional[ExternalServicePort] = None, + ) -> MinerControlPort: + """Create a miner controller adapter instance.""" + + if not isinstance(config, MinerControllerPyASICConfig): + raise MinerControllerConfigurationError( + "Invalid configuration for pyasic Miner Controller." + ) + + # Get the config from the provided configuration + miner_controller_configuration: MinerControllerPyASICConfig = config + + return PyASICMinerController( + ip=miner_controller_configuration.ip, + logger=logger, + ) + + +class PyASICMinerController(MinerControlPort): + """Controls a miner via pyasic.""" + + def __init__( + self, + ip: str, + logger: Optional[LoggerPort] = None, + ): + self.logger = logger + + self.ip = ip + + self._miner = None + + self._log_configuration() + + def _log_configuration(self): + if self.logger: + self.logger.debug( + f"Entities Configured: IP={self.ip}" + ) + + def _get_miner(self) -> Optional[pyasic.AnyMiner]: + if self._miner is None: + self._miner = asyncio.run(pyasic.get_miner(self.ip)) + return self._miner + + + def get_miner_hashrate(self) -> Optional[HashRate]: + """ + Gets the current hash rate, if available. + """ + + if self.logger: + self.logger.debug(f"Fetching hashrate from from {self.ip}...") + + hashrate = asyncio.run(self._get_miner().get_hashrate()) + if hashrate is None: + if self.logger: + self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") + return None + real_hashrate = HashRate( + value=float(hashrate), + unit=str(hashrate.unit) + ) + + if self.logger: + self.logger.debug(f"Hashrate fetched: {real_hashrate}") + + return real_hashrate + + def get_miner_power(self) -> Optional[Watts]: + """Gets the current power consumption, if available.""" + if self.logger: + self.logger.debug(f"Fetching power consumption from from {self.ip}...") + + wattage = asyncio.run(self._get_miner().get_wattage()) + if wattage is None: + if self.logger: + self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") + return None + power_watts = Watts(wattage) + + if self.logger: + self.logger.debug(f"Power consumption fetched: {power_watts}") + + return power_watts + + def get_miner_status(self) -> MinerStatus: + """Gets the current operational status of the miner.""" + if self.logger: + self.logger.debug(f"Fetching miner status from {self.ip}...") + + mining_state = asyncio.run(self._get_miner().is_mining()) + + state_map: Dict[Optional[bool], MinerStatus] = { + True: MinerStatus.ON, + False: MinerStatus.OFF, + None: MinerStatus.UNKNOWN, + } + + miner_status = state_map.get(mining_state, MinerStatus.UNKNOWN) + + if self.logger: + self.logger.debug(f"Miner status fetched: {miner_status}") + + return miner_status + + def stop_miner(self) -> bool: + """Attempts to stop the specified miner. Returns True on success request.""" + if self.logger: + self.logger.debug(f"Sending stop command to miner at {self.ip}...") + + success = asyncio.run(self._get_miner().stop_mining()) + + if self.logger: + self.logger.debug(f"Stop command sent. Success: {success}") + + return success + + def start_miner(self) -> bool: + """Attempts to start the miner. Returns True on success request.""" + if self.logger: + self.logger.debug(f"Sending start command to miner at {self.ip}...") + + success = asyncio.run(self._get_miner().resume_mining()) + + if self.logger: + self.logger.debug(f"Start command sent. Success: {success}") + + return success diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 58233b7..78c2f3b 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -21,3 +21,4 @@ class MinerControllerAdapter(AdapterType): DUMMY = "dummy" GENERIC_SOCKET_HOME_ASSISTANT_API = "generic_socket_home_assistant_api" + PYASIC = "pyasic" diff --git a/edge_mining/shared/adapter_configs/miner.py b/edge_mining/shared/adapter_configs/miner.py index 4a55b55..1ecaf47 100644 --- a/edge_mining/shared/adapter_configs/miner.py +++ b/edge_mining/shared/adapter_configs/miner.py @@ -2,7 +2,7 @@ Collection of adapters configuration for the miner domain of the Edge Mining application. """ - +import ipaddress from dataclasses import asdict, dataclass, field from edge_mining.domain.miner.common import MinerControllerAdapter @@ -65,3 +65,34 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict): """Create a configuration object from a dictionary""" return cls(**data) + +@dataclass(frozen=True) +class MinerControllerPyASICConfig(MinerControllerConfig): + """ + Miner controller configuration. It encapsulates the configuration parameters + to control a miner via pyasic. + """ + + ip: str = field(default="switch.miner_socket") + + + def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For the pyasic Miner Controller, it is valid if the adapter type matches, + and the IP is a valid IP address. + """ + try: + ipaddress.ip_address(self.ip) + return adapter_type == MinerControllerAdapter.PYASIC + except ValueError: + return False + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/pyproject.toml b/pyproject.toml index aa67d84..f9584c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ telegram = [ solar = [ "astral>=3.2", ] +pyasic = [ + "pyasic==0.76.5" +] all = [ "edge-mining[api,homeassistant,mqtt,telegram,solar]", ] From 02f307472a0b49076d6e502ce84820b61b6d0fc8 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:20:05 +0200 Subject: [PATCH 02/43] refactor: enhance PyASIC miner controller to ensure miner instance retrieval before operations and fix typo --- .../domain/miner/controllers/pyasic.py | 82 ++++++++++++++----- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index eb69b6b..61c97f1 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -2,10 +2,13 @@ pyasic adapter (Implementation of Port) that controls a miner via pyasic. """ + import asyncio from typing import Dict, Optional import pyasic +from pyasic import AnyMiner +from pyasic.device.algorithm.hashrate import AlgoHashRate from edge_mining.domain.common import Watts from edge_mining.domain.miner.common import MinerStatus @@ -42,9 +45,7 @@ def create( """Create a miner controller adapter instance.""" if not isinstance(config, MinerControllerPyASICConfig): - raise MinerControllerConfigurationError( - "Invalid configuration for pyasic Miner Controller." - ) + raise MinerControllerConfigurationError("Invalid configuration for pyasic Miner Controller.") # Get the config from the provided configuration miner_controller_configuration: MinerControllerPyASICConfig = config @@ -67,21 +68,21 @@ def __init__( self.ip = ip - self._miner = None + self._miner: Optional[AnyMiner] = None self._log_configuration() + # Retrieve the pyasic miner instance + self._get_miner() + def _log_configuration(self): if self.logger: - self.logger.debug( - f"Entities Configured: IP={self.ip}" - ) + self.logger.debug(f"Entities Configured: IP={self.ip}") - def _get_miner(self) -> Optional[pyasic.AnyMiner]: + def _get_miner(self) -> None: + """Retrieve the pyasic miner instance.""" if self._miner is None: self._miner = asyncio.run(pyasic.get_miner(self.ip)) - return self._miner - def get_miner_hashrate(self) -> Optional[HashRate]: """ @@ -91,15 +92,20 @@ def get_miner_hashrate(self) -> Optional[HashRate]: if self.logger: self.logger.debug(f"Fetching hashrate from from {self.ip}...") - hashrate = asyncio.run(self._get_miner().get_hashrate()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return None + + hashrate: Optional[AlgoHashRate] = asyncio.run(self._miner.get_hashrate()) if hashrate is None: if self.logger: self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") return None - real_hashrate = HashRate( - value=float(hashrate), - unit=str(hashrate.unit) - ) + real_hashrate = HashRate(value=float(hashrate), unit=str(hashrate.unit)) if self.logger: self.logger.debug(f"Hashrate fetched: {real_hashrate}") @@ -111,12 +117,20 @@ def get_miner_power(self) -> Optional[Watts]: if self.logger: self.logger.debug(f"Fetching power consumption from from {self.ip}...") - wattage = asyncio.run(self._get_miner().get_wattage()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return None + + wattage = asyncio.run(self._miner.get_wattage()) if wattage is None: if self.logger: self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") return None - power_watts = Watts(wattage) + power_watts = Watts(wattage) if self.logger: self.logger.debug(f"Power consumption fetched: {power_watts}") @@ -128,7 +142,15 @@ def get_miner_status(self) -> MinerStatus: if self.logger: self.logger.debug(f"Fetching miner status from {self.ip}...") - mining_state = asyncio.run(self._get_miner().is_mining()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return MinerStatus.UNKNOWN + + mining_state = asyncio.run(self._miner.is_mining()) state_map: Dict[Optional[bool], MinerStatus] = { True: MinerStatus.ON, @@ -148,21 +170,37 @@ def stop_miner(self) -> bool: if self.logger: self.logger.debug(f"Sending stop command to miner at {self.ip}...") - success = asyncio.run(self._get_miner().stop_mining()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return False + + success = asyncio.run(self._miner.stop_mining()) if self.logger: self.logger.debug(f"Stop command sent. Success: {success}") - return success + return success or False def start_miner(self) -> bool: """Attempts to start the miner. Returns True on success request.""" if self.logger: self.logger.debug(f"Sending start command to miner at {self.ip}...") - success = asyncio.run(self._get_miner().resume_mining()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return False + + success = asyncio.run(self._miner.resume_mining()) if self.logger: self.logger.debug(f"Start command sent. Success: {success}") - return success + return success or False From 65353e311ccbdd094792a29e90d3e8f8ab7fa883 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:22:36 +0200 Subject: [PATCH 03/43] feat: add PyASIC miner controller to adapter service for enhanced mining capabilities --- .../application/services/adapter_service.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index 4cacdf4..e63e075 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -21,6 +21,7 @@ from edge_mining.adapters.domain.miner.controllers.generic_socket_home_assistant_api import ( GenericSocketHomeAssistantAPIMinerControllerAdapterFactory, ) +from edge_mining.adapters.domain.miner.controllers.pyasic import PyASICMinerControllerAdapterFactory from edge_mining.adapters.domain.notification.dummy import DummyNotifier from edge_mining.adapters.domain.notification.telegram import TelegramNotifierFactory from edge_mining.adapters.domain.performance.dummy import DummyMiningPerformanceTracker @@ -69,6 +70,7 @@ EnergyMonitorAdapterFactory, ExternalServiceFactory, ForecastAdapterFactory, + MinerControllerAdapterFactory, ) from edge_mining.shared.logging.port import LoggerPort @@ -280,6 +282,7 @@ def _initialize_miner_controller_adapter( ) try: + miner_controller_factory: Optional[MinerControllerAdapterFactory] = None instance: Optional[MinerControlPort] = None if miner_controller.adapter_type == MinerControllerAdapter.DUMMY: @@ -299,6 +302,17 @@ def _initialize_miner_controller_adapter( miner_controller_factory.from_miner(miner) + instance = miner_controller_factory.create( + config=miner_controller.config, + logger=self.logger, + external_service=external_service, + ) + elif miner_controller.adapter_type == MinerControllerAdapter.PYASIC: + # --- PyASIC Controller --- + miner_controller_factory = PyASICMinerControllerAdapterFactory() + + miner_controller_factory.from_miner(miner) + instance = miner_controller_factory.create( config=miner_controller.config, logger=self.logger, From d2301dece33be42b9db4b598d55844026e3c8bf4 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:28:16 +0200 Subject: [PATCH 04/43] refactor: streamline import statements in adapter_service.py for improved readability --- .../application/services/adapter_service.py | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index e63e075..1625260 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -4,18 +4,10 @@ from typing import Dict, List, Optional, Union -from edge_mining.adapters.domain.energy.dummy_solar import ( - DummySolarEnergyMonitorFactory, -) -from edge_mining.adapters.domain.energy.home_assistant_api import ( - HomeAssistantAPIEnergyMonitorFactory, -) -from edge_mining.adapters.domain.forecast.dummy_solar import ( - DummyForecastProviderFactory, -) -from edge_mining.adapters.domain.forecast.home_assistant_api import ( - HomeAssistantForecastProviderFactory, -) +from edge_mining.adapters.domain.energy.dummy_solar import DummySolarEnergyMonitorFactory +from edge_mining.adapters.domain.energy.home_assistant_api import HomeAssistantAPIEnergyMonitorFactory +from edge_mining.adapters.domain.forecast.dummy_solar import DummyForecastProviderFactory +from edge_mining.adapters.domain.forecast.home_assistant_api import HomeAssistantForecastProviderFactory from edge_mining.adapters.domain.home_load.dummy import DummyHomeForecastProvider from edge_mining.adapters.domain.miner.controllers.dummy import DummyMinerController from edge_mining.adapters.domain.miner.controllers.generic_socket_home_assistant_api import ( @@ -25,9 +17,7 @@ from edge_mining.adapters.domain.notification.dummy import DummyNotifier from edge_mining.adapters.domain.notification.telegram import TelegramNotifierFactory from edge_mining.adapters.domain.performance.dummy import DummyMiningPerformanceTracker -from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ( - ServiceHomeAssistantAPIFactory, -) +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ServiceHomeAssistantAPIFactory from edge_mining.adapters.infrastructure.rule_engine.common import RuleEngineType from edge_mining.adapters.infrastructure.rule_engine.factory import RuleEngineFactory from edge_mining.application.interfaces import AdapterServiceInterface @@ -37,16 +27,10 @@ from edge_mining.domain.energy.ports import EnergyMonitorPort, EnergyMonitorRepository from edge_mining.domain.forecast.common import ForecastProviderAdapter from edge_mining.domain.forecast.entities import ForecastProvider -from edge_mining.domain.forecast.ports import ( - ForecastProviderPort, - ForecastProviderRepository, -) +from edge_mining.domain.forecast.ports import ForecastProviderPort, ForecastProviderRepository from edge_mining.domain.home_load.common import HomeForecastProviderAdapter from edge_mining.domain.home_load.entities import HomeForecastProvider -from edge_mining.domain.home_load.ports import ( - HomeForecastProviderPort, - HomeForecastProviderRepository, -) +from edge_mining.domain.home_load.ports import HomeForecastProviderPort, HomeForecastProviderRepository from edge_mining.domain.miner.common import MinerControllerAdapter from edge_mining.domain.miner.entities import Miner, MinerController from edge_mining.domain.miner.ports import MinerControllerRepository, MinerControlPort @@ -55,17 +39,11 @@ from edge_mining.domain.notification.ports import NotificationPort, NotifierRepository from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter from edge_mining.domain.performance.entities import MiningPerformanceTracker -from edge_mining.domain.performance.ports import ( - MiningPerformanceTrackerPort, - MiningPerformanceTrackerRepository, -) +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort, MiningPerformanceTrackerRepository from edge_mining.domain.policy.services import RuleEngine from edge_mining.shared.external_services.common import ExternalServiceAdapter from edge_mining.shared.external_services.entities import ExternalService -from edge_mining.shared.external_services.ports import ( - ExternalServicePort, - ExternalServiceRepository, -) +from edge_mining.shared.external_services.ports import ExternalServicePort, ExternalServiceRepository from edge_mining.shared.interfaces.factories import ( EnergyMonitorAdapterFactory, ExternalServiceFactory, From a16e00ecb9c99e8ef384ab4570e6d8b11d82327c Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:29:02 +0200 Subject: [PATCH 05/43] fix: update default IP address in MinerControllerPyASICConfig and clean up code formatting --- edge_mining/shared/adapter_configs/miner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/edge_mining/shared/adapter_configs/miner.py b/edge_mining/shared/adapter_configs/miner.py index 1ecaf47..0041bcf 100644 --- a/edge_mining/shared/adapter_configs/miner.py +++ b/edge_mining/shared/adapter_configs/miner.py @@ -2,6 +2,7 @@ Collection of adapters configuration for the miner domain of the Edge Mining application. """ + import ipaddress from dataclasses import asdict, dataclass, field @@ -66,6 +67,7 @@ def from_dict(cls, data: dict): """Create a configuration object from a dictionary""" return cls(**data) + @dataclass(frozen=True) class MinerControllerPyASICConfig(MinerControllerConfig): """ @@ -73,8 +75,7 @@ class MinerControllerPyASICConfig(MinerControllerConfig): to control a miner via pyasic. """ - ip: str = field(default="switch.miner_socket") - + ip: str = field(default="192.168.1.100") def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: """ From 6dd5576b2826f45177fd1798ff46a71d71381d7c Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:29:30 +0200 Subject: [PATCH 06/43] fix: add PyASIC configuration to miner adapter maps for proper integration --- edge_mining/shared/adapter_maps/miner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/edge_mining/shared/adapter_maps/miner.py b/edge_mining/shared/adapter_maps/miner.py index 140880c..b08d110 100644 --- a/edge_mining/shared/adapter_maps/miner.py +++ b/edge_mining/shared/adapter_maps/miner.py @@ -9,16 +9,19 @@ from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, ) from edge_mining.shared.external_services.common import ExternalServiceAdapter from edge_mining.shared.interfaces.config import MinerControllerConfig MINER_CONTROLLER_CONFIG_TYPE_MAP: Dict[MinerControllerAdapter, Optional[type[MinerControllerConfig]]] = { MinerControllerAdapter.DUMMY: MinerControllerDummyConfig, + MinerControllerAdapter.PYASIC: MinerControllerPyASICConfig, MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API: MinerControllerGenericSocketHomeAssistantAPIConfig, } MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP: Dict[MinerControllerAdapter, Optional[ExternalServiceAdapter]] = { MinerControllerAdapter.DUMMY: None, # Dummy does not use an external service + MinerControllerAdapter.PYASIC: None, # PyASIC does not use an external service MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API: ExternalServiceAdapter.HOME_ASSISTANT_API, } From 681fd6786cf1c14e256899d7bc5434e0218c0d9b Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:29:59 +0200 Subject: [PATCH 07/43] feat: add validation schema for MinerControllerPyASICConfig with IP address validation --- edge_mining/adapters/domain/miner/schemas.py | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index e0cbd9d..a84ec42 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -1,5 +1,6 @@ """Validation schemas for miner domain.""" +import ipaddress import uuid from typing import Dict, Optional, Union, cast @@ -12,6 +13,7 @@ from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, ) from edge_mining.shared.interfaces.config import MinerControllerConfig @@ -500,10 +502,43 @@ class Config: validate_assignment = True +class MinerControllerPyASICConfigSchema(BaseModel): + """Schema for MinerControllerPyASICConfig.""" + + ip: str = Field(..., description="IP address of the PyASIC miner") + + @field_validator("ip") + @classmethod + def validate_ip(cls, v: str) -> str: + """Validate that the value is a plausible IP address.""" + v = v.strip() + if not v: + raise ValueError("IP address must be a non-empty string") + try: + ipaddress.ip_address(str(v)) + except ValueError as e: + raise ValueError(f"Invalid IP address: {v}") from e + return v + + def to_model(self) -> MinerControllerPyASICConfig: + """ + Convert schema to MinerControllerPyASICConfig adapter configuration model instance. + """ + + return MinerControllerPyASICConfig(ip=self.ip) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + MINER_CONTROLLER_CONFIG_SCHEMA_MAP: Dict[ type[MinerControllerConfig], Union[type[MinerControllerDummyConfigSchema], type[MinerControllerGenericSocketHomeAssistantAPIConfigSchema]], ] = { MinerControllerDummyConfig: MinerControllerDummyConfigSchema, MinerControllerGenericSocketHomeAssistantAPIConfig: MinerControllerGenericSocketHomeAssistantAPIConfigSchema, + MinerControllerPyASICConfig: MinerControllerPyASICConfigSchema, } From 63a8fdd8fff8eb013bfff7cf55d5040ec65e3f69 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:30:46 +0200 Subject: [PATCH 08/43] feat: add handler for PyASIC Miner Controller configuration in CLI menu --- .../adapters/domain/miner/cli/commands.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/edge_mining/adapters/domain/miner/cli/commands.py b/edge_mining/adapters/domain/miner/cli/commands.py index d6107ca..26274f2 100644 --- a/edge_mining/adapters/domain/miner/cli/commands.py +++ b/edge_mining/adapters/domain/miner/cli/commands.py @@ -18,6 +18,7 @@ from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, ) from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP from edge_mining.shared.external_services.entities import ExternalService @@ -540,6 +541,20 @@ def handle_miner_controller_generic_socket_home_assistant_api_config(miner: Opti ) +def handle_miner_controller_pyasic_config(miner: Optional[Miner]) -> MinerControllerConfig: + """Handle configuration for the PyASIC Miner Controller.""" + click.echo(click.style("\n--- PyASIC Miner Controller Configuration ---", fg="yellow")) + + ip: str = click.prompt( + "IP address of the PyASIC miner (eg. 192.168.1.100)", + type=str, + default="192.168.1.100", + ) + return MinerControllerPyASICConfig( + ip=ip, + ) + + def handle_miner_controller_configuration( adapter_type: MinerControllerAdapter, miner: Optional[Miner] ) -> Optional[MinerControllerConfig]: @@ -549,6 +564,8 @@ def handle_miner_controller_configuration( config = handle_miner_controller_dummy_config(miner) elif adapter_type.value == MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API.value: config = handle_miner_controller_generic_socket_home_assistant_api_config(miner) + elif adapter_type.value == MinerControllerAdapter.PYASIC.value: + config = handle_miner_controller_pyasic_config(miner) else: click.echo(click.style("Unsupported controller type selected. Aborting.", fg="red")) return config From 78985a69cc3edc07f6a3068612d14bfc086c5202 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:31:59 +0200 Subject: [PATCH 09/43] fix: update pydantic and homeassistant_api versions in dependencies to be compatible with pyasic --- pyproject.toml | 6 +++--- requirements.txt | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f9584c3..8f9afaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ keywords = [ ] requires-python = ">=3.11" dependencies = [ - "pydantic>=2.8.2", + "pydantic>=2.11.0", "pyyaml>=6.0.2", "pydantic-settings>=2.8.1", "apscheduler>=3.11.0", @@ -51,7 +51,7 @@ api = [ "uvicorn[standard]>=0.34.1", ] homeassistant = [ - "homeassistant_api>=5.0.0", + "homeassistant_api==4.2.2.post1", ] mqtt = [ "paho-mqtt>=2.1.0", @@ -66,7 +66,7 @@ pyasic = [ "pyasic==0.76.5" ] all = [ - "edge-mining[api,homeassistant,mqtt,telegram,solar]", + "edge-mining[api,homeassistant,mqtt,telegram,solar,pyasic]", ] dev = [ "pytest>=6.0", diff --git a/requirements.txt b/requirements.txt index 7fbebf4..5430c64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Core Dependencies -pydantic==2.8.2 +pydantic==2.11.0 pyyaml==6.0.2 pydantic-settings==2.8.1 apscheduler==3.11.0 @@ -12,6 +12,7 @@ uvicorn[standard]==0.34.1 # Optional - For specific Driven Adapters paho-mqtt==2.1.0 -homeassistant_api==5.0.0 +homeassistant_api==4.2.2.post1 python-telegram-bot>=20.0 -astral==3.2 \ No newline at end of file +astral==3.2 +pyasic==0.76.5 From 7f09d60ad262c3a800f2e394905c214691680e11 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:32:26 +0200 Subject: [PATCH 10/43] fix: add "pyasic" to cSpell words in settings.json for improved spell checking --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 96fee94..8269707 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,6 +54,7 @@ "sheduler", "satoshi", "hashrate", - "homeassistant" + "homeassistant", + "pyasic" ] } From e9cb49227572adb7c57d6e8a02a076cd28884615 Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 4 Sep 2025 21:16:58 +0200 Subject: [PATCH 11/43] fix: update MINER_CONTROLLER_CONFIG_SCHEMA_MAP to include MinerControllerPyASICConfigSchema --- edge_mining/adapters/domain/miner/schemas.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index a84ec42..be4764e 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -536,7 +536,11 @@ class Config: MINER_CONTROLLER_CONFIG_SCHEMA_MAP: Dict[ type[MinerControllerConfig], - Union[type[MinerControllerDummyConfigSchema], type[MinerControllerGenericSocketHomeAssistantAPIConfigSchema]], + Union[ + type[MinerControllerDummyConfigSchema], + type[MinerControllerGenericSocketHomeAssistantAPIConfigSchema], + type[MinerControllerPyASICConfigSchema], + ], ] = { MinerControllerDummyConfig: MinerControllerDummyConfigSchema, MinerControllerGenericSocketHomeAssistantAPIConfig: MinerControllerGenericSocketHomeAssistantAPIConfigSchema, From ab57b97f52281a06af04b15a9ff2a4e5c0311644 Mon Sep 17 00:00:00 2001 From: markoceri Date: Mon, 8 Sep 2025 16:51:48 +0200 Subject: [PATCH 12/43] feat: add utility function to run asynchronous functions from synchronous context --- edge_mining/adapters/utils.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 edge_mining/adapters/utils.py diff --git a/edge_mining/adapters/utils.py b/edge_mining/adapters/utils.py new file mode 100644 index 0000000..ced8239 --- /dev/null +++ b/edge_mining/adapters/utils.py @@ -0,0 +1,35 @@ +"""Collection of utility functions for adapters.""" + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Coroutine, TypeVar + +T = TypeVar("T") + + +def run_async_func(func: Callable[[], Coroutine[Any, Any, T]]) -> T: + """ + Executes an asynchronous function (coroutine) from a synchronous context, + handling the presence of an already running event loop. + + If no event loop is running, the coroutine is executed directly using asyncio.run(). + If an event loop is already running (e.g., in environments like FastAPI), + the coroutine is executed in a separate thread to avoid conflicts with the main event loop. + + Args: + func: A zero-argument function that returns a coroutine (e.g., lambda: my_async_func()). + + Returns: + The result returned by the coroutine. + + Raises: + Propagates any exceptions raised by the coroutine. + """ + + try: + asyncio.get_running_loop() # Triggers RuntimeError if no running event loop + with ThreadPoolExecutor(1) as pool: + return pool.submit(lambda: asyncio.run(func())).result() + + except RuntimeError: + return asyncio.run(func()) From 3a8eff032db79e35fbc14d7074801c5e84f44268 Mon Sep 17 00:00:00 2001 From: markoceri Date: Mon, 8 Sep 2025 16:54:31 +0200 Subject: [PATCH 13/43] fix: async miner retrieval into mixed sync/async context --- .../adapters/domain/miner/controllers/pyasic.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 61c97f1..1b1ac57 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -3,13 +3,13 @@ that controls a miner via pyasic. """ -import asyncio -from typing import Dict, Optional +from typing import Dict, Optional, cast import pyasic from pyasic import AnyMiner from pyasic.device.algorithm.hashrate import AlgoHashRate +from edge_mining.adapters.utils import run_async_func from edge_mining.domain.common import Watts from edge_mining.domain.miner.common import MinerStatus from edge_mining.domain.miner.entities import Miner @@ -82,7 +82,15 @@ def _log_configuration(self): def _get_miner(self) -> None: """Retrieve the pyasic miner instance.""" if self._miner is None: - self._miner = asyncio.run(pyasic.get_miner(self.ip)) + try: + miner = run_async_func(lambda: pyasic.get_miner(self.ip)) + if miner is not None: + self._miner = cast(AnyMiner, miner) + if self.logger: + self.logger.debug(f"Successfully retrieved miner instance from {self.ip}") + except Exception as e: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}: {e}") def get_miner_hashrate(self) -> Optional[HashRate]: """ From c7a312b8f7601d7bc073a0f57346ab3efb95f60f Mon Sep 17 00:00:00 2001 From: markoceri Date: Mon, 8 Sep 2025 16:55:49 +0200 Subject: [PATCH 14/43] refactor: remove miner retrieval in PyASICMinerController initialization --- edge_mining/adapters/domain/miner/controllers/pyasic.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 1b1ac57..023fea4 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -72,9 +72,6 @@ def __init__( self._log_configuration() - # Retrieve the pyasic miner instance - self._get_miner() - def _log_configuration(self): if self.logger: self.logger.debug(f"Entities Configured: IP={self.ip}") From 0ae888f2ab1aa26784a7a3035fc8012a86b971ce Mon Sep 17 00:00:00 2001 From: markoceri Date: Mon, 8 Sep 2025 16:56:48 +0200 Subject: [PATCH 15/43] fix: update miner instance checks and replace asyncio.run with utility function for async calls --- .../domain/miner/controllers/pyasic.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 023fea4..33c7151 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -100,12 +100,13 @@ def get_miner_hashrate(self) -> Optional[HashRate]: # Get pyasic miner instance self._get_miner() - if not self._miner: + if self._miner is None: if self.logger: self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") return None - hashrate: Optional[AlgoHashRate] = asyncio.run(self._miner.get_hashrate()) + miner = self._miner + hashrate: Optional[AlgoHashRate] = run_async_func(lambda: miner.get_hashrate()) if hashrate is None: if self.logger: self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") @@ -125,12 +126,13 @@ def get_miner_power(self) -> Optional[Watts]: # Get pyasic miner instance self._get_miner() - if not self._miner: + if self._miner is None: if self.logger: self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") return None - wattage = asyncio.run(self._miner.get_wattage()) + miner = self._miner + wattage = run_async_func(lambda: miner.get_wattage()) if wattage is None: if self.logger: self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") @@ -150,12 +152,13 @@ def get_miner_status(self) -> MinerStatus: # Get pyasic miner instance self._get_miner() - if not self._miner: + if self._miner is None: if self.logger: self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") return MinerStatus.UNKNOWN - mining_state = asyncio.run(self._miner.is_mining()) + miner = self._miner + mining_state = run_async_func(lambda: miner.is_mining()) state_map: Dict[Optional[bool], MinerStatus] = { True: MinerStatus.ON, @@ -178,12 +181,13 @@ def stop_miner(self) -> bool: # Get pyasic miner instance self._get_miner() - if not self._miner: + if self._miner is None: if self.logger: self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") return False - success = asyncio.run(self._miner.stop_mining()) + miner = self._miner + success = run_async_func(lambda: miner.stop_mining()) if self.logger: self.logger.debug(f"Stop command sent. Success: {success}") @@ -203,7 +207,8 @@ def start_miner(self) -> bool: self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") return False - success = asyncio.run(self._miner.resume_mining()) + miner = self._miner + success = run_async_func(lambda: miner.resume_mining()) if self.logger: self.logger.debug(f"Start command sent. Success: {success}") From 4a88ca7e68e15254826eb3eb71b95a9c1389ff66 Mon Sep 17 00:00:00 2001 From: b-rowan <121075405+b-rowan@users.noreply.github.com> Date: Thu, 28 Aug 2025 22:05:49 -0600 Subject: [PATCH 16/43] feature: add pyasic miner adapter --- .../domain/miner/controllers/pyasic.py | 168 ++++++++++++++++++ edge_mining/domain/miner/common.py | 1 + edge_mining/shared/adapter_configs/miner.py | 33 +++- pyproject.toml | 3 + 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 edge_mining/adapters/domain/miner/controllers/pyasic.py diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py new file mode 100644 index 0000000..eb69b6b --- /dev/null +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -0,0 +1,168 @@ +""" +pyasic adapter (Implementation of Port) +that controls a miner via pyasic. +""" +import asyncio +from typing import Dict, Optional + +import pyasic + +from edge_mining.domain.common import Watts +from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError +from edge_mining.domain.miner.ports import MinerControlPort +from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.shared.adapter_configs.miner import MinerControllerPyASICConfig +from edge_mining.shared.external_services.ports import ExternalServicePort +from edge_mining.shared.interfaces.config import Configuration +from edge_mining.shared.interfaces.factories import MinerControllerAdapterFactory +from edge_mining.shared.logging.port import LoggerPort + + +class PyASICMinerControllerAdapterFactory(MinerControllerAdapterFactory): + """ + Create a factory for pyasic Miner Controller Adapter. + This factory is used to create instances of the adapter. + """ + + def __init__(self): + self._miner: Optional[Miner] = None + + def from_miner(self, miner: Miner): + """Set the miner for this controller.""" + self._miner = miner + + def create( + self, + config: Optional[Configuration] = None, + logger: Optional[LoggerPort] = None, + external_service: Optional[ExternalServicePort] = None, + ) -> MinerControlPort: + """Create a miner controller adapter instance.""" + + if not isinstance(config, MinerControllerPyASICConfig): + raise MinerControllerConfigurationError( + "Invalid configuration for pyasic Miner Controller." + ) + + # Get the config from the provided configuration + miner_controller_configuration: MinerControllerPyASICConfig = config + + return PyASICMinerController( + ip=miner_controller_configuration.ip, + logger=logger, + ) + + +class PyASICMinerController(MinerControlPort): + """Controls a miner via pyasic.""" + + def __init__( + self, + ip: str, + logger: Optional[LoggerPort] = None, + ): + self.logger = logger + + self.ip = ip + + self._miner = None + + self._log_configuration() + + def _log_configuration(self): + if self.logger: + self.logger.debug( + f"Entities Configured: IP={self.ip}" + ) + + def _get_miner(self) -> Optional[pyasic.AnyMiner]: + if self._miner is None: + self._miner = asyncio.run(pyasic.get_miner(self.ip)) + return self._miner + + + def get_miner_hashrate(self) -> Optional[HashRate]: + """ + Gets the current hash rate, if available. + """ + + if self.logger: + self.logger.debug(f"Fetching hashrate from from {self.ip}...") + + hashrate = asyncio.run(self._get_miner().get_hashrate()) + if hashrate is None: + if self.logger: + self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") + return None + real_hashrate = HashRate( + value=float(hashrate), + unit=str(hashrate.unit) + ) + + if self.logger: + self.logger.debug(f"Hashrate fetched: {real_hashrate}") + + return real_hashrate + + def get_miner_power(self) -> Optional[Watts]: + """Gets the current power consumption, if available.""" + if self.logger: + self.logger.debug(f"Fetching power consumption from from {self.ip}...") + + wattage = asyncio.run(self._get_miner().get_wattage()) + if wattage is None: + if self.logger: + self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") + return None + power_watts = Watts(wattage) + + if self.logger: + self.logger.debug(f"Power consumption fetched: {power_watts}") + + return power_watts + + def get_miner_status(self) -> MinerStatus: + """Gets the current operational status of the miner.""" + if self.logger: + self.logger.debug(f"Fetching miner status from {self.ip}...") + + mining_state = asyncio.run(self._get_miner().is_mining()) + + state_map: Dict[Optional[bool], MinerStatus] = { + True: MinerStatus.ON, + False: MinerStatus.OFF, + None: MinerStatus.UNKNOWN, + } + + miner_status = state_map.get(mining_state, MinerStatus.UNKNOWN) + + if self.logger: + self.logger.debug(f"Miner status fetched: {miner_status}") + + return miner_status + + def stop_miner(self) -> bool: + """Attempts to stop the specified miner. Returns True on success request.""" + if self.logger: + self.logger.debug(f"Sending stop command to miner at {self.ip}...") + + success = asyncio.run(self._get_miner().stop_mining()) + + if self.logger: + self.logger.debug(f"Stop command sent. Success: {success}") + + return success + + def start_miner(self) -> bool: + """Attempts to start the miner. Returns True on success request.""" + if self.logger: + self.logger.debug(f"Sending start command to miner at {self.ip}...") + + success = asyncio.run(self._get_miner().resume_mining()) + + if self.logger: + self.logger.debug(f"Start command sent. Success: {success}") + + return success diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 58233b7..78c2f3b 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -21,3 +21,4 @@ class MinerControllerAdapter(AdapterType): DUMMY = "dummy" GENERIC_SOCKET_HOME_ASSISTANT_API = "generic_socket_home_assistant_api" + PYASIC = "pyasic" diff --git a/edge_mining/shared/adapter_configs/miner.py b/edge_mining/shared/adapter_configs/miner.py index 4a55b55..1ecaf47 100644 --- a/edge_mining/shared/adapter_configs/miner.py +++ b/edge_mining/shared/adapter_configs/miner.py @@ -2,7 +2,7 @@ Collection of adapters configuration for the miner domain of the Edge Mining application. """ - +import ipaddress from dataclasses import asdict, dataclass, field from edge_mining.domain.miner.common import MinerControllerAdapter @@ -65,3 +65,34 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict): """Create a configuration object from a dictionary""" return cls(**data) + +@dataclass(frozen=True) +class MinerControllerPyASICConfig(MinerControllerConfig): + """ + Miner controller configuration. It encapsulates the configuration parameters + to control a miner via pyasic. + """ + + ip: str = field(default="switch.miner_socket") + + + def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: + """ + Check if the configuration is valid for the given adapter type. + For the pyasic Miner Controller, it is valid if the adapter type matches, + and the IP is a valid IP address. + """ + try: + ipaddress.ip_address(self.ip) + return adapter_type == MinerControllerAdapter.PYASIC + except ValueError: + return False + + def to_dict(self) -> dict: + """Converts the configuration object into a serializable dictionary""" + return {**asdict(self)} + + @classmethod + def from_dict(cls, data: dict): + """Create a configuration object from a dictionary""" + return cls(**data) diff --git a/pyproject.toml b/pyproject.toml index aa67d84..f9584c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ telegram = [ solar = [ "astral>=3.2", ] +pyasic = [ + "pyasic==0.76.5" +] all = [ "edge-mining[api,homeassistant,mqtt,telegram,solar]", ] From 86fdb6a012d9cef4794e2f33ae194a9a763b46bc Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:20:05 +0200 Subject: [PATCH 17/43] refactor: enhance PyASIC miner controller to ensure miner instance retrieval before operations and fix typo --- .../domain/miner/controllers/pyasic.py | 82 ++++++++++++++----- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index eb69b6b..61c97f1 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -2,10 +2,13 @@ pyasic adapter (Implementation of Port) that controls a miner via pyasic. """ + import asyncio from typing import Dict, Optional import pyasic +from pyasic import AnyMiner +from pyasic.device.algorithm.hashrate import AlgoHashRate from edge_mining.domain.common import Watts from edge_mining.domain.miner.common import MinerStatus @@ -42,9 +45,7 @@ def create( """Create a miner controller adapter instance.""" if not isinstance(config, MinerControllerPyASICConfig): - raise MinerControllerConfigurationError( - "Invalid configuration for pyasic Miner Controller." - ) + raise MinerControllerConfigurationError("Invalid configuration for pyasic Miner Controller.") # Get the config from the provided configuration miner_controller_configuration: MinerControllerPyASICConfig = config @@ -67,21 +68,21 @@ def __init__( self.ip = ip - self._miner = None + self._miner: Optional[AnyMiner] = None self._log_configuration() + # Retrieve the pyasic miner instance + self._get_miner() + def _log_configuration(self): if self.logger: - self.logger.debug( - f"Entities Configured: IP={self.ip}" - ) + self.logger.debug(f"Entities Configured: IP={self.ip}") - def _get_miner(self) -> Optional[pyasic.AnyMiner]: + def _get_miner(self) -> None: + """Retrieve the pyasic miner instance.""" if self._miner is None: self._miner = asyncio.run(pyasic.get_miner(self.ip)) - return self._miner - def get_miner_hashrate(self) -> Optional[HashRate]: """ @@ -91,15 +92,20 @@ def get_miner_hashrate(self) -> Optional[HashRate]: if self.logger: self.logger.debug(f"Fetching hashrate from from {self.ip}...") - hashrate = asyncio.run(self._get_miner().get_hashrate()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return None + + hashrate: Optional[AlgoHashRate] = asyncio.run(self._miner.get_hashrate()) if hashrate is None: if self.logger: self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") return None - real_hashrate = HashRate( - value=float(hashrate), - unit=str(hashrate.unit) - ) + real_hashrate = HashRate(value=float(hashrate), unit=str(hashrate.unit)) if self.logger: self.logger.debug(f"Hashrate fetched: {real_hashrate}") @@ -111,12 +117,20 @@ def get_miner_power(self) -> Optional[Watts]: if self.logger: self.logger.debug(f"Fetching power consumption from from {self.ip}...") - wattage = asyncio.run(self._get_miner().get_wattage()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return None + + wattage = asyncio.run(self._miner.get_wattage()) if wattage is None: if self.logger: self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") return None - power_watts = Watts(wattage) + power_watts = Watts(wattage) if self.logger: self.logger.debug(f"Power consumption fetched: {power_watts}") @@ -128,7 +142,15 @@ def get_miner_status(self) -> MinerStatus: if self.logger: self.logger.debug(f"Fetching miner status from {self.ip}...") - mining_state = asyncio.run(self._get_miner().is_mining()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return MinerStatus.UNKNOWN + + mining_state = asyncio.run(self._miner.is_mining()) state_map: Dict[Optional[bool], MinerStatus] = { True: MinerStatus.ON, @@ -148,21 +170,37 @@ def stop_miner(self) -> bool: if self.logger: self.logger.debug(f"Sending stop command to miner at {self.ip}...") - success = asyncio.run(self._get_miner().stop_mining()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return False + + success = asyncio.run(self._miner.stop_mining()) if self.logger: self.logger.debug(f"Stop command sent. Success: {success}") - return success + return success or False def start_miner(self) -> bool: """Attempts to start the miner. Returns True on success request.""" if self.logger: self.logger.debug(f"Sending start command to miner at {self.ip}...") - success = asyncio.run(self._get_miner().resume_mining()) + # Get pyasic miner instance + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + return False + + success = asyncio.run(self._miner.resume_mining()) if self.logger: self.logger.debug(f"Start command sent. Success: {success}") - return success + return success or False From 1e079f75d7b479d89fbc24e0896e675cc9138b01 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 07:22:36 -0600 Subject: [PATCH 18/43] feat: add PyASIC miner controller to adapter service for enhanced mining capabilities --- .../application/services/adapter_service.py | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index 1ed7263..e63e075 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -4,19 +4,30 @@ from typing import Dict, List, Optional, Union -from edge_mining.adapters.domain.energy.monitors.dummy_solar import DummySolarEnergyMonitorFactory -from edge_mining.adapters.domain.energy.monitors.home_assistant_api import HomeAssistantAPIEnergyMonitorFactory -from edge_mining.adapters.domain.forecast.providers.dummy_solar import DummyForecastProviderFactory -from edge_mining.adapters.domain.forecast.providers.home_assistant_api import HomeAssistantForecastProviderFactory -from edge_mining.adapters.domain.home_load.providers.dummy import DummyHomeForecastProvider +from edge_mining.adapters.domain.energy.dummy_solar import ( + DummySolarEnergyMonitorFactory, +) +from edge_mining.adapters.domain.energy.home_assistant_api import ( + HomeAssistantAPIEnergyMonitorFactory, +) +from edge_mining.adapters.domain.forecast.dummy_solar import ( + DummyForecastProviderFactory, +) +from edge_mining.adapters.domain.forecast.home_assistant_api import ( + HomeAssistantForecastProviderFactory, +) +from edge_mining.adapters.domain.home_load.dummy import DummyHomeForecastProvider from edge_mining.adapters.domain.miner.controllers.dummy import DummyMinerController from edge_mining.adapters.domain.miner.controllers.generic_socket_home_assistant_api import ( GenericSocketHomeAssistantAPIMinerControllerAdapterFactory, ) -from edge_mining.adapters.domain.notification.notifiers.dummy import DummyNotifier -from edge_mining.adapters.domain.notification.notifiers.telegram import TelegramNotifierFactory -from edge_mining.adapters.domain.performance.trackers.dummy import DummyMiningPerformanceTracker -from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ServiceHomeAssistantAPIFactory +from edge_mining.adapters.domain.miner.controllers.pyasic import PyASICMinerControllerAdapterFactory +from edge_mining.adapters.domain.notification.dummy import DummyNotifier +from edge_mining.adapters.domain.notification.telegram import TelegramNotifierFactory +from edge_mining.adapters.domain.performance.dummy import DummyMiningPerformanceTracker +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ( + ServiceHomeAssistantAPIFactory, +) from edge_mining.adapters.infrastructure.rule_engine.common import RuleEngineType from edge_mining.adapters.infrastructure.rule_engine.factory import RuleEngineFactory from edge_mining.application.interfaces import AdapterServiceInterface @@ -26,10 +37,16 @@ from edge_mining.domain.energy.ports import EnergyMonitorPort, EnergyMonitorRepository from edge_mining.domain.forecast.common import ForecastProviderAdapter from edge_mining.domain.forecast.entities import ForecastProvider -from edge_mining.domain.forecast.ports import ForecastProviderPort, ForecastProviderRepository +from edge_mining.domain.forecast.ports import ( + ForecastProviderPort, + ForecastProviderRepository, +) from edge_mining.domain.home_load.common import HomeForecastProviderAdapter from edge_mining.domain.home_load.entities import HomeForecastProvider -from edge_mining.domain.home_load.ports import HomeForecastProviderPort, HomeForecastProviderRepository +from edge_mining.domain.home_load.ports import ( + HomeForecastProviderPort, + HomeForecastProviderRepository, +) from edge_mining.domain.miner.common import MinerControllerAdapter from edge_mining.domain.miner.entities import Miner, MinerController from edge_mining.domain.miner.ports import MinerControllerRepository, MinerControlPort @@ -38,15 +55,22 @@ from edge_mining.domain.notification.ports import NotificationPort, NotifierRepository from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter from edge_mining.domain.performance.entities import MiningPerformanceTracker -from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort, MiningPerformanceTrackerRepository +from edge_mining.domain.performance.ports import ( + MiningPerformanceTrackerPort, + MiningPerformanceTrackerRepository, +) from edge_mining.domain.policy.services import RuleEngine from edge_mining.shared.external_services.common import ExternalServiceAdapter from edge_mining.shared.external_services.entities import ExternalService -from edge_mining.shared.external_services.ports import ExternalServicePort, ExternalServiceRepository +from edge_mining.shared.external_services.ports import ( + ExternalServicePort, + ExternalServiceRepository, +) from edge_mining.shared.interfaces.factories import ( EnergyMonitorAdapterFactory, ExternalServiceFactory, ForecastAdapterFactory, + MinerControllerAdapterFactory, ) from edge_mining.shared.logging.port import LoggerPort @@ -249,7 +273,6 @@ def _initialize_miner_controller_adapter( return cached_instance # Retrieve the external service associated to the miner controller - external_service: Optional[ExternalServicePort] = None if miner_controller.external_service_id: external_service = self.get_external_service(miner_controller.external_service_id) if not external_service: @@ -259,6 +282,7 @@ def _initialize_miner_controller_adapter( ) try: + miner_controller_factory: Optional[MinerControllerAdapterFactory] = None instance: Optional[MinerControlPort] = None if miner_controller.adapter_type == MinerControllerAdapter.DUMMY: @@ -278,6 +302,17 @@ def _initialize_miner_controller_adapter( miner_controller_factory.from_miner(miner) + instance = miner_controller_factory.create( + config=miner_controller.config, + logger=self.logger, + external_service=external_service, + ) + elif miner_controller.adapter_type == MinerControllerAdapter.PYASIC: + # --- PyASIC Controller --- + miner_controller_factory = PyASICMinerControllerAdapterFactory() + + miner_controller_factory.from_miner(miner) + instance = miner_controller_factory.create( config=miner_controller.config, logger=self.logger, @@ -329,7 +364,6 @@ def _initialize_notifier_adapter(self, notifier: Notifier) -> Optional[Notificat return cached_instance # Retrieve the external service associated to the notifier - external_service: Optional[ExternalServicePort] = None if notifier.external_service_id: external_service = self.get_external_service(notifier.external_service_id) if not external_service: From 07fb5d403ffc5a9bb1116aa15aef5638d7c8c6c3 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:28:16 +0200 Subject: [PATCH 19/43] refactor: streamline import statements in adapter_service.py for improved readability --- .../application/services/adapter_service.py | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index e63e075..1625260 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -4,18 +4,10 @@ from typing import Dict, List, Optional, Union -from edge_mining.adapters.domain.energy.dummy_solar import ( - DummySolarEnergyMonitorFactory, -) -from edge_mining.adapters.domain.energy.home_assistant_api import ( - HomeAssistantAPIEnergyMonitorFactory, -) -from edge_mining.adapters.domain.forecast.dummy_solar import ( - DummyForecastProviderFactory, -) -from edge_mining.adapters.domain.forecast.home_assistant_api import ( - HomeAssistantForecastProviderFactory, -) +from edge_mining.adapters.domain.energy.dummy_solar import DummySolarEnergyMonitorFactory +from edge_mining.adapters.domain.energy.home_assistant_api import HomeAssistantAPIEnergyMonitorFactory +from edge_mining.adapters.domain.forecast.dummy_solar import DummyForecastProviderFactory +from edge_mining.adapters.domain.forecast.home_assistant_api import HomeAssistantForecastProviderFactory from edge_mining.adapters.domain.home_load.dummy import DummyHomeForecastProvider from edge_mining.adapters.domain.miner.controllers.dummy import DummyMinerController from edge_mining.adapters.domain.miner.controllers.generic_socket_home_assistant_api import ( @@ -25,9 +17,7 @@ from edge_mining.adapters.domain.notification.dummy import DummyNotifier from edge_mining.adapters.domain.notification.telegram import TelegramNotifierFactory from edge_mining.adapters.domain.performance.dummy import DummyMiningPerformanceTracker -from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ( - ServiceHomeAssistantAPIFactory, -) +from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ServiceHomeAssistantAPIFactory from edge_mining.adapters.infrastructure.rule_engine.common import RuleEngineType from edge_mining.adapters.infrastructure.rule_engine.factory import RuleEngineFactory from edge_mining.application.interfaces import AdapterServiceInterface @@ -37,16 +27,10 @@ from edge_mining.domain.energy.ports import EnergyMonitorPort, EnergyMonitorRepository from edge_mining.domain.forecast.common import ForecastProviderAdapter from edge_mining.domain.forecast.entities import ForecastProvider -from edge_mining.domain.forecast.ports import ( - ForecastProviderPort, - ForecastProviderRepository, -) +from edge_mining.domain.forecast.ports import ForecastProviderPort, ForecastProviderRepository from edge_mining.domain.home_load.common import HomeForecastProviderAdapter from edge_mining.domain.home_load.entities import HomeForecastProvider -from edge_mining.domain.home_load.ports import ( - HomeForecastProviderPort, - HomeForecastProviderRepository, -) +from edge_mining.domain.home_load.ports import HomeForecastProviderPort, HomeForecastProviderRepository from edge_mining.domain.miner.common import MinerControllerAdapter from edge_mining.domain.miner.entities import Miner, MinerController from edge_mining.domain.miner.ports import MinerControllerRepository, MinerControlPort @@ -55,17 +39,11 @@ from edge_mining.domain.notification.ports import NotificationPort, NotifierRepository from edge_mining.domain.performance.common import MiningPerformanceTrackerAdapter from edge_mining.domain.performance.entities import MiningPerformanceTracker -from edge_mining.domain.performance.ports import ( - MiningPerformanceTrackerPort, - MiningPerformanceTrackerRepository, -) +from edge_mining.domain.performance.ports import MiningPerformanceTrackerPort, MiningPerformanceTrackerRepository from edge_mining.domain.policy.services import RuleEngine from edge_mining.shared.external_services.common import ExternalServiceAdapter from edge_mining.shared.external_services.entities import ExternalService -from edge_mining.shared.external_services.ports import ( - ExternalServicePort, - ExternalServiceRepository, -) +from edge_mining.shared.external_services.ports import ExternalServicePort, ExternalServiceRepository from edge_mining.shared.interfaces.factories import ( EnergyMonitorAdapterFactory, ExternalServiceFactory, From b16b8eccfc7bfaef2308fc9ace08f083e8c21a31 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:29:02 +0200 Subject: [PATCH 20/43] fix: update default IP address in MinerControllerPyASICConfig and clean up code formatting --- edge_mining/shared/adapter_configs/miner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/edge_mining/shared/adapter_configs/miner.py b/edge_mining/shared/adapter_configs/miner.py index 1ecaf47..0041bcf 100644 --- a/edge_mining/shared/adapter_configs/miner.py +++ b/edge_mining/shared/adapter_configs/miner.py @@ -2,6 +2,7 @@ Collection of adapters configuration for the miner domain of the Edge Mining application. """ + import ipaddress from dataclasses import asdict, dataclass, field @@ -66,6 +67,7 @@ def from_dict(cls, data: dict): """Create a configuration object from a dictionary""" return cls(**data) + @dataclass(frozen=True) class MinerControllerPyASICConfig(MinerControllerConfig): """ @@ -73,8 +75,7 @@ class MinerControllerPyASICConfig(MinerControllerConfig): to control a miner via pyasic. """ - ip: str = field(default="switch.miner_socket") - + ip: str = field(default="192.168.1.100") def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: """ From 1bc2b38c5064673ffdfac53b4517d003bfd699d9 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:29:30 +0200 Subject: [PATCH 21/43] fix: add PyASIC configuration to miner adapter maps for proper integration --- edge_mining/shared/adapter_maps/miner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/edge_mining/shared/adapter_maps/miner.py b/edge_mining/shared/adapter_maps/miner.py index 140880c..b08d110 100644 --- a/edge_mining/shared/adapter_maps/miner.py +++ b/edge_mining/shared/adapter_maps/miner.py @@ -9,16 +9,19 @@ from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, ) from edge_mining.shared.external_services.common import ExternalServiceAdapter from edge_mining.shared.interfaces.config import MinerControllerConfig MINER_CONTROLLER_CONFIG_TYPE_MAP: Dict[MinerControllerAdapter, Optional[type[MinerControllerConfig]]] = { MinerControllerAdapter.DUMMY: MinerControllerDummyConfig, + MinerControllerAdapter.PYASIC: MinerControllerPyASICConfig, MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API: MinerControllerGenericSocketHomeAssistantAPIConfig, } MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP: Dict[MinerControllerAdapter, Optional[ExternalServiceAdapter]] = { MinerControllerAdapter.DUMMY: None, # Dummy does not use an external service + MinerControllerAdapter.PYASIC: None, # PyASIC does not use an external service MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API: ExternalServiceAdapter.HOME_ASSISTANT_API, } From 1fdd4abcd275533cf464dec10286373c51495cab Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:29:59 +0200 Subject: [PATCH 22/43] feat: add validation schema for MinerControllerPyASICConfig with IP address validation --- edge_mining/adapters/domain/miner/schemas.py | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 1ff63f4..471406e 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -1,5 +1,6 @@ """Validation schemas for miner domain.""" +import ipaddress import uuid from typing import Dict, Optional, Union, cast @@ -12,6 +13,7 @@ from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, ) from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_CONFIG_TYPE_MAP from edge_mining.shared.interfaces.config import MinerControllerConfig @@ -523,10 +525,43 @@ class Config: validate_assignment = True +class MinerControllerPyASICConfigSchema(BaseModel): + """Schema for MinerControllerPyASICConfig.""" + + ip: str = Field(..., description="IP address of the PyASIC miner") + + @field_validator("ip") + @classmethod + def validate_ip(cls, v: str) -> str: + """Validate that the value is a plausible IP address.""" + v = v.strip() + if not v: + raise ValueError("IP address must be a non-empty string") + try: + ipaddress.ip_address(str(v)) + except ValueError as e: + raise ValueError(f"Invalid IP address: {v}") from e + return v + + def to_model(self) -> MinerControllerPyASICConfig: + """ + Convert schema to MinerControllerPyASICConfig adapter configuration model instance. + """ + + return MinerControllerPyASICConfig(ip=self.ip) + + class Config: + """Pydantic configuration.""" + + use_enum_values = True + validate_assignment = True + + MINER_CONTROLLER_CONFIG_SCHEMA_MAP: Dict[ type[MinerControllerConfig], Union[type[MinerControllerDummyConfigSchema], type[MinerControllerGenericSocketHomeAssistantAPIConfigSchema]], ] = { MinerControllerDummyConfig: MinerControllerDummyConfigSchema, MinerControllerGenericSocketHomeAssistantAPIConfig: MinerControllerGenericSocketHomeAssistantAPIConfigSchema, + MinerControllerPyASICConfig: MinerControllerPyASICConfigSchema, } From ed8e3c9e36ac81f32e729ead4083c727cb96772e Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:30:46 +0200 Subject: [PATCH 23/43] feat: add handler for PyASIC Miner Controller configuration in CLI menu --- .../adapters/domain/miner/cli/commands.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/edge_mining/adapters/domain/miner/cli/commands.py b/edge_mining/adapters/domain/miner/cli/commands.py index d6107ca..26274f2 100644 --- a/edge_mining/adapters/domain/miner/cli/commands.py +++ b/edge_mining/adapters/domain/miner/cli/commands.py @@ -18,6 +18,7 @@ from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, + MinerControllerPyASICConfig, ) from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_TYPE_EXTERNAL_SERVICE_MAP from edge_mining.shared.external_services.entities import ExternalService @@ -540,6 +541,20 @@ def handle_miner_controller_generic_socket_home_assistant_api_config(miner: Opti ) +def handle_miner_controller_pyasic_config(miner: Optional[Miner]) -> MinerControllerConfig: + """Handle configuration for the PyASIC Miner Controller.""" + click.echo(click.style("\n--- PyASIC Miner Controller Configuration ---", fg="yellow")) + + ip: str = click.prompt( + "IP address of the PyASIC miner (eg. 192.168.1.100)", + type=str, + default="192.168.1.100", + ) + return MinerControllerPyASICConfig( + ip=ip, + ) + + def handle_miner_controller_configuration( adapter_type: MinerControllerAdapter, miner: Optional[Miner] ) -> Optional[MinerControllerConfig]: @@ -549,6 +564,8 @@ def handle_miner_controller_configuration( config = handle_miner_controller_dummy_config(miner) elif adapter_type.value == MinerControllerAdapter.GENERIC_SOCKET_HOME_ASSISTANT_API.value: config = handle_miner_controller_generic_socket_home_assistant_api_config(miner) + elif adapter_type.value == MinerControllerAdapter.PYASIC.value: + config = handle_miner_controller_pyasic_config(miner) else: click.echo(click.style("Unsupported controller type selected. Aborting.", fg="red")) return config From 6df10efeb68eb93d07a11d48e3231843da3e21e4 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:31:59 +0200 Subject: [PATCH 24/43] fix: update pydantic and homeassistant_api versions in dependencies to be compatible with pyasic --- pyproject.toml | 6 +++--- requirements.txt | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f9584c3..8f9afaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ keywords = [ ] requires-python = ">=3.11" dependencies = [ - "pydantic>=2.8.2", + "pydantic>=2.11.0", "pyyaml>=6.0.2", "pydantic-settings>=2.8.1", "apscheduler>=3.11.0", @@ -51,7 +51,7 @@ api = [ "uvicorn[standard]>=0.34.1", ] homeassistant = [ - "homeassistant_api>=5.0.0", + "homeassistant_api==4.2.2.post1", ] mqtt = [ "paho-mqtt>=2.1.0", @@ -66,7 +66,7 @@ pyasic = [ "pyasic==0.76.5" ] all = [ - "edge-mining[api,homeassistant,mqtt,telegram,solar]", + "edge-mining[api,homeassistant,mqtt,telegram,solar,pyasic]", ] dev = [ "pytest>=6.0", diff --git a/requirements.txt b/requirements.txt index 7fbebf4..5430c64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Core Dependencies -pydantic==2.8.2 +pydantic==2.11.0 pyyaml==6.0.2 pydantic-settings==2.8.1 apscheduler==3.11.0 @@ -12,6 +12,7 @@ uvicorn[standard]==0.34.1 # Optional - For specific Driven Adapters paho-mqtt==2.1.0 -homeassistant_api==5.0.0 +homeassistant_api==4.2.2.post1 python-telegram-bot>=20.0 -astral==3.2 \ No newline at end of file +astral==3.2 +pyasic==0.76.5 From 0181a67105463f2e0e815892f053c94e219b996f Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 3 Sep 2025 15:32:26 +0200 Subject: [PATCH 25/43] fix: add "pyasic" to cSpell words in settings.json for improved spell checking --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 96fee94..8269707 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,6 +54,7 @@ "sheduler", "satoshi", "hashrate", - "homeassistant" + "homeassistant", + "pyasic" ] } From e82419d5f811877ccdd6c2f6901921bb2cbae95e Mon Sep 17 00:00:00 2001 From: Brett Rowan <121075405+b-rowan@users.noreply.github.com> Date: Mon, 22 Sep 2025 08:34:01 -0600 Subject: [PATCH 26/43] feature: add password to pyasic miner adaptor --- .../adapters/domain/miner/controllers/pyasic.py | 10 +++++++++- edge_mining/shared/adapter_configs/miner.py | 1 + pyproject.toml | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 61c97f1..4a48c92 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -52,6 +52,7 @@ def create( return PyASICMinerController( ip=miner_controller_configuration.ip, + password=miner_controller_configuration.password, logger=logger, ) @@ -62,11 +63,13 @@ class PyASICMinerController(MinerControlPort): def __init__( self, ip: str, + password: str | None = None, logger: Optional[LoggerPort] = None, ): self.logger = logger self.ip = ip + self.password = password self._miner: Optional[AnyMiner] = None @@ -82,7 +85,12 @@ def _log_configuration(self): def _get_miner(self) -> None: """Retrieve the pyasic miner instance.""" if self._miner is None: - self._miner = asyncio.run(pyasic.get_miner(self.ip)) + self._miner: AnyMiner = asyncio.run(pyasic.get_miner(self.ip)) + if self._miner is not None and self.password is not None: + if self._miner.rpc is not None: + self._miner.rpc.pwd = self.password + if self._miner.web is not None: + self._miner.web.pwd = self.password def get_miner_hashrate(self) -> Optional[HashRate]: """ diff --git a/edge_mining/shared/adapter_configs/miner.py b/edge_mining/shared/adapter_configs/miner.py index 0041bcf..4e3b7cb 100644 --- a/edge_mining/shared/adapter_configs/miner.py +++ b/edge_mining/shared/adapter_configs/miner.py @@ -76,6 +76,7 @@ class MinerControllerPyASICConfig(MinerControllerConfig): """ ip: str = field(default="192.168.1.100") + password: str | None = None # None represents "use the default" def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: """ diff --git a/pyproject.toml b/pyproject.toml index 8f9afaf..3443e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ keywords = [ "hexagonal-architecture", "domain-driven-design", ] -requires-python = ">=3.11" +requires-python = ">=3.11,<4.0" dependencies = [ "pydantic>=2.11.0", "pyyaml>=6.0.2", From 3e59e88cbabbc772d53ca78a900c8eff26294454 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Thu, 23 Oct 2025 14:54:07 +0200 Subject: [PATCH 27/43] fix: enhance PyASIC miner controller CLI configuration to use existing defaults --- .../adapters/domain/miner/cli/commands.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/edge_mining/adapters/domain/miner/cli/commands.py b/edge_mining/adapters/domain/miner/cli/commands.py index 77cd16f..ef7455a 100644 --- a/edge_mining/adapters/domain/miner/cli/commands.py +++ b/edge_mining/adapters/domain/miner/cli/commands.py @@ -574,14 +574,31 @@ def handle_miner_controller_pyasic_config( """Handle configuration for the PyASIC Miner Controller.""" click.echo(click.style("\n--- PyASIC Miner Controller Configuration ---", fg="yellow")) + # Default values from hardcoded values + default_ip = "192.168.1.100" + default_password = Optional[str] = None # None represents "use the default miner password" + + # Try to get defaults from current_config + if current_config and current_config.is_valid(MinerControllerAdapter.PYASIC): + config: MinerControllerPyASICConfig = cast(MinerControllerPyASICConfig, current_config) + default_ip = config.ip + default_password = config.password + ip: str = click.prompt( "IP address of the PyASIC miner (eg. 192.168.1.100)", type=str, - default="192.168.1.100", + default=default_ip, ) - return MinerControllerPyASICConfig( - ip=ip, + + password: str = click.prompt( + "Password of the PyASIC miner (empty represents 'use the default miner password')", + type=str, + default=default_password, ) + if password == "": + password = None + + return MinerControllerPyASICConfig(ip=ip, password=password) def handle_miner_controller_configuration( From 0198706557bb36436f11bdec98aa2778881c8f59 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Thu, 23 Oct 2025 15:10:33 +0200 Subject: [PATCH 28/43] feat: add password field to MinerControllerPyASICConfigSchema with validation --- edge_mining/adapters/domain/miner/schemas.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 471406e..e77b91a 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -529,6 +529,9 @@ class MinerControllerPyASICConfigSchema(BaseModel): """Schema for MinerControllerPyASICConfig.""" ip: str = Field(..., description="IP address of the PyASIC miner") + password: str = Field( + ..., description="Password of the PyASIC miner (empty represents 'use the default miner password')" + ) @field_validator("ip") @classmethod @@ -543,6 +546,15 @@ def validate_ip(cls, v: str) -> str: raise ValueError(f"Invalid IP address: {v}") from e return v + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + """Validate that the value is a plausible password.""" + v = v.strip() + if not v: + v = None + return v + def to_model(self) -> MinerControllerPyASICConfig: """ Convert schema to MinerControllerPyASICConfig adapter configuration model instance. From c8805312d67afbc8e137fd3a94743853ddb4ffa4 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Thu, 23 Oct 2025 18:41:48 +0200 Subject: [PATCH 29/43] fix: adapters path for import statements in adapter_service --- .../application/services/adapter_service.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index 95e129b..8f9ffd2 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -4,19 +4,19 @@ from typing import Dict, List, Optional, Union -from edge_mining.adapters.domain.energy.dummy_solar import DummySolarEnergyMonitorFactory -from edge_mining.adapters.domain.energy.home_assistant_api import HomeAssistantAPIEnergyMonitorFactory -from edge_mining.adapters.domain.forecast.dummy_solar import DummyForecastProviderFactory -from edge_mining.adapters.domain.forecast.home_assistant_api import HomeAssistantForecastProviderFactory -from edge_mining.adapters.domain.home_load.dummy import DummyHomeForecastProvider +from edge_mining.adapters.domain.energy.monitors.dummy_solar import DummySolarEnergyMonitorFactory +from edge_mining.adapters.domain.energy.monitors.home_assistant_api import HomeAssistantAPIEnergyMonitorFactory +from edge_mining.adapters.domain.forecast.providers.dummy_solar import DummyForecastProviderFactory +from edge_mining.adapters.domain.forecast.providers.home_assistant_api import HomeAssistantForecastProviderFactory +from edge_mining.adapters.domain.home_load.providers.dummy import DummyHomeForecastProvider from edge_mining.adapters.domain.miner.controllers.dummy import DummyMinerController from edge_mining.adapters.domain.miner.controllers.generic_socket_home_assistant_api import ( GenericSocketHomeAssistantAPIMinerControllerAdapterFactory, ) from edge_mining.adapters.domain.miner.controllers.pyasic import PyASICMinerControllerAdapterFactory -from edge_mining.adapters.domain.notification.dummy import DummyNotifier -from edge_mining.adapters.domain.notification.telegram import TelegramNotifierFactory -from edge_mining.adapters.domain.performance.dummy import DummyMiningPerformanceTracker +from edge_mining.adapters.domain.notification.notifiers.dummy import DummyNotifier +from edge_mining.adapters.domain.notification.notifiers.telegram import TelegramNotifierFactory +from edge_mining.adapters.domain.performance.trackers.dummy import DummyMiningPerformanceTracker from edge_mining.adapters.infrastructure.homeassistant.homeassistant_api import ServiceHomeAssistantAPIFactory from edge_mining.adapters.infrastructure.rule_engine.common import RuleEngineType from edge_mining.adapters.infrastructure.rule_engine.factory import RuleEngineFactory From 0c062fe47012adb2355f822073441e94351878d6 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Thu, 23 Oct 2025 19:15:18 +0200 Subject: [PATCH 30/43] fix: Attribute "_miner" already defined --- edge_mining/adapters/domain/miner/controllers/pyasic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 4a48c92..170345d 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -85,7 +85,7 @@ def _log_configuration(self): def _get_miner(self) -> None: """Retrieve the pyasic miner instance.""" if self._miner is None: - self._miner: AnyMiner = asyncio.run(pyasic.get_miner(self.ip)) + self._miner = asyncio.run(pyasic.get_miner(self.ip)) if self._miner is not None and self.password is not None: if self._miner.rpc is not None: self._miner.rpc.pwd = self.password From 94417ea95819216f878ece2756c3fb6e70f4c7c4 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:01:01 +0100 Subject: [PATCH 31/43] feat: enhance MinerControllerPyASICConfig with additional parameters and protocol --- edge_mining/domain/miner/common.py | 8 ++++++ edge_mining/shared/adapter_configs/miner.py | 31 ++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 78c2f3b..91892cf 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -22,3 +22,11 @@ class MinerControllerAdapter(AdapterType): DUMMY = "dummy" GENERIC_SOCKET_HOME_ASSISTANT_API = "generic_socket_home_assistant_api" PYASIC = "pyasic" + + +class MinerControllerProtocol(Enum): + """Types of miner controller protocols.""" + + WEB = "web" + RPC = "rpc" + SSH = "ssh" diff --git a/edge_mining/shared/adapter_configs/miner.py b/edge_mining/shared/adapter_configs/miner.py index 76c65b1..2a8df2b 100644 --- a/edge_mining/shared/adapter_configs/miner.py +++ b/edge_mining/shared/adapter_configs/miner.py @@ -3,10 +3,12 @@ of the Edge Mining application. """ +from enum import Enum import ipaddress from dataclasses import asdict, dataclass, field +from typing import Optional -from edge_mining.domain.miner.common import MinerControllerAdapter +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerControllerProtocol from edge_mining.domain.miner.value_objects import HashRate from edge_mining.shared.interfaces.config import MinerControllerConfig @@ -87,7 +89,10 @@ class MinerControllerPyASICConfig(MinerControllerConfig): """ ip: str = field(default="192.168.1.100") - password: str | None = None # None represents "use the default" + port: Optional[int] = field(default=None) # None represents "use the default" + username: Optional[str] = field(default=None) # None represents "use the default" + password: Optional[str] = field(default=None) # None represents "use the default" + protocol: MinerControllerProtocol = field(default=MinerControllerProtocol.WEB) def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: """ @@ -103,9 +108,27 @@ def is_valid(self, adapter_type: MinerControllerAdapter) -> bool: def to_dict(self) -> dict: """Converts the configuration object into a serializable dictionary""" - return {**asdict(self)} + result = asdict(self) + + # Convert all enum values to their string representation + for key, value in result.items(): + if isinstance(value, Enum): + result[key] = value.value + + return result @classmethod def from_dict(cls, data: dict): """Create a configuration object from a dictionary""" - return cls(**data) + protocol = MinerControllerProtocol.WEB + if "protocol" in data: + protocol_value = data.get("protocol") + protocol = MinerControllerProtocol(protocol_value) + + return MinerControllerPyASICConfig( + ip=data.get("ip", "192.168.1.100"), + port=data.get("port", None), + username=data.get("username", None), + password=data.get("password", None), + protocol=protocol, + ) From 882e6ff0ec74294d932d25031782125615314786 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:01:17 +0100 Subject: [PATCH 32/43] fix: initialize external_service variable in AdapterService --- edge_mining/application/services/adapter_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index 8f9ffd2..c194e42 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -251,6 +251,7 @@ def _initialize_miner_controller_adapter( return cached_instance # Retrieve the external service associated to the miner controller + external_service: Optional[ExternalServicePort] = None if miner_controller.external_service_id: external_service = self.get_external_service(miner_controller.external_service_id) if not external_service: From 880ad20174229ce89644db7b82fcc67f989b96ca Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:03:22 +0100 Subject: [PATCH 33/43] feat: add protocol, port, and username parameters to PyASICMinerController initialization --- .../adapters/domain/miner/controllers/pyasic.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 4605bac..1e4862c 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -11,7 +11,7 @@ from edge_mining.adapters.utils import run_async_func from edge_mining.domain.common import Watts -from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.miner.common import MinerControllerProtocol, MinerStatus from edge_mining.domain.miner.entities import Miner from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError from edge_mining.domain.miner.ports import MinerControlPort @@ -52,6 +52,9 @@ def create( return PyASICMinerController( ip=miner_controller_configuration.ip, + protocol=miner_controller_configuration.protocol, + port=miner_controller_configuration.port, + username=miner_controller_configuration.username, password=miner_controller_configuration.password, logger=logger, ) @@ -63,13 +66,19 @@ class PyASICMinerController(MinerControlPort): def __init__( self, ip: str, - password: str | None = None, + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + protocol: Optional[MinerControllerProtocol] = None, logger: Optional[LoggerPort] = None, ): self.logger = logger self.ip = ip self.password = password + self.port = port + self.username = username + self.protocol = protocol self._miner: Optional[AnyMiner] = None From 821f7d4151110e6ba1ee92fd8f3f44777acfc46b Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:04:40 +0100 Subject: [PATCH 34/43] feat: enhance _get_miner method to set additional parameters for RPC and WEB protocols --- .../domain/miner/controllers/pyasic.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 1e4862c..dd2cde9 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -8,6 +8,8 @@ import pyasic from pyasic import AnyMiner from pyasic.device.algorithm.hashrate import AlgoHashRate +from pyasic.rpc.cgminer import BaseMinerRPCAPI +from pyasic.web.base import BaseWebAPI from edge_mining.adapters.utils import run_async_func from edge_mining.domain.common import Watts @@ -90,15 +92,32 @@ def _log_configuration(self): def _get_miner(self) -> None: """Retrieve the pyasic miner instance.""" - if self._miner is not None and self.password is not None: - if self._miner.rpc is not None: - self._miner.rpc.pwd = self.password - if self._miner.web is not None: - self._miner.web.pwd = self.password + if self._miner is None: try: miner = run_async_func(lambda: pyasic.get_miner(self.ip)) if miner is not None: self._miner = cast(AnyMiner, miner) + + # Set additional parameters like protocol, password,port + if self.protocol == MinerControllerProtocol.RPC: + if isinstance(self._miner.rpc, BaseMinerRPCAPI): + if self.port: + self._miner.rpc.port = self.port + if self.password: + self._miner.rpc.pwd = self.password + else: + self.logger.error("Unknown RPC Protocol") + elif self.protocol == MinerControllerProtocol.WEB: + if isinstance(self._miner.web, BaseWebAPI): + if self.port: + self._miner.web.port = self.port + if self.password: + self._miner.web.pwd = self.password + if self.username: + self._miner.web.username = self.username + else: + self.logger.error("Unknown Web Protocol") + if self.logger: self.logger.debug(f"Successfully retrieved miner instance from {self.ip}") except Exception as e: From 59084be61958667f8a8995525e1ac82091c38b00 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:05:31 +0100 Subject: [PATCH 35/43] fix: change debug logs to error logs for miner instance retrieval failures --- .../adapters/domain/miner/controllers/pyasic.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index dd2cde9..2a3a958 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -137,7 +137,7 @@ def get_miner_hashrate(self) -> Optional[HashRate]: if not self._miner: if self.logger: - self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return None miner = self._miner @@ -163,7 +163,7 @@ def get_miner_power(self) -> Optional[Watts]: if not self._miner: if self.logger: - self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return None miner = self._miner @@ -189,7 +189,7 @@ def get_miner_status(self) -> MinerStatus: if not self._miner: if self.logger: - self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return MinerStatus.UNKNOWN miner = self._miner @@ -218,7 +218,7 @@ def stop_miner(self) -> bool: if not self._miner: if self.logger: - self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return False miner = self._miner @@ -239,7 +239,7 @@ def start_miner(self) -> bool: if not self._miner: if self.logger: - self.logger.debug(f"Failed to retrieve miner instance from {self.ip}...") + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return False miner = self._miner From cb5032892456bc5d686d78342dcad92a9b7e81f0 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:07:18 +0100 Subject: [PATCH 36/43] refactor: simplify run_async_func parameter type and usage in pyasic controller --- .../adapters/domain/miner/controllers/pyasic.py | 12 ++++++------ edge_mining/adapters/utils.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 2a3a958..8bef21b 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -94,7 +94,7 @@ def _get_miner(self) -> None: """Retrieve the pyasic miner instance.""" if self._miner is None: try: - miner = run_async_func(lambda: pyasic.get_miner(self.ip)) + miner = run_async_func(pyasic.get_miner(self.ip)) if miner is not None: self._miner = cast(AnyMiner, miner) @@ -141,7 +141,7 @@ def get_miner_hashrate(self) -> Optional[HashRate]: return None miner = self._miner - hashrate: Optional[AlgoHashRate] = run_async_func(lambda: miner.get_hashrate()) + hashrate: Optional[AlgoHashRate] = run_async_func(miner.get_hashrate()) if hashrate is None: if self.logger: self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") @@ -167,7 +167,7 @@ def get_miner_power(self) -> Optional[Watts]: return None miner = self._miner - wattage = run_async_func(lambda: miner.get_wattage()) + wattage = run_async_func(miner.get_wattage()) if wattage is None: if self.logger: self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") @@ -193,7 +193,7 @@ def get_miner_status(self) -> MinerStatus: return MinerStatus.UNKNOWN miner = self._miner - mining_state = run_async_func(lambda: miner.is_mining()) + mining_state = run_async_func(miner.is_mining()) state_map: Dict[Optional[bool], MinerStatus] = { True: MinerStatus.ON, @@ -222,7 +222,7 @@ def stop_miner(self) -> bool: return False miner = self._miner - success = run_async_func(lambda: miner.stop_mining()) + success = run_async_func(miner.stop_mining()) if self.logger: self.logger.debug(f"Stop command sent. Success: {success}") @@ -243,7 +243,7 @@ def start_miner(self) -> bool: return False miner = self._miner - success = run_async_func(lambda: miner.resume_mining()) + success = run_async_func(miner.resume_mining()) if self.logger: self.logger.debug(f"Start command sent. Success: {success}") diff --git a/edge_mining/adapters/utils.py b/edge_mining/adapters/utils.py index ced8239..88b3cde 100644 --- a/edge_mining/adapters/utils.py +++ b/edge_mining/adapters/utils.py @@ -2,12 +2,12 @@ import asyncio from concurrent.futures import ThreadPoolExecutor -from typing import Any, Callable, Coroutine, TypeVar +from typing import Any, Coroutine, TypeVar T = TypeVar("T") -def run_async_func(func: Callable[[], Coroutine[Any, Any, T]]) -> T: +def run_async_func(func: Coroutine[Any, Any, T]) -> T: """ Executes an asynchronous function (coroutine) from a synchronous context, handling the presence of an already running event loop. @@ -17,7 +17,7 @@ def run_async_func(func: Callable[[], Coroutine[Any, Any, T]]) -> T: the coroutine is executed in a separate thread to avoid conflicts with the main event loop. Args: - func: A zero-argument function that returns a coroutine (e.g., lambda: my_async_func()). + func: A coroutine function (e.g., my_async_func()). Returns: The result returned by the coroutine. @@ -29,7 +29,7 @@ def run_async_func(func: Callable[[], Coroutine[Any, Any, T]]) -> T: try: asyncio.get_running_loop() # Triggers RuntimeError if no running event loop with ThreadPoolExecutor(1) as pool: - return pool.submit(lambda: asyncio.run(func())).result() + return pool.submit(lambda: asyncio.run(func)).result() except RuntimeError: - return asyncio.run(func()) + return asyncio.run(func) From 588666f7871d38f68063f0e9f17b1c1f5e2ea667 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:08:49 +0100 Subject: [PATCH 37/43] fix: improve from_model method in MinerSchema for hash_rate and hash_rate_max handling --- edge_mining/adapters/domain/miner/schemas.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 0aa69ee..c905eca 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -101,16 +101,20 @@ def validate_power_max(cls, v: Optional[float]) -> Optional[float]: @classmethod def from_model(cls, miner: Miner) -> "MinerSchema": """Create MinerSchema from a Miner domain model instance.""" + hash_rate: Optional[HashRateSchema] = None + if miner.hash_rate: + hash_rate = HashRateSchema(value=miner.hash_rate.value, unit=miner.hash_rate.unit) + + hash_rate_max: Optional[HashRateSchema] = None + if miner.hash_rate_max: + hash_rate_max = HashRateSchema(value=miner.hash_rate_max.value, unit=miner.hash_rate_max.unit) + return cls( id=str(miner.id), name=miner.name, status=miner.status, - hash_rate=HashRateSchema(value=miner.hash_rate.value, unit=miner.hash_rate.unit) - if miner.hash_rate - else None, - hash_rate_max=HashRateSchema(value=miner.hash_rate_max.value, unit=miner.hash_rate_max.unit) - if miner.hash_rate_max - else None, + hash_rate=hash_rate, + hash_rate_max=hash_rate_max, power_consumption=miner.power_consumption, power_consumption_max=miner.power_consumption_max, active=miner.active, From 967cf521bb99fd9938b78ab2193d1860824469bc Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:16:03 +0100 Subject: [PATCH 38/43] feat: add support for additional parameters in CLI configuration of PyASIC miner controller --- .../adapters/domain/miner/cli/commands.py | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/edge_mining/adapters/domain/miner/cli/commands.py b/edge_mining/adapters/domain/miner/cli/commands.py index 4721b79..66e822c 100644 --- a/edge_mining/adapters/domain/miner/cli/commands.py +++ b/edge_mining/adapters/domain/miner/cli/commands.py @@ -12,7 +12,7 @@ ) from edge_mining.application.interfaces import ConfigurationServiceInterface from edge_mining.domain.common import EntityId, Watts -from edge_mining.domain.miner.common import MinerControllerAdapter, MinerStatus +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerControllerProtocol, MinerStatus from edge_mining.domain.miner.entities import Miner, MinerController from edge_mining.domain.miner.value_objects import HashRate from edge_mining.shared.adapter_configs.miner import ( @@ -576,13 +576,19 @@ def handle_miner_controller_pyasic_config( # Default values from hardcoded values default_ip = "192.168.1.100" - default_password: Optional[str] = None # None represents "use the default miner password" + default_port: Optional[int] = None + default_username: Optional[str] = None + default_password: Optional[str] = None + default_protocol: MinerControllerProtocol = MinerControllerProtocol.WEB # Try to get defaults from current_config if current_config and current_config.is_valid(MinerControllerAdapter.PYASIC): config: MinerControllerPyASICConfig = cast(MinerControllerPyASICConfig, current_config) - default_ip = config.ip - default_password = config.password + default_ip = config.ip or default_ip + default_port = config.port or default_port + default_username = config.username or default_username + default_password = config.password or default_password + default_protocol = config.protocol or default_protocol ip: str = click.prompt( "IP address of the PyASIC miner (eg. 192.168.1.100)", @@ -590,15 +596,37 @@ def handle_miner_controller_pyasic_config( default=default_ip, ) - password: Optional[str] = click.prompt( + protocol: MinerControllerProtocol = click.prompt( + "Protocol to use to connect to the PyASIC miner", + type=click.Choice([p.value for p in MinerControllerProtocol]), + default=default_protocol.value, + ) + protocol = MinerControllerProtocol(protocol) + + port_input = click.prompt( + "Port of the PyASIC miner (eg. 80, press Enter for default)", + type=str, + default="", + ) + port: Optional[int] = None if port_input == "" else int(port_input) + + username_input = click.prompt( + "Username of the PyASIC miner (eg. root, press Enter for default)", + type=str, + default="", + ) + username: Optional[str] = username_input if username_input != "" else default_username + + password_input = click.prompt( "Password of the PyASIC miner (empty represents 'use the default miner password')", type=str, - default=default_password, + default="", ) + password: Optional[str] = password_input if password_input != "" else default_password if password == "": password = None - return MinerControllerPyASICConfig(ip=ip, password=password) + return MinerControllerPyASICConfig(ip=ip, port=port, username=username, password=password, protocol=protocol) def handle_miner_controller_configuration( From d1ed6f9f95f40f9d1e410d6a354a6f40cc33b029 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Fri, 31 Oct 2025 18:24:52 +0100 Subject: [PATCH 39/43] fix: add logger check before logging unknown protocol errors in PyASICMinerController --- edge_mining/adapters/domain/miner/controllers/pyasic.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 8bef21b..cf628f6 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -106,7 +106,8 @@ def _get_miner(self) -> None: if self.password: self._miner.rpc.pwd = self.password else: - self.logger.error("Unknown RPC Protocol") + if self.logger: + self.logger.error("Unknown RPC Protocol") elif self.protocol == MinerControllerProtocol.WEB: if isinstance(self._miner.web, BaseWebAPI): if self.port: @@ -116,7 +117,8 @@ def _get_miner(self) -> None: if self.username: self._miner.web.username = self.username else: - self.logger.error("Unknown Web Protocol") + if self.logger: + self.logger.error("Unknown Web Protocol") if self.logger: self.logger.debug(f"Successfully retrieved miner instance from {self.ip}") From 3e795afe2ab519edc63b5d437e8df9bd0e2f8f91 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Tue, 4 Nov 2025 15:41:33 +0100 Subject: [PATCH 40/43] fix: updated MinerControllerPyASICConfigSchema with additional fields and validations --- edge_mining/adapters/domain/miner/schemas.py | 50 ++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index c905eca..e4eb661 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, field_serializer, field_validator from edge_mining.domain.common import EntityId, Watts -from edge_mining.domain.miner.common import MinerControllerAdapter, MinerStatus +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerControllerProtocol, MinerStatus from edge_mining.domain.miner.entities import Miner, MinerController from edge_mining.domain.miner.value_objects import HashRate from edge_mining.shared.adapter_configs.miner import ( @@ -533,8 +533,17 @@ class MinerControllerPyASICConfigSchema(BaseModel): """Schema for MinerControllerPyASICConfig.""" ip: str = Field(..., description="IP address of the PyASIC miner") - password: str = Field( - ..., description="Password of the PyASIC miner (empty represents 'use the default miner password')" + port: Optional[int] = Field( + None, description="Port of the PyASIC miner (empty represents 'use the default miner port')" + ) + username: Optional[str] = Field( + None, description="Username of the PyASIC miner (empty represents 'use the default miner username')" + ) + password: Optional[str] = Field( + None, description="Password of the PyASIC miner (empty represents 'use the default miner password')" + ) + protocol: MinerControllerProtocol = Field( + default=MinerControllerProtocol.WEB, description="Protocol to use for connecting to the miner" ) @field_validator("ip") @@ -550,6 +559,24 @@ def validate_ip(cls, v: str) -> str: raise ValueError(f"Invalid IP address: {v}") from e return v + @field_validator("port") + @classmethod + def validate_port(cls, v: Optional[int]) -> Optional[int]: + """Validate that the value is a plausible port number.""" + if v is not None: + if not (0 < v < 65536): + raise ValueError("Port must be between 1 and 65535") + return v + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + """Validate that the value is a plausible username.""" + v = v.strip() + if not v: + v = None + return v + @field_validator("password") @classmethod def validate_password(cls, v: str) -> str: @@ -559,12 +586,27 @@ def validate_password(cls, v: str) -> str: v = None return v + @field_validator("protocol") + @classmethod + def validate_protocol(cls, v: str) -> MinerControllerProtocol: + """Validate that protocol is a recognized MinerControllerProtocol.""" + protocol_values = [protocol.value for protocol in MinerControllerProtocol] + if v not in protocol_values: + raise ValueError(f"protocol must be one of {protocol_values}") + return MinerControllerProtocol(v) + def to_model(self) -> MinerControllerPyASICConfig: """ Convert schema to MinerControllerPyASICConfig adapter configuration model instance. """ - return MinerControllerPyASICConfig(ip=self.ip) + return MinerControllerPyASICConfig( + ip=self.ip, + port=self.port, + username=self.username, + password=self.password, + protocol=self.protocol, + ) class Config: """Pydantic configuration.""" From 75b6fe1e7022e4d0f7e8f4fd3367c2dc76c76135 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Wed, 5 Nov 2025 15:35:53 +0100 Subject: [PATCH 41/43] fix: better error logging for PyASIC miner controller --- edge_mining/adapters/domain/miner/controllers/pyasic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index cf628f6..e3c1d05 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -107,7 +107,7 @@ def _get_miner(self) -> None: self._miner.rpc.pwd = self.password else: if self.logger: - self.logger.error("Unknown RPC Protocol") + self.logger.error("Unknown PyASIC Miner Controller RPC Protocol") elif self.protocol == MinerControllerProtocol.WEB: if isinstance(self._miner.web, BaseWebAPI): if self.port: @@ -118,7 +118,7 @@ def _get_miner(self) -> None: self._miner.web.username = self.username else: if self.logger: - self.logger.error("Unknown Web Protocol") + self.logger.error("Unknown PyASIC Miner Controller Web Protocol") if self.logger: self.logger.debug(f"Successfully retrieved miner instance from {self.ip}") From 697cf9b733347bee38829402409c6a116168d32e Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Wed, 5 Nov 2025 15:38:13 +0100 Subject: [PATCH 42/43] fix: enhance protocol handling in PyASICMinerController with SSH support --- .../adapters/domain/miner/controllers/pyasic.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index e3c1d05..3d090db 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -10,6 +10,7 @@ from pyasic.device.algorithm.hashrate import AlgoHashRate from pyasic.rpc.cgminer import BaseMinerRPCAPI from pyasic.web.base import BaseWebAPI +from pyasic.ssh.base import BaseSSH from edge_mining.adapters.utils import run_async_func from edge_mining.domain.common import Watts @@ -119,6 +120,20 @@ def _get_miner(self) -> None: else: if self.logger: self.logger.error("Unknown PyASIC Miner Controller Web Protocol") + elif self.protocol == MinerControllerProtocol.SSH: + if isinstance(self._miner.ssh, BaseSSH): + if self.port: + self._miner.ssh.port = self.port + if self.password: + self._miner.ssh.pwd = self.password + if self.username: + self._miner.ssh.username = self.username + else: + if self.logger: + self.logger.error("Unknown PyASIC Miner Controller SSH Protocol") + else: + if self.logger: + self.logger.error(f"Unknown PyASIC Miner Controller Protocol: {self.protocol}") if self.logger: self.logger.debug(f"Successfully retrieved miner instance from {self.ip}") From b624041b90f5c96539b2053eb4403c676779bdde Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Wed, 5 Nov 2025 18:19:12 +0100 Subject: [PATCH 43/43] fix: update import statement for BaseMinerRPCAPI in PyASICMinerController --- edge_mining/adapters/domain/miner/controllers/pyasic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 3d090db..6dad4a8 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -8,7 +8,7 @@ import pyasic from pyasic import AnyMiner from pyasic.device.algorithm.hashrate import AlgoHashRate -from pyasic.rpc.cgminer import BaseMinerRPCAPI +from pyasic.rpc.base import BaseMinerRPCAPI from pyasic.web.base import BaseWebAPI from pyasic.ssh.base import BaseSSH