From f9dbf2a0c54e84f93bb5ffa04a470bb83a87de2e Mon Sep 17 00:00:00 2001 From: markoceri Date: Tue, 7 Apr 2026 18:48:14 +0200 Subject: [PATCH 01/33] style: improve string formatting in logging messages across multiple files --- .../energy/monitors/home_assistant_api.py | 6 ++---- .../event_bus/in_memory_event_bus.py | 6 +++--- .../homeassistant/homeassistant_api.py | 8 ++------ .../test_sqlalchemy_home_load_repositories.py | 4 +++- .../services/test_configuration_event_flow.py | 19 ++++++++++++++----- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/edge_mining/adapters/domain/energy/monitors/home_assistant_api.py b/edge_mining/adapters/domain/energy/monitors/home_assistant_api.py index 72e32ee..1a892ca 100644 --- a/edge_mining/adapters/domain/energy/monitors/home_assistant_api.py +++ b/edge_mining/adapters/domain/energy/monitors/home_assistant_api.py @@ -375,8 +375,7 @@ def get_current_energy_state(self) -> Optional[EnergyStateSnapshot]: if self.entity_grid: if self.logger: self.logger.warning( - f"Could not retrieve grid value (Entity: {self.entity_grid}). " - "Continuing without grid data." + f"Could not retrieve grid value (Entity: {self.entity_grid}). Continuing without grid data." ) # Battery: We want positive for CHARGING, negative for DISCHARGING @@ -396,8 +395,7 @@ def get_current_energy_state(self) -> Optional[EnergyStateSnapshot]: if production_watts is None and self.entity_production: if self.logger: self.logger.warning( - f"Could not retrieve production value (Entity: {self.entity_production}). " - "Defaulting to 0W." + f"Could not retrieve production value (Entity: {self.entity_production}). Defaulting to 0W." ) if consumption_watts is None and self.entity_consumption: if self.logger: diff --git a/edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py b/edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py index 18982f8..5223690 100644 --- a/edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py +++ b/edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py @@ -26,7 +26,7 @@ def subscribe( ) -> None: self._handlers[event_type].append((handler, blocking)) self._logger.debug( - f"EventBus: subscribed {handler.__qualname__} to " f"{event_type.__name__} (blocking={blocking})" + f"EventBus: subscribed {handler.__qualname__} to {event_type.__name__} (blocking={blocking})" ) async def publish(self, event: DomainEvent) -> None: @@ -36,7 +36,7 @@ async def publish(self, event: DomainEvent) -> None: return self._logger.debug( - f"EventBus: publishing {event.event_type} " f"(id={event.event_id[:8]}..., handlers={len(handlers)})" + f"EventBus: publishing {event.event_type} (id={event.event_id[:8]}..., handlers={len(handlers)})" ) # 1. Blocking handlers — the publisher WAITS, exceptions are propagated @@ -54,5 +54,5 @@ async def _safe_execute(self, handler: Callable, event: DomainEvent) -> None: await handler(event) except Exception as e: self._logger.warning( - f"EventBus: fire-and-forget handler {handler.__qualname__} " f"failed for {event.event_type}: {e}" + f"EventBus: fire-and-forget handler {handler.__qualname__} failed for {event.event_type}: {e}" ) diff --git a/edge_mining/adapters/infrastructure/homeassistant/homeassistant_api.py b/edge_mining/adapters/infrastructure/homeassistant/homeassistant_api.py index eed118d..6731445 100644 --- a/edge_mining/adapters/infrastructure/homeassistant/homeassistant_api.py +++ b/edge_mining/adapters/infrastructure/homeassistant/homeassistant_api.py @@ -157,9 +157,7 @@ def get_entity_state(self, entity_id: Optional[str]) -> Tuple[Optional[str], Opt return None, None except UnauthorizedError: if self.logger: - self.logger.error( - "Home Assistant API authentication failed. " "Please verify your access token is valid." - ) + self.logger.error("Home Assistant API authentication failed. Please verify your access token is valid.") return None, None except RequestTimeoutError: if self.logger: @@ -261,9 +259,7 @@ def set_entity_state(self, entity_id: Optional[str], state: str) -> bool: return False except UnauthorizedError: if self.logger: - self.logger.error( - "Home Assistant API authentication failed. " "Please verify your access token is valid." - ) + self.logger.error("Home Assistant API authentication failed. Please verify your access token is valid.") return False except RequestTimeoutError: if self.logger: diff --git a/tests/integration/adapters/persistence/test_sqlalchemy_home_load_repositories.py b/tests/integration/adapters/persistence/test_sqlalchemy_home_load_repositories.py index a1959b7..e8461c5 100644 --- a/tests/integration/adapters/persistence/test_sqlalchemy_home_load_repositories.py +++ b/tests/integration/adapters/persistence/test_sqlalchemy_home_load_repositories.py @@ -39,7 +39,9 @@ def test_add_and_get_home_forecast_provider_with_enum_adapter( assert retrieved.adapter_type == HomeForecastProviderAdapter.DUMMY assert isinstance(retrieved.config, HomeForecastProviderDummyConfig) - def test_update_home_forecast_provider_with_enum_adapter(self, repository: SqlAlchemyHomeForecastProviderRepository): + def test_update_home_forecast_provider_with_enum_adapter( + self, repository: SqlAlchemyHomeForecastProviderRepository + ): """Regression test: enum adapter_type must remain valid through update commit.""" provider = HomeForecastProvider( name="Original Home Forecast", diff --git a/tests/unit/application/services/test_configuration_event_flow.py b/tests/unit/application/services/test_configuration_event_flow.py index 211d896..710c809 100644 --- a/tests/unit/application/services/test_configuration_event_flow.py +++ b/tests/unit/application/services/test_configuration_event_flow.py @@ -42,11 +42,18 @@ def mock_persistence(): # Each repo mock needs get_by_id, add, update, remove, get_all for repo_name in [ - "external_service_repo", "energy_source_repo", "energy_monitor_repo", - "miner_repo", "miner_controller_repo", "policy_repo", - "optimization_unit_repo", "forecast_provider_repo", - "home_forecast_provider_repo", "mining_performance_tracker_repo", - "notifier_repo", "settings_repo", + "external_service_repo", + "energy_source_repo", + "energy_monitor_repo", + "miner_repo", + "miner_controller_repo", + "policy_repo", + "optimization_unit_repo", + "forecast_provider_repo", + "home_forecast_provider_repo", + "mining_performance_tracker_repo", + "notifier_repo", + "settings_repo", ]: repo = MagicMock() repo.get_all.return_value = [] @@ -67,6 +74,7 @@ def config_service(mock_persistence, mock_event_bus, logger): # --- Test that ConfigurationService publishes events --- + @pytest.mark.asyncio async def test_create_external_service_publishes_event(config_service, mock_event_bus): """Creating an external service should publish a ConfigurationUpdatedEvent.""" @@ -167,6 +175,7 @@ async def test_remove_miner_controller_publishes_event(config_service, mock_even # --- Test end-to-end flow with real InMemoryEventBus --- + @pytest.mark.asyncio async def test_end_to_end_cache_invalidation(mock_persistence, logger): """End-to-end: creating an energy monitor triggers cache invalidation in AdapterService.""" From f8565f8f4aa96cd405409317fab510bc50d19346 Mon Sep 17 00:00:00 2001 From: markoceri Date: Tue, 7 Apr 2026 18:48:27 +0200 Subject: [PATCH 02/33] feat: add FORECAST_PROVIDER to ConfigurationUpdatedEventType enum --- edge_mining/application/events/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/edge_mining/application/events/common.py b/edge_mining/application/events/common.py index c668087..100814e 100644 --- a/edge_mining/application/events/common.py +++ b/edge_mining/application/events/common.py @@ -10,6 +10,7 @@ class ConfigurationUpdatedEventType(Enum): MINER_CONTROLLER = "miner_controller" NOTIFIER = "notifier" EXTERNAL_SERVICE = "external_service" + FORECAST_PROVIDER = "forecast_provider" UNKNOWN = "" From aa406f238374d87a3c86473fcc4a6d470fe5c486 Mon Sep 17 00:00:00 2001 From: markoceri Date: Tue, 7 Apr 2026 23:44:20 +0200 Subject: [PATCH 03/33] feat: implement miner aggregate root and feature management, add monitoring and control ports in domain layer --- edge_mining/domain/miner/aggregate_roots.py | 135 +++++++++++ edge_mining/domain/miner/common.py | 26 +++ edge_mining/domain/miner/entities.py | 31 +-- edge_mining/domain/miner/ports.py | 240 ++++++++++++++++++-- edge_mining/domain/miner/value_objects.py | 57 ++++- edge_mining/domain/policy/value_objects.py | 2 +- 6 files changed, 439 insertions(+), 52 deletions(-) create mode 100644 edge_mining/domain/miner/aggregate_roots.py diff --git a/edge_mining/domain/miner/aggregate_roots.py b/edge_mining/domain/miner/aggregate_roots.py new file mode 100644 index 0000000..fb6bc64 --- /dev/null +++ b/edge_mining/domain/miner/aggregate_roots.py @@ -0,0 +1,135 @@ +"""Collection of Aggregate Roots for the Mining Device Management domain of the Edge Mining application.""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from edge_mining.domain.common import AggregateRoot, EntityId, Watts +from edge_mining.domain.miner.common import MinerFeatureType +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature + + +@dataclass +class Miner(AggregateRoot): + """Aggregate root for a miner. + + Represents the physical mining asset and its intrinsic (static) properties. + Runtime operational state (status, current hash rate, current power consumption) + is captured separately in MinerStateSnapshot. + + Aggregates MinerFeature value objects, each representing a capability + provided by a controller. Multiple controllers can provide features + to the same miner. + """ + + name: str = "" + model: Optional[str] = None + hash_rate_max: Optional[HashRate] = None # Max hash rate for the miner + power_consumption_max: Optional[Watts] = None # Max power consumption for the miner + active: bool = True # Is the miner active in the system? + + features: List[MinerFeature] = field(default_factory=list) + + def activate(self): + """Activate the miner.""" + self.active = True + + def deactivate(self): + """Deactivate the miner.""" + self.active = False + + # --- Feature management (aggregate root invariants) --- + + def add_feature(self, feature: MinerFeature) -> None: + """Add a feature to the miner. + + Raises ValueError if a feature with the same (feature_type, controller_id) already exists. + """ + for existing in self.features: + if existing.feature_type == feature.feature_type and existing.controller_id == feature.controller_id: + raise ValueError( + f"Feature {feature.feature_type.value} from controller {feature.controller_id} already exists." + ) + self.features.append(feature) + + def remove_feature(self, feature_type: MinerFeatureType, controller_id: EntityId) -> None: + """Remove a specific feature by type and controller.""" + self.features = [ + f for f in self.features if not (f.feature_type == feature_type and f.controller_id == controller_id) + ] + + def remove_features_by_controller(self, controller_id: EntityId) -> None: + """Remove all features provided by a specific controller.""" + self.features = [f for f in self.features if f.controller_id != controller_id] + + def get_active_feature(self, feature_type: MinerFeatureType) -> Optional[MinerFeature]: + """Get the highest-priority enabled feature of the given type. + + Returns None if no enabled feature of this type exists. + """ + candidates = [f for f in self.features if f.feature_type == feature_type and f.enabled] + if not candidates: + return None + return max(candidates, key=lambda f: f.priority) + + def get_features_by_controller(self, controller_id: EntityId) -> List[MinerFeature]: + """Get all features provided by a specific controller.""" + return [f for f in self.features if f.controller_id == controller_id] + + def get_features_by_type(self, feature_type: MinerFeatureType) -> List[MinerFeature]: + """Get all features of a specific type (all controllers).""" + return [f for f in self.features if f.feature_type == feature_type] + + def get_controller_ids(self) -> List[EntityId]: + """Get all unique controller IDs associated with this miner.""" + return list({f.controller_id for f in self.features}) + + def has_feature(self, feature_type: MinerFeatureType) -> bool: + """Check if the miner has at least one enabled feature of the given type.""" + return any(f.feature_type == feature_type and f.enabled for f in self.features) + + def enable_feature(self, feature_type: MinerFeatureType, controller_id: EntityId) -> None: + """Enable a specific feature. Replaces the immutable VO with an enabled copy.""" + self.features = [ + MinerFeature( + feature_type=f.feature_type, + controller_id=f.controller_id, + priority=f.priority, + enabled=True, + ) + if f.feature_type == feature_type and f.controller_id == controller_id + else f + for f in self.features + ] + + def disable_feature(self, feature_type: MinerFeatureType, controller_id: EntityId) -> None: + """Disable a specific feature. Replaces the immutable VO with a disabled copy.""" + self.features = [ + MinerFeature( + feature_type=f.feature_type, + controller_id=f.controller_id, + priority=f.priority, + enabled=False, + ) + if f.feature_type == feature_type and f.controller_id == controller_id + else f + for f in self.features + ] + + def set_priority(self, feature_type: MinerFeatureType, controller_id: EntityId, priority: int) -> None: + """Set the priority of a specific feature. + + Raises ValueError if priority is out of range [1, 100]. + """ + if not 1 <= priority <= 100: + raise ValueError(f"Priority must be between 1 and 100, got {priority}.") + self.features = [ + MinerFeature( + feature_type=f.feature_type, + controller_id=f.controller_id, + priority=priority, + enabled=f.enabled, + ) + if f.feature_type == feature_type and f.controller_id == controller_id + else f + for f in self.features + ] diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 91892cf..33d24aa 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -16,6 +16,32 @@ class MinerStatus(Enum): ERROR = "error" +class MinerFeatureType(Enum): + """Types of features that a miner can support, provided by controllers.""" + + # Monitoring (read-only) + HASHRATE_MONITORING = "hashrate_monitoring" + POWER_MONITORING = "power_monitoring" + STATUS_MONITORING = "status_monitoring" + CHIP_TEMPERATURE_MONITORING = "chip_temperature_monitoring" + BOARD_TEMPERATURE_MONITORING = "board_temperature_monitoring" + INLET_TEMPERATURE_MONITORING = "inlet_temperature_monitoring" + OUTLET_TEMPERATURE_MONITORING = "outlet_temperature_monitoring" + FAN_SPEED_INTERNAL_MONITORING = "fan_speed_internal_monitoring" + FAN_SPEED_EXTERNAL_MONITORING = "fan_speed_external_monitoring" + VOLTAGE_MONITORING = "voltage_monitoring" + FREQUENCY_MONITORING = "frequency_monitoring" + + # Control (write) + MINING_CONTROL = "mining_control" + POWER_CONTROL = "power_control" + INTERNAL_FAN_CONTROL = "internal_fan_control" + EXTERNAL_FAN_CONTROL = "external_fan_control" + + # Info + MODEL_DETECTION = "model_detection" + + class MinerControllerAdapter(AdapterType): """Types of miner controller adapter.""" diff --git a/edge_mining/domain/miner/entities.py b/edge_mining/domain/miner/entities.py index a91ae21..470dda6 100644 --- a/edge_mining/domain/miner/entities.py +++ b/edge_mining/domain/miner/entities.py @@ -3,40 +3,11 @@ from dataclasses import dataclass from typing import Optional -from edge_mining.domain.common import Entity, EntityId, Watts +from edge_mining.domain.common import Entity, EntityId from edge_mining.domain.miner.common import MinerControllerAdapter -from edge_mining.domain.miner.value_objects import HashRate from edge_mining.shared.interfaces.config import MinerControllerConfig -@dataclass -class Miner(Entity): - """Entity for a miner. - - Represents the physical mining asset and its intrinsic (static) properties. - Runtime operational state (status, current hash rate, current power consumption) - is captured separately in MinerStateSnapshot. - """ - - name: str = "" - model: Optional[str] = None - hash_rate_max: Optional[HashRate] = None # Max hash rate for the miner - power_consumption_max: Optional[Watts] = None # Max power consumption for the miner - active: bool = True # Is the miner active in the system? - - controller_id: Optional[EntityId] = None # Controller for the miner - - def activate(self): - """Activate the miner.""" - self.active = True - print(f"Domain: Miner {self.id} activated") - - def deactivate(self): - """Deactivate the miner.""" - self.active = False - print(f"Domain: Miner {self.id} deactivated") - - @dataclass class MinerController(Entity): """Entity for a miner controller.""" diff --git a/edge_mining/domain/miner/ports.py b/edge_mining/domain/miner/ports.py index 7aabe00..93150e0 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -1,48 +1,250 @@ """Collection of Ports for the Mining Device Management domain of the Edge Mining application.""" from abc import ABC, abstractmethod -from typing import List, Optional +from typing import ClassVar, List, Optional from edge_mining.domain.common import EntityId, Watts -from edge_mining.domain.miner.common import MinerStatus -from edge_mining.domain.miner.entities import Miner, MinerController -from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashRate, + Temperature, + Voltage, +) +# --- Feature Ports --- -class MinerControlPort(ABC): - """Port for the Miner Control.""" + +class MinerFeaturePort(ABC): + """Base port for all miner feature ports. + + Each concrete port declares its feature_type as a ClassVar. + Adapters declare their capabilities by implementing these ports via multiple inheritance. + Feature discovery is introspective via MRO. + """ + + feature_type: ClassVar[MinerFeatureType] + + @classmethod + def get_supported_features(cls) -> List[MinerFeatureType]: + """Introspect MRO to discover all supported feature types. + + Walks the class hierarchy and collects feature_type from each + MinerFeaturePort subclass that declares one. + """ + features = [] + for base in cls.__mro__: + if ( + base is not MinerFeaturePort + and isinstance(base, type) + and issubclass(base, MinerFeaturePort) + and "feature_type" in base.__dict__ + ): + features.append(base.feature_type) + return features + + +# --- Monitoring Ports (read-only) --- + + +class HashrateMonitorPort(MinerFeaturePort): + """Port for monitoring miner hashrate.""" + + feature_type = MinerFeatureType.HASHRATE_MONITORING @abstractmethod - def get_model(self) -> Optional[str]: - """Gets the model of the miner.""" + def get_hashrate(self) -> Optional[HashRate]: + """Gets the current hash rate, if available.""" raise NotImplementedError + +class PowerMonitorPort(MinerFeaturePort): + """Port for monitoring miner power consumption.""" + + feature_type = MinerFeatureType.POWER_MONITORING + @abstractmethod - def start_miner(self) -> bool: - """Attempts to start the miner. Returns True on success request.""" + def get_power(self) -> Optional[Watts]: + """Gets the current power consumption, if available.""" raise NotImplementedError + +class StatusMonitorPort(MinerFeaturePort): + """Port for monitoring miner operational status.""" + + feature_type = MinerFeatureType.STATUS_MONITORING + @abstractmethod - def stop_miner(self) -> bool: - """Attempts to stop the specified miner. Returns True on success request.""" + def get_status(self) -> MinerStatus: + """Gets the current operational status of the miner.""" raise NotImplementedError + +class ChipTemperatureMonitorPort(MinerFeaturePort): + """Port for monitoring ASIC chip temperature.""" + + feature_type = MinerFeatureType.CHIP_TEMPERATURE_MONITORING + @abstractmethod - def get_miner_status(self) -> MinerStatus: - """Gets the current operational status of the miner.""" + def get_chip_temperature(self) -> Optional[Temperature]: + """Gets the current chip temperature, if available.""" raise NotImplementedError + +class BoardTemperatureMonitorPort(MinerFeaturePort): + """Port for monitoring board temperature.""" + + feature_type = MinerFeatureType.BOARD_TEMPERATURE_MONITORING + @abstractmethod - def get_miner_power(self) -> Optional[Watts]: - """Gets the current power consumption, if available.""" + def get_board_temperature(self) -> Optional[Temperature]: + """Gets the current board temperature, if available.""" raise NotImplementedError + +class InletTemperatureMonitorPort(MinerFeaturePort): + """Port for monitoring inlet air temperature.""" + + feature_type = MinerFeatureType.INLET_TEMPERATURE_MONITORING + @abstractmethod - def get_miner_hashrate(self) -> Optional[HashRate]: - """Gets the current hash rate, if available.""" + def get_inlet_temperature(self) -> Optional[Temperature]: + """Gets the current inlet air temperature, if available.""" + raise NotImplementedError + + +class OutletTemperatureMonitorPort(MinerFeaturePort): + """Port for monitoring outlet air temperature.""" + + feature_type = MinerFeatureType.OUTLET_TEMPERATURE_MONITORING + + @abstractmethod + def get_outlet_temperature(self) -> Optional[Temperature]: + """Gets the current outlet air temperature, if available.""" + raise NotImplementedError + + +class InternalFanSpeedMonitorPort(MinerFeaturePort): + """Port for monitoring internal fan speed.""" + + feature_type = MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + + @abstractmethod + def get_internal_fan_speed(self) -> Optional[FanSpeed]: + """Gets the current internal fan speed, if available.""" raise NotImplementedError +class ExternalFanSpeedMonitorPort(MinerFeaturePort): + """Port for monitoring external fan speed.""" + + feature_type = MinerFeatureType.FAN_SPEED_EXTERNAL_MONITORING + + @abstractmethod + def get_external_fan_speed(self) -> Optional[FanSpeed]: + """Gets the current external fan speed, if available.""" + raise NotImplementedError + + +class VoltageMonitorPort(MinerFeaturePort): + """Port for monitoring miner voltage.""" + + feature_type = MinerFeatureType.VOLTAGE_MONITORING + + @abstractmethod + def get_voltage(self) -> Optional[Voltage]: + """Gets the current voltage, if available.""" + raise NotImplementedError + + +class FrequencyMonitorPort(MinerFeaturePort): + """Port for monitoring chip operating frequency.""" + + feature_type = MinerFeatureType.FREQUENCY_MONITORING + + @abstractmethod + def get_frequency(self) -> Optional[Frequency]: + """Gets the current chip operating frequency, if available.""" + raise NotImplementedError + + +# --- Control Ports (write) --- + + +class MiningControlPort(MinerFeaturePort): + """Port for software-level mining start/stop control.""" + + feature_type = MinerFeatureType.MINING_CONTROL + + @abstractmethod + def start_mining(self) -> bool: + """Attempts to start mining. Returns True on success.""" + raise NotImplementedError + + @abstractmethod + def stop_mining(self) -> bool: + """Attempts to stop mining. Returns True on success.""" + raise NotImplementedError + + +class PowerControlPort(MinerFeaturePort): + """Port for hard power on/off control (e.g., smart plug).""" + + feature_type = MinerFeatureType.POWER_CONTROL + + @abstractmethod + def power_on(self) -> bool: + """Attempts to power on the miner. Returns True on success.""" + raise NotImplementedError + + @abstractmethod + def power_off(self) -> bool: + """Attempts to power off the miner. Returns True on success.""" + raise NotImplementedError + + +class InternalFanControlPort(MinerFeaturePort): + """Port for controlling internal fan speed via firmware.""" + + feature_type = MinerFeatureType.INTERNAL_FAN_CONTROL + + @abstractmethod + def set_internal_fan_speed(self, speed_percent: float) -> bool: + """Sets internal fan speed as a percentage (0-100). Returns True on success.""" + raise NotImplementedError + + +class ExternalFanControlPort(MinerFeaturePort): + """Port for controlling external fan speed (e.g., ESPHome devices).""" + + feature_type = MinerFeatureType.EXTERNAL_FAN_CONTROL + + @abstractmethod + def set_external_fan_speed(self, speed_percent: float) -> bool: + """Sets external fan speed as a percentage (0-100). Returns True on success.""" + raise NotImplementedError + + +# --- Info Ports --- + + +class ModelDetectionPort(MinerFeaturePort): + """Port for detecting miner hardware model.""" + + feature_type = MinerFeatureType.MODEL_DETECTION + + @abstractmethod + def get_model(self) -> Optional[str]: + """Gets the model of the miner, if available.""" + raise NotImplementedError + + +# --- Repository Ports --- + + class MinerRepository(ABC): """Port for the Miner Repository.""" @@ -73,7 +275,7 @@ def remove(self, miner_id: EntityId) -> None: @abstractmethod def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: - """Retrieves a list of miners by their associated controller ID.""" + """Retrieves a list of miners that have at least one feature provided by the given controller.""" raise NotImplementedError diff --git a/edge_mining/domain/miner/value_objects.py b/edge_mining/domain/miner/value_objects.py index 6646c29..45e1b91 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from typing import Optional -from edge_mining.domain.common import ValueObject, Watts -from edge_mining.domain.miner.common import MinerStatus +from edge_mining.domain.common import EntityId, ValueObject, Watts +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus @dataclass(frozen=True) @@ -15,6 +15,51 @@ class HashRate(ValueObject): unit: str = "TH/s" +@dataclass(frozen=True) +class Temperature(ValueObject): + """Value Object for a temperature measurement.""" + + value: float + unit: str = "°C" + + +@dataclass(frozen=True) +class FanSpeed(ValueObject): + """Value Object for a fan speed measurement.""" + + value: float + unit: str = "RPM" + + +@dataclass(frozen=True) +class Voltage(ValueObject): + """Value Object for a voltage measurement.""" + + value: float + unit: str = "V" + + +@dataclass(frozen=True) +class Frequency(ValueObject): + """Value Object for a frequency measurement.""" + + value: float + unit: str = "MHz" + + +@dataclass(frozen=True) +class MinerFeature(ValueObject): + """Value Object representing a single capability provided by a controller to a miner. + + Identity is given by the pair (feature_type, controller_id). + """ + + feature_type: MinerFeatureType + controller_id: EntityId + priority: int = 50 # 1-100, higher = higher priority + enabled: bool = True + + @dataclass(frozen=True) class MinerStateSnapshot(ValueObject): """Value Object representing a snapshot of a miner's operational state at a given moment. @@ -27,3 +72,11 @@ class MinerStateSnapshot(ValueObject): status: MinerStatus = MinerStatus.UNKNOWN hash_rate: Optional[HashRate] = None power_consumption: Optional[Watts] = None + chip_temperature: Optional[Temperature] = None + board_temperature: Optional[Temperature] = None + inlet_temperature: Optional[Temperature] = None + outlet_temperature: Optional[Temperature] = None + internal_fan_speed: Optional[FanSpeed] = None + external_fan_speed: Optional[FanSpeed] = None + voltage: Optional[Voltage] = None + frequency: Optional[Frequency] = None diff --git a/edge_mining/domain/policy/value_objects.py b/edge_mining/domain/policy/value_objects.py index ae7b723..23e30a2 100644 --- a/edge_mining/domain/policy/value_objects.py +++ b/edge_mining/domain/policy/value_objects.py @@ -10,7 +10,7 @@ from edge_mining.domain.forecast.aggregate_root import Forecast from edge_mining.domain.forecast.value_objects import Sun from edge_mining.domain.home_load.value_objects import ConsumptionForecast -from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.miner.value_objects import HashRate, MinerStateSnapshot From 8069e6a0ed7acdc5eda6fc44edcf361efecd1f43 Mon Sep 17 00:00:00 2001 From: markoceri Date: Tue, 7 Apr 2026 23:44:45 +0200 Subject: [PATCH 04/33] fix: update import path for Miner to use aggregate_roots module --- edge_mining/shared/interfaces/factories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/edge_mining/shared/interfaces/factories.py b/edge_mining/shared/interfaces/factories.py index 872b2ae..c98e68c 100644 --- a/edge_mining/shared/interfaces/factories.py +++ b/edge_mining/shared/interfaces/factories.py @@ -4,7 +4,7 @@ from typing import Any, Optional from edge_mining.domain.energy.entities import EnergySource -from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.shared.external_services.ports import ExternalServicePort from edge_mining.shared.interfaces.config import Configuration, ExternalServiceConfig from edge_mining.shared.logging.port import LoggerPort From c473aa8f0b23600f3ff6e04b1875ae8b142407f2 Mon Sep 17 00:00:00 2001 From: markoceri Date: Tue, 7 Apr 2026 23:50:26 +0200 Subject: [PATCH 05/33] refactor: remove unused model update logic in OptimizationService --- edge_mining/application/services/optimization_service.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index 59b5702..896c5e5 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -292,11 +292,6 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option power_consumption=current_power, ) - # Update model if available and it has changed (static config update) - current_model = miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - break # We found a valid miner and controller, we can stop looking for more miners # --- Mining Performance Tracker --- From e3f5d7751b96af7c8f88ec402db3fc50d0cf2e9c Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 00:00:08 +0200 Subject: [PATCH 06/33] feat: enhance miner feature management by integrating feature ports and updating adapter service methods into application layer --- edge_mining/application/interfaces.py | 23 +- .../application/services/adapter_service.py | 61 ++++-- .../services/configuration_service.py | 78 +++++-- .../services/miner_action_service.py | 204 ++++++++++-------- .../services/optimization_service.py | 138 +++++++----- 5 files changed, 310 insertions(+), 194 deletions(-) diff --git a/edge_mining/application/interfaces.py b/edge_mining/application/interfaces.py index 8b9e1b0..0e26d4d 100644 --- a/edge_mining/application/interfaces.py +++ b/edge_mining/application/interfaces.py @@ -13,9 +13,10 @@ from edge_mining.domain.forecast.entities import ForecastProvider from edge_mining.domain.forecast.ports import ForecastProviderPort from edge_mining.domain.home_load.ports import HomeForecastProviderPort -from edge_mining.domain.miner.common import MinerControllerAdapter -from edge_mining.domain.miner.entities import Miner, MinerController -from edge_mining.domain.miner.ports import MinerControlPort +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.ports import MinerFeaturePort from edge_mining.domain.miner.value_objects import HashRate, MinerStateSnapshot from edge_mining.domain.notification.common import NotificationAdapter from edge_mining.domain.notification.entities import Notifier @@ -48,8 +49,12 @@ def get_energy_monitor(self, energy_source: EnergySource) -> Optional[EnergyMoni """Get an energy monitor adapter instance.""" @abstractmethod - def get_miner_controller(self, miner: Miner) -> Optional[MinerControlPort]: - """Get a miner controller adapter instance""" + def get_miner_controller_adapter(self, miner: Miner, controller_id: EntityId) -> Optional[MinerFeaturePort]: + """Get a miner controller adapter instance for a specific controller.""" + + @abstractmethod + def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: + """Get the adapter implementing the highest-priority active feature port for a miner.""" @abstractmethod def get_all_notifiers(self) -> List[NotificationPort]: @@ -157,7 +162,6 @@ async def add_miner( model: Optional[str] = None, hash_rate_max: Optional[HashRate] = None, power_consumption_max: Optional[Watts] = None, - controller_id: Optional[EntityId] = None, active: bool = True, ) -> Miner: """Add a miner to the system.""" @@ -182,7 +186,6 @@ async def update_miner( model: Optional[str] = None, hash_rate_max: Optional[HashRate] = None, power_consumption_max: Optional[Watts] = None, - controller_id: Optional[EntityId] = None, active: bool = True, ) -> Miner: """Update a miner in the system.""" @@ -245,7 +248,11 @@ async def update_miner_controller( @abstractmethod async def set_miner_controller(self, controller_id: EntityId, miner_id: EntityId) -> None: - """Set a miner controller to a miner.""" + """Associate a controller to a miner, auto-creating features for all supported feature types.""" + + @abstractmethod + async def unlink_controller_from_miner(self, controller_id: EntityId, miner_id: EntityId) -> None: + """Remove all features provided by a controller from a miner.""" @abstractmethod def check_miner_controller(self, controller: MinerController) -> bool: diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index 2f7433e..04d8891 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -32,9 +32,10 @@ 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.miner.common import MinerControllerAdapter -from edge_mining.domain.miner.entities import Miner, MinerController -from edge_mining.domain.miner.ports import MinerControllerRepository, MinerControlPort +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.ports import MinerControllerRepository, MinerFeaturePort from edge_mining.domain.notification.common import NotificationAdapter from edge_mining.domain.notification.entities import Notifier from edge_mining.domain.notification.ports import NotificationPort, NotifierRepository @@ -85,7 +86,7 @@ def __init__( Optional[ Union[ EnergyMonitorPort, - MinerControlPort, + MinerFeaturePort, NotificationPort, ForecastProviderPort, HomeForecastProviderPort, @@ -230,7 +231,7 @@ def _initialize_energy_monitor_adapter( def _initialize_miner_controller_adapter( self, miner: Miner, miner_controller: MinerController - ) -> Optional[MinerControlPort]: + ) -> Optional[MinerFeaturePort]: """Initialize a miner controller adapter.""" # If the adapter has already been created, we use it. if miner_controller.id in self._instance_cache: @@ -243,8 +244,6 @@ def _initialize_miner_controller_adapter( cached_instance = self._instance_cache[miner_controller.id] if not cached_instance: - # If the cached instance is None, we return it - # to indicate that the adapter was not initialized. if self.logger: self.logger.warning( f"Cached instance for miner controller ID {miner_controller.id} " @@ -252,16 +251,14 @@ def _initialize_miner_controller_adapter( ) return None - # Check if the cached instance is of the correct type - if not isinstance(cached_instance, MinerControlPort): + if not isinstance(cached_instance, MinerFeaturePort): if self.logger: self.logger.warning( f"Cached instance for miner controller ID {miner_controller.id} " - f"is not of type MinerControlPort. Reinitializing adapter." + f"is not of type MinerFeaturePort. Reinitializing adapter." ) return None - # If the cached instance is valid, we return it return cached_instance # Retrieve the external service associated to the miner controller @@ -276,7 +273,7 @@ def _initialize_miner_controller_adapter( try: miner_controller_factory: Optional[MinerControllerAdapterFactory] = None - instance: Optional[MinerControlPort] = None + instance: Optional[MinerFeaturePort] = None if miner_controller.adapter_type == MinerControllerAdapter.DUMMY: if miner.power_consumption_max is None or miner.hash_rate_max is None: @@ -616,19 +613,43 @@ def get_energy_monitor(self, energy_source: EnergySource) -> Optional[EnergyMoni return None return self._initialize_energy_monitor_adapter(energy_source, energy_monitor) - def get_miner_controller(self, miner: Miner) -> Optional[MinerControlPort]: - """Get a miner controller adapter instance""" - if not miner.controller_id: - if self.logger: - self.logger.error(f"Miner {miner.name} does not have an associated MinerController ID.") - return None - miner_controller = self.miner_controller_repo.get_by_id(miner.controller_id) + def get_miner_controller_adapter(self, miner: Miner, controller_id: EntityId) -> Optional[MinerFeaturePort]: + """Get a miner controller adapter instance for a specific controller.""" + miner_controller = self.miner_controller_repo.get_by_id(controller_id) if not miner_controller: if self.logger: - self.logger.error(f"Miner Controller ID {miner.controller_id} not found or not a MinerController.") + self.logger.error(f"Miner Controller ID {controller_id} not found.") return None return self._initialize_miner_controller_adapter(miner, miner_controller) + def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: + """Get the adapter implementing the highest-priority active feature for a miner. + + Resolves the active MinerFeature for the given feature_type, retrieves + the associated controller adapter, and verifies it supports the feature. + """ + active_feature = miner.get_active_feature(feature_type) + if not active_feature: + if self.logger: + self.logger.debug(f"No active feature of type {feature_type.value} for miner {miner.name}.") + return None + + adapter = self.get_miner_controller_adapter(miner, active_feature.controller_id) + if not adapter: + return None + + # Verify the adapter actually supports the requested feature type + supported = adapter.__class__.get_supported_features() + if feature_type not in supported: + if self.logger: + self.logger.error( + f"Adapter for controller {active_feature.controller_id} " + f"does not support feature {feature_type.value}." + ) + return None + + return adapter + def get_all_notifiers(self) -> List[NotificationPort]: """Get all notifier adapter instances""" notifier_instances = [] diff --git a/edge_mining/application/services/configuration_service.py b/edge_mining/application/services/configuration_service.py index b7d011c..8ba0a7d 100644 --- a/edge_mining/application/services/configuration_service.py +++ b/edge_mining/application/services/configuration_service.py @@ -9,7 +9,7 @@ ConfigurationUpdatedEventType, ) from edge_mining.application.events.configuration_events import ConfigurationUpdatedEvent -from edge_mining.application.interfaces import ConfigurationServiceInterface, EventBusInterface +from edge_mining.application.interfaces import AdapterServiceInterface, ConfigurationServiceInterface, EventBusInterface from edge_mining.domain.common import EntityId, Watts from edge_mining.domain.energy.common import EnergyMonitorAdapter, EnergySourceType from edge_mining.domain.energy.entities import EnergyMonitor, EnergySource @@ -28,14 +28,15 @@ from edge_mining.domain.home_load.exceptions import HomeForecastProviderNotFoundError from edge_mining.domain.home_load.ports import HomeForecastProviderRepository from edge_mining.domain.miner.common import MinerControllerAdapter -from edge_mining.domain.miner.entities import Miner, MinerController +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.exceptions import ( MinerControllerConfigurationError, MinerControllerNotFoundError, MinerNotFoundError, ) from edge_mining.domain.miner.ports import MinerControllerRepository, MinerRepository -from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature from edge_mining.domain.notification.common import NotificationAdapter from edge_mining.domain.notification.entities import Notifier from edge_mining.domain.notification.exceptions import NotifierConfigurationError, NotifierNotFoundError @@ -101,7 +102,13 @@ class ConfigurationService(ConfigurationServiceInterface): """Handles configuration of miners, policies, and system settings.""" - def __init__(self, persistence_settings: PersistenceSettings, event_bus: EventBusInterface, logger: LoggerPort): + def __init__( + self, + persistence_settings: PersistenceSettings, + event_bus: EventBusInterface, + logger: LoggerPort, + adapter_service: Optional[AdapterServiceInterface] = None, + ): # Domains self.external_service_repo: ExternalServiceRepository = persistence_settings.external_service_repo self.energy_source_repo: EnergySourceRepository = persistence_settings.energy_source_repo @@ -123,6 +130,7 @@ def __init__(self, persistence_settings: PersistenceSettings, event_bus: EventBu # Infrastructure self._event_bus = event_bus self.logger = logger + self.adapter_service = adapter_service # --- External Service Management --- async def create_external_service( @@ -1296,7 +1304,6 @@ async def add_miner( model: Optional[str] = None, hash_rate_max: Optional[HashRate] = None, power_consumption_max: Optional[Watts] = None, - controller_id: Optional[EntityId] = None, active: bool = True, ) -> Miner: """Add a miner to the system.""" @@ -1314,7 +1321,6 @@ async def add_miner( model=model, hash_rate_max=hash_rate_max, power_consumption_max=power_consumption_max, - controller_id=controller_id, active=active, ) @@ -1356,7 +1362,6 @@ async def update_miner( model: Optional[str] = None, hash_rate_max: Optional[HashRate] = None, power_consumption_max: Optional[Watts] = None, - controller_id: Optional[EntityId] = None, active: bool = True, ) -> Miner: """Update a miner in the system.""" @@ -1371,7 +1376,6 @@ async def update_miner( miner.model = model miner.hash_rate_max = hash_rate_max miner.power_consumption_max = power_consumption_max - miner.controller_id = controller_id miner.active = active self.check_miner(miner) @@ -1425,11 +1429,11 @@ def check_miner(self, miner: Miner) -> bool: if not miner: raise MinerNotFoundError("Miner not found.") - # Check if the controller exists - if miner.controller_id: - controller = self.miner_controller_repo.get_by_id(miner.controller_id) + # Verify all referenced controllers exist + for controller_id in miner.get_controller_ids(): + controller = self.miner_controller_repo.get_by_id(controller_id) if not controller: - raise MinerControllerNotFoundError(f"Miner Controller with ID {miner.controller_id} not found.") + raise MinerControllerNotFoundError(f"Miner Controller with ID {controller_id} not found.") self.logger.debug(f"Miner {miner.id} ({miner.name}) is valid.") return True @@ -1478,14 +1482,16 @@ def list_miner_controllers(self) -> List[MinerController]: return self.miner_controller_repo.get_all() async def unlink_miner_controller(self, miner_controller_id: EntityId) -> None: - """Unlink a miner controller from all miners.""" + """Unlink a miner controller from all miners (remove all features from that controller).""" self.logger.info(f"Unlinking controller {miner_controller_id} from all miners") miners: List[Miner] = self.miner_repo.get_by_controller_id(miner_controller_id) for miner in miners: - self.logger.info(f"Unlinking miner {miner.name} ({miner.id}) from controller {miner_controller_id}") - miner.controller_id = None + self.logger.info( + f"Removing features from miner {miner.name} ({miner.id}) for controller {miner_controller_id}" + ) + miner.remove_features_by_controller(miner_controller_id) self.miner_repo.update(miner) async def remove_miner_controller(self, controller_id: EntityId) -> MinerController: @@ -1559,7 +1565,7 @@ async def update_miner_controller( return controller async def set_miner_controller(self, controller_id: EntityId, miner_id: EntityId) -> None: - """Set a miner controller to a miner.""" + """Associate a controller to a miner, auto-creating features for all supported feature types.""" self.logger.info(f"Adding controller {controller_id} to miner {miner_id}") miner = self.miner_repo.get_by_id(miner_id) @@ -1567,10 +1573,46 @@ async def set_miner_controller(self, controller_id: EntityId, miner_id: EntityId if not miner: raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") - if not self.miner_controller_repo.get_by_id(controller_id): + controller = self.miner_controller_repo.get_by_id(controller_id) + if not controller: raise MinerControllerNotFoundError(f"Controller with ID {controller_id} does not exist.") - miner.controller_id = controller_id + # Discover supported features via adapter's MRO + if not self.adapter_service: + raise MinerControllerConfigurationError("Adapter service is required to discover supported features.") + + adapter = self.adapter_service.get_miner_controller_adapter(miner, controller_id) + if not adapter: + raise MinerControllerConfigurationError(f"Could not initialize adapter for controller {controller_id}.") + + supported_features = adapter.__class__.get_supported_features() + + # Auto-create features (enabled=True, priority=50) + for feature_type in supported_features: + feature = MinerFeature( + feature_type=feature_type, + controller_id=controller_id, + priority=50, + enabled=True, + ) + try: + miner.add_feature(feature) + except ValueError: + # Feature already exists for this (type, controller) pair — skip + pass + + self.miner_repo.update(miner) + + async def unlink_controller_from_miner(self, controller_id: EntityId, miner_id: EntityId) -> None: + """Remove all features provided by a controller from a specific miner.""" + self.logger.info(f"Unlinking controller {controller_id} from miner {miner_id}") + + miner = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + miner.remove_features_by_controller(controller_id) self.miner_repo.update(miner) def check_miner_controller(self, controller: MinerController) -> bool: diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 16b1c36..e18718e 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -4,17 +4,24 @@ from edge_mining.application.interfaces import AdapterServiceInterface, EventBusInterface, MinerActionServiceInterface from edge_mining.domain.common import EntityId, Watts -from edge_mining.domain.miner.common import MinerStatus -from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus from edge_mining.domain.miner.events import MinerStateChangedEvent from edge_mining.domain.miner.exceptions import ( MinerControllerConfigurationError, - MinerControllerNotFoundError, MinerNotActiveError, MinerNotFoundError, ) -from edge_mining.domain.miner.ports import MinerRepository -from edge_mining.domain.miner.value_objects import HashRate, MinerStateSnapshot +from edge_mining.domain.miner.ports import ( + HashrateMonitorPort, + MinerRepository, + MiningControlPort, + ModelDetectionPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerStateSnapshot from edge_mining.domain.notification.ports import NotificationPort from edge_mining.shared.logging.port import LoggerPort @@ -39,6 +46,15 @@ def __init__( self._event_bus = event_bus self.logger = logger + def _try_update_model(self, miner: Miner) -> None: + """Update miner model from MODEL_DETECTION feature port if available.""" + model_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) + if model_port and isinstance(model_port, ModelDetectionPort): + current_model = model_port.get_model() + if current_model and miner.model != current_model: + miner.model = current_model + self.miner_repo.update(miner) + async def _notify(self, notifiers: List[NotificationPort], title: str, message: str): """Sends a notification using the configured notifiers.""" @@ -64,22 +80,27 @@ async def start_miner(self, miner_id: EntityId, notifiers: Optional[List[Notific if not miner.active: raise MinerNotActiveError(f"Miner {miner_id} is not active and cannot be started.") - # Get the miner controller from the adapter service - miner_controller = self.adapter_service.get_miner_controller(miner) + # Try MINING_CONTROL first, then POWER_CONTROL as fallback + mining_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + power_ctrl_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") + if not mining_port and not power_ctrl_port: + raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") - # Query current state from controller - current_status = miner_controller.get_miner_status() + # Get current status + status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = status_port.get_status() - # Update model if available and it has changed (static config update) - current_model = miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + # Update model + self._try_update_model(miner) - success = miner_controller.start_miner() + success = False + if mining_port and isinstance(mining_port, MiningControlPort): + success = mining_port.start_mining() + elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): + success = power_ctrl_port.power_on() if success: if self.logger: @@ -121,22 +142,27 @@ async def stop_miner(self, miner_id: EntityId, notifiers: Optional[List[Notifica if not miner.active: raise MinerNotActiveError(f"Miner {miner_id} is not active and cannot be stopped.") - # Get the miner controller from the adapter service - miner_controller = self.adapter_service.get_miner_controller(miner) + # Try MINING_CONTROL first, then POWER_CONTROL as fallback + mining_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + power_ctrl_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") + if not mining_port and not power_ctrl_port: + raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") - # Query current state from controller - current_status = miner_controller.get_miner_status() + # Get current status + status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = status_port.get_status() - # Update model if available and it has changed (static config update) - current_model = miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + # Update model + self._try_update_model(miner) - success = miner_controller.stop_miner() + success = False + if mining_port and isinstance(mining_port, MiningControlPort): + success = mining_port.stop_mining() + elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): + success = power_ctrl_port.power_off() if success: if self.logger: @@ -175,15 +201,11 @@ def get_miner_consumption(self, miner_id: EntityId) -> Optional[Watts]: if not miner: raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") - # Get the miner controller from the adapter service - miner_controller = self.adapter_service.get_miner_controller(miner) - - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") + port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + if not port or not isinstance(port, PowerMonitorPort): + raise MinerControllerConfigurationError(f"No power monitor available for miner {miner_id}.") - current_power = miner_controller.get_miner_power() - - return current_power + return port.get_power() def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: """Gets the current hash rate of the specified miner.""" @@ -195,21 +217,13 @@ def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: if not miner: raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") - # Get the miner controller from the adapter service - miner_controller = self.adapter_service.get_miner_controller(miner) - - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") - - current_hashrate = miner_controller.get_miner_hashrate() + port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + if not port or not isinstance(port, HashrateMonitorPort): + raise MinerControllerConfigurationError(f"No hashrate monitor available for miner {miner_id}.") - # Update model if available and it has changed (static config update) - current_model = miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + self._try_update_model(miner) - return current_hashrate + return port.get_hashrate() async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: """Gets the current status of the specified miner as a state snapshot.""" @@ -221,22 +235,23 @@ async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: if not miner: raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") - # Get the miner controller from the adapter service - miner_controller = self.adapter_service.get_miner_controller(miner) + # Query individual feature ports + status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = status_port.get_status() - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") + hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = hashrate_port.get_hashrate() - # Query current state from controller - current_status = miner_controller.get_miner_status() - current_hashrate = miner_controller.get_miner_hashrate() - current_power = miner_controller.get_miner_power() + power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = power_port.get_power() - # Update model if available and it has changed (static config update) - current_model = miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + self._try_update_model(miner) return MinerStateSnapshot( status=current_status, @@ -275,27 +290,26 @@ async def sync_all_miners(self, include_inactive: bool = False) -> None: if self.logger: self.logger.debug(f"Syncing status for miner {miner.id} ({miner.name})...") - # Get the miner controller from the adapter service - miner_controller = self.adapter_service.get_miner_controller(miner) - - if not miner_controller: + status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + if not status_port or not isinstance(status_port, StatusMonitorPort): if self.logger: - self.logger.warning( - f"Miner controller for miner {miner.id} ({miner.name}) is not configured. Skipping." - ) + self.logger.warning(f"No status monitor for miner {miner.id} ({miner.name}). Skipping.") error_count += 1 continue - # Query current state from controller (for logging purposes) - current_status = miner_controller.get_miner_status() - current_hashrate = miner_controller.get_miner_hashrate() - current_power = miner_controller.get_miner_power() + current_status = status_port.get_status() + + hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = hashrate_port.get_hashrate() + + power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = power_port.get_power() - # Update model if available and it has changed (static config update) - current_model = miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + self._try_update_model(miner) synced_count += 1 @@ -322,26 +336,34 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi if self.logger: self.logger.info(f"Getting miner details from controller {controller_id}") - # Create a temporary miner to hold the details retrieved from the controller + # Create a temporary miner with features for all possible feature types + # so the adapter service can resolve the controller + + temp_features = [MinerFeature(feature_type=ft, controller_id=controller_id) for ft in MinerFeatureType] temp_miner = Miner( name="Unknown", model="Unknown", hash_rate_max=None, power_consumption_max=None, - controller_id=controller_id, active=True, + features=temp_features, ) - # Get the miner controller from the adapter service - miner_controller = self.adapter_service.get_miner_controller(temp_miner) - - if not miner_controller: - raise MinerControllerNotFoundError(f"Controller with ID {controller_id} not found.") - - # Retrieve details from the controller - current_status = miner_controller.get_miner_status() - current_hashrate = miner_controller.get_miner_hashrate() - current_power = miner_controller.get_miner_power() + # Query via feature ports + status_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.STATUS_MONITORING) + current_status = MinerStatus.UNKNOWN + if status_port and isinstance(status_port, StatusMonitorPort): + current_status = status_port.get_status() + + hashrate_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.HASHRATE_MONITORING) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = hashrate_port.get_hashrate() + + power_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = power_port.get_power() has_no_details = all( ( diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index 896c5e5..d077849 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -25,11 +25,19 @@ from edge_mining.domain.forecast.ports import ForecastProviderPort from edge_mining.domain.home_load.ports import HomeForecastProviderPort from edge_mining.domain.home_load.value_objects import ConsumptionForecast -from edge_mining.domain.miner.common import MinerStatus -from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus from edge_mining.domain.miner.events import MinerStateChangedEvent from edge_mining.domain.miner.exceptions import MinerError -from edge_mining.domain.miner.ports import MinerControlPort, MinerRepository +from edge_mining.domain.miner.ports import ( + HashrateMonitorPort, + MinerFeaturePort, + MinerRepository, + MiningControlPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) from edge_mining.domain.miner.value_objects import HashRate, MinerStateSnapshot from edge_mining.domain.notification.ports import NotificationPort from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit @@ -262,28 +270,31 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option ) continue # Try next miner if available - if not miner.controller_id: + if not miner.get_controller_ids(): if self.logger: self.logger.warning( - f"Miner {miner_id} in optimization unit '{optimization_unit.name}' does not have a controller ID. Skipping miner." + f"Miner {miner_id} in optimization unit '{optimization_unit.name}' has no controllers. Skipping miner." ) continue # Try next miner if available - # --- Miner Controller --- - miner_controller = self.adapter_service.get_miner_controller(miner) - if not miner_controller: + # --- Query current state via feature ports --- + status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + if not status_port or not isinstance(status_port, StatusMonitorPort): if self.logger: - self.logger.error( - f"Controller for miner {miner_id} " - f"(Config ID: {miner.controller_id}) not found/initialized. " - f"Using default." - ) - continue # Try next miner if available + self.logger.error(f"No status monitor port for miner {miner_id}. Skipping.") + continue - # Query current state from controller - current_status = miner_controller.get_miner_status() - current_hashrate = miner_controller.get_miner_hashrate() - current_power = miner_controller.get_miner_power() + current_status = status_port.get_status() + + hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = hashrate_port.get_hashrate() + + power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = power_port.get_power() # Build the miner state snapshot miner_state = MinerStateSnapshot( @@ -687,32 +698,24 @@ async def _process_single_miner_in_unit( ) return - # --- Miner Controller --- - miner_controller: Optional[MinerControlPort] = None - if miner.controller_id: - try: - miner_controller = self.adapter_service.get_miner_controller(miner) - except Exception as e: - if self.logger: - self.logger.critical(f"Error getting controller for miner {miner_id}: {e}") - if not miner_controller: - if self.logger: - self.logger.error( - f"Controller for miner {miner_id} " - f"(Config ID: {miner.controller_id}) not found/initialized. " - f"Using default." - ) - message = f"Controller for miner {miner_id} not found in optimization unit '{optimization_unit.name}'." - await self._notify_unit( - notifiers, - f"Optimizer Error ({optimization_unit.name})", - message, - ) + # --- Miner Controller (via feature ports) --- + status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + if not status_port or not isinstance(status_port, StatusMonitorPort): + if self.logger: + self.logger.error(f"No status monitor available for miner {miner_id}. Cannot control miner.") + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name} / {miner_id})", + "Status monitor unavailable.", + ) + return + + mining_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) - if not miner_controller: + if not mining_port: if self.logger: self.logger.error( - f"No miner controller (specific or default) available " + f"No mining control port available " f"for miner {miner_id} in optimization unit " f"'{optimization_unit.name}'. Cannot control miner." ) @@ -725,10 +728,18 @@ async def _process_single_miner_in_unit( # Get current status and make decision try: - # Query current state from controller - current_status = miner_controller.get_miner_status() - current_hashrate = miner_controller.get_miner_hashrate() - current_power = miner_controller.get_miner_power() + # Query current state via feature ports + current_status = status_port.get_status() + + hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = hashrate_port.get_hashrate() + + power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + current_power = None + if power_port and isinstance(power_port, PowerMonitorPort): + current_power = power_port.get_power() # Build the miner state snapshot miner_state = MinerStateSnapshot( @@ -738,10 +749,15 @@ async def _process_single_miner_in_unit( ) # Update model if available and it has changed (static config update) - current_model = miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + model_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) + if model_port: + from edge_mining.domain.miner.ports import ModelDetectionPort + + if isinstance(model_port, ModelDetectionPort): + current_model = model_port.get_model() + if current_model and miner.model != current_model: + miner.model = current_model + self.miner_repo.update(miner) # Creates a copy of the context with the miner included, so that the policy # can access miner-specific data, without modifying the original context. @@ -801,7 +817,8 @@ async def _process_single_miner_in_unit( ) await self._execute_miner_decision( - miner_controller, + mining_port, + status_port, miner_id, decision, current_status, @@ -834,7 +851,8 @@ async def _process_single_miner_in_unit( async def _execute_miner_decision( self, - controller: MinerControlPort, + mining_port: MinerFeaturePort, + status_port: StatusMonitorPort, miner_id: EntityId, decision: MiningDecision, current_status: MinerStatus, @@ -847,8 +865,11 @@ async def _execute_miner_decision( if decision == MiningDecision.START_MINING and current_status != MinerStatus.ON: if self.logger: - self.logger.info(f"Executing START for miner {miner_id} via {type(controller).__name__}") - success = controller.start_miner() + self.logger.info(f"Executing START for miner {miner_id} via {type(mining_port).__name__}") + if isinstance(mining_port, MiningControlPort): + success = mining_port.start_mining() + elif isinstance(mining_port, PowerControlPort): + success = mining_port.power_on() action_taken = True if success: await self._notify_unit( @@ -865,8 +886,11 @@ async def _execute_miner_decision( elif decision == MiningDecision.STOP_MINING and current_status == MinerStatus.ON: if self.logger: - self.logger.info(f"Executing STOP for miner {miner_id} via {type(controller).__name__}") - success = controller.stop_miner() + self.logger.info(f"Executing STOP for miner {miner_id} via {type(mining_port).__name__}") + if isinstance(mining_port, MiningControlPort): + success = mining_port.stop_mining() + elif isinstance(mining_port, PowerControlPort): + success = mining_port.power_off() action_taken = True if success: await self._notify_unit( @@ -885,13 +909,13 @@ async def _execute_miner_decision( if not success: if self.logger: self.logger.error( - f"Command {decision.name} for miner {miner_id} failed using controller {type(controller).__name__}." + f"Command {decision.name} for miner {miner_id} failed using controller {type(mining_port).__name__}." ) else: miner = self.miner_repo.get_by_id(miner_id) # Get new miner state to publish in the event - new_status = controller.get_miner_status() # Get the updated status after the command + new_status = status_port.get_status() # Publish miner state changed event if self._event_bus: From 4633456907bf36d711fa9dca902c7490c02dabd8 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 00:02:52 +0200 Subject: [PATCH 07/33] feat: add miner_features table and update repositories to manage miner features --- .../a1b2c3d4e5f6_add_miner_features_table.py | 63 +++++ .../adapters/domain/miner/repositories.py | 216 ++++++++++-------- edge_mining/adapters/domain/miner/tables.py | 116 ++++++---- 3 files changed, 256 insertions(+), 139 deletions(-) create mode 100644 alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py diff --git a/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py b/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py new file mode 100644 index 0000000..ffd92e7 --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py @@ -0,0 +1,63 @@ +"""Add miner_features table and remove controller_id from miners + +Revision ID: a1b2c3d4e5f6 +Revises: 4e55fe6113c7 +Create Date: 2026-01-24 10:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "4e55fe6113c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add miner_features table and migrate controller_id data.""" + # Create miner_features table + op.create_table( + "miner_features", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("miner_id", sa.String(), nullable=False), + sa.Column("controller_id", sa.String(), nullable=False), + sa.Column("feature_type", sa.String(), nullable=False), + sa.Column("priority", sa.Integer(), nullable=False, server_default="50"), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"), + sa.ForeignKeyConstraint( + ["miner_id"], + ["miners.id"], + ), + sa.ForeignKeyConstraint( + ["controller_id"], + ["miner_controllers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + + # Remove controller_id column from miners table + # Note: SQLite doesn't support DROP COLUMN directly in older versions, + # but Alembic handles this with batch mode on SQLite. + with op.batch_alter_table("miners") as batch_op: + batch_op.drop_column("controller_id") + + +def downgrade() -> None: + """Remove miner_features table and restore controller_id on miners.""" + # Re-add controller_id to miners + with op.batch_alter_table("miners") as batch_op: + batch_op.add_column(sa.Column("controller_id", sa.String(), nullable=True)) + batch_op.create_foreign_key( + "fk_miners_controller_id", + "miner_controllers", + ["controller_id"], + ["id"], + ) + + # Drop miner_features table + op.drop_table("miner_features") diff --git a/edge_mining/adapters/domain/miner/repositories.py b/edge_mining/adapters/domain/miner/repositories.py index 7f611fb..8ae0d8c 100644 --- a/edge_mining/adapters/domain/miner/repositories.py +++ b/edge_mining/adapters/domain/miner/repositories.py @@ -7,12 +7,20 @@ from sqlalchemy import select -from edge_mining.adapters.domain.miner.tables import miner_controllers_table, miners_table +from edge_mining.adapters.domain.miner.tables import ( + delete_features_for_miner, + load_features_for_miner, + miner_controllers_table, + miner_features_table, + miners_table, + save_features_for_miner, +) from edge_mining.adapters.infrastructure.persistence.sqlalchemy.base import BaseSQLAlchemyRepository from edge_mining.adapters.infrastructure.persistence.sqlite import BaseSqliteRepository from edge_mining.domain.common import EntityId, Watts -from edge_mining.domain.miner.common import MinerControllerAdapter -from edge_mining.domain.miner.entities import Miner, MinerController +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.exceptions import ( MinerControllerAlreadyExistsError, MinerControllerConfigurationError, @@ -21,7 +29,7 @@ MinerError, ) from edge_mining.domain.miner.ports import MinerControllerRepository, MinerRepository -from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_CONFIG_TYPE_MAP from edge_mining.shared.interfaces.config import MinerControllerConfig @@ -61,40 +69,44 @@ def remove(self, miner_id: EntityId) -> None: del self._miners[miner_id] def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: - """Get all miners associated with a specific controller ID.""" - return ( - [copy.deepcopy(m) for m in self._miners.values() if m.controller_id == controller_id] - if controller_id - else [] - ) + """Get all miners that have at least one feature provided by the given controller.""" + if not controller_id: + return [] + return [ + copy.deepcopy(m) for m in self._miners.values() if any(f.controller_id == controller_id for f in m.features) + ] class SqliteMinerRepository(MinerRepository): """SQLite implementation for the Miner Repository.""" TABLE_NAME = "miners" + FEATURES_TABLE_NAME = "miner_features" - # Declarative schema definition - # NOTE: If you modify SCHEMA, update BaseSqliteRepository.CURRENT_DB_VERSION SCHEMA = { "id": "TEXT PRIMARY KEY", "name": "TEXT NOT NULL", - "model": "TEXT", # Miner model/hardware identifier + "model": "TEXT", "active": "INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1))", - "hash_rate_max": "TEXT", # JSON object of HashRate dict + "hash_rate_max": "TEXT", "power_consumption_max": "REAL", - "controller_id": "TEXT", # Foreign key to miner controller + } + + FEATURES_SCHEMA = { + "id": "INTEGER PRIMARY KEY AUTOINCREMENT", + "miner_id": "TEXT NOT NULL", + "controller_id": "TEXT NOT NULL", + "feature_type": "TEXT NOT NULL", + "priority": "INTEGER NOT NULL DEFAULT 50", + "enabled": "INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1))", } def __init__(self, db: BaseSqliteRepository): self._db = db self.logger = db.logger - # BaseSqliteRepository generates CREATE TABLE SQL automatically - self._db.create_tables( - table_name=self.TABLE_NAME, - schema=self.SCHEMA, - ) + self._db.create_tables(table_name=self.TABLE_NAME, schema=self.SCHEMA) + self._db.create_tables(table_name=self.FEATURES_TABLE_NAME, schema=self.FEATURES_SCHEMA) def _dict_to_hashrate(self, data: Dict[str, Any]) -> HashRate: """Deserialize a dictionary (from JSON) into an HashRate object.""" @@ -107,17 +119,21 @@ def _hashrate_to_dict(self, hash_rate: Optional[HashRate]) -> Dict[str, Any]: "unit": hash_rate.unit if hash_rate else "TH/s", } - def _row_to_miner(self, row: sqlite3.Row) -> Optional[Miner]: + def _row_to_miner(self, row: sqlite3.Row, conn: Optional[sqlite3.Connection] = None) -> Optional[Miner]: """Deserialize a row from the database into a Miner object.""" if not row: return None try: hash_rate_max_data = json.loads(row["hash_rate_max"]) if row["hash_rate_max"] else None - hash_rate_max = self._dict_to_hashrate(hash_rate_max_data) if hash_rate_max_data else None + miner_id = EntityId(row["id"]) + features: List[MinerFeature] = [] + if conn: + features = self._load_features(conn, miner_id) + return Miner( - id=EntityId(row["id"]), + id=miner_id, name=row["name"] if row["name"] is not None else "", model=row["model"] if row["model"] is not None else None, active=(row["active"] == 1 if row["active"] is not None else False), @@ -125,24 +141,49 @@ def _row_to_miner(self, row: sqlite3.Row) -> Optional[Miner]: power_consumption_max=( Watts(row["power_consumption_max"]) if row["power_consumption_max"] is not None else None ), - controller_id=(EntityId(row["controller_id"]) if row["controller_id"] else None), + features=features, ) except (ValueError, KeyError) as e: self.logger.error(f"Error deserializing Miner from DB row: {row}. Error: {e}") return None + def _load_features(self, conn: sqlite3.Connection, miner_id: EntityId) -> List[MinerFeature]: + """Load features for a miner from the features table.""" + sql = f"SELECT * FROM {self.FEATURES_TABLE_NAME} WHERE miner_id = ?" + cursor = conn.cursor() + cursor.execute(sql, (str(miner_id),)) + rows = cursor.fetchall() + features = [] + for r in rows: + features.append( + MinerFeature( + feature_type=MinerFeatureType(r["feature_type"]), + controller_id=EntityId(r["controller_id"]), + priority=r["priority"], + enabled=bool(r["enabled"]), + ) + ) + return features + + def _save_features(self, conn: sqlite3.Connection, miner_id: EntityId, features: List[MinerFeature]) -> None: + """Replace all features for a miner.""" + conn.execute(f"DELETE FROM {self.FEATURES_TABLE_NAME} WHERE miner_id = ?", (str(miner_id),)) + for f in features: + conn.execute( + f"INSERT INTO {self.FEATURES_TABLE_NAME} (miner_id, controller_id, feature_type, priority, enabled) VALUES (?, ?, ?, ?, ?)", + (str(miner_id), str(f.controller_id), f.feature_type.value, f.priority, int(f.enabled)), + ) + def add(self, miner: Miner) -> None: """Add a miner to the SQLite database.""" self.logger.debug(f"Adding miner {miner.id} to SQLite.") sql = f""" - INSERT INTO {self.TABLE_NAME} (id, name, model, active, hash_rate_max, power_consumption_max, - controller_id) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO {self.TABLE_NAME} (id, name, model, active, hash_rate_max, power_consumption_max) + VALUES (?, ?, ?, ?, ?, ?) """ conn = self._db.get_connection() try: - # Serialize hash_rate_max to JSON for storage hash_rate_max_json = json.dumps(self._hashrate_to_dict(miner.hash_rate_max)) with conn: @@ -155,9 +196,9 @@ def add(self, miner: Miner) -> None: miner.active, hash_rate_max_json, (float(miner.power_consumption_max) if miner.power_consumption_max is not None else 0.0), - miner.controller_id, ), ) + self._save_features(conn, miner.id, miner.features) except sqlite3.IntegrityError as e: self.logger.error(f"Integrity error adding miner {miner.id}: {e}") # Could mean that the ID already exists @@ -179,10 +220,10 @@ def get_by_id(self, miner_id: EntityId) -> Optional[Miner]: cursor = conn.cursor() cursor.execute(sql, (miner_id,)) row = cursor.fetchone() - return self._row_to_miner(row) + return self._row_to_miner(row, conn) except sqlite3.Error as e: self.logger.error(f"SQLite error getting miner {miner_id}: {e}") - return None # Or raise exception? Returning None is more forgiving + return None finally: if conn: conn.close() @@ -199,7 +240,7 @@ def get_all(self) -> List[Miner]: cursor.execute(sql) rows = cursor.fetchall() for row in rows: - miner = self._row_to_miner(row) + miner = self._row_to_miner(row, conn) if miner: miners.append(miner) except sqlite3.Error as e: @@ -216,13 +257,11 @@ def update(self, miner: Miner) -> None: sql = f""" UPDATE {self.TABLE_NAME} - SET name = ?, model = ?, active = ?, hash_rate_max = ?, power_consumption_max = ?, - controller_id = ? + SET name = ?, model = ?, active = ?, hash_rate_max = ?, power_consumption_max = ? WHERE id = ? """ conn = self._db.get_connection() try: - # Serialize hash_rate_max to JSON for storage hash_rate_max_json = json.dumps(self._hashrate_to_dict(miner.hash_rate_max)) with conn: @@ -235,12 +274,12 @@ def update(self, miner: Miner) -> None: miner.active, hash_rate_max_json, (float(miner.power_consumption_max) if miner.power_consumption_max is not None else 0.0), - miner.controller_id, miner.id, ), ) if cursor.rowcount == 0: raise MinerError(f"No miner found with ID {miner.id} for update.") + self._save_features(conn, miner.id, miner.features) except sqlite3.Error as e: self.logger.error(f"SQLite error updating miner {miner.id}: {e}") raise MinerError(f"DB error updating miner: {e}") from e @@ -252,16 +291,15 @@ def remove(self, miner_id: EntityId) -> None: """Remove a miner from the SQLite database.""" self.logger.debug(f"Removing miner {miner_id} from SQLite.") - sql = f"DELETE FROM {self.TABLE_NAME} WHERE id = ?" conn = self._db.get_connection() try: with conn: cursor = conn.cursor() - cursor.execute(sql, (miner_id,)) + # Delete features first + cursor.execute(f"DELETE FROM {self.FEATURES_TABLE_NAME} WHERE miner_id = ?", (str(miner_id),)) + cursor.execute(f"DELETE FROM {self.TABLE_NAME} WHERE id = ?", (str(miner_id),)) if cursor.rowcount == 0: self.logger.warning(f"Attempt to remove non-existent miner with ID {miner_id}.") - # There is no need to raise an exception here, removing a - # non-existent is idempotent. except sqlite3.Error as e: self.logger.error(f"SQLite error removing miner {miner_id}: {e}") raise MinerError(f"DB error removing miner: {e}") from e @@ -270,18 +308,22 @@ def remove(self, miner_id: EntityId) -> None: conn.close() def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: - """Get all miners associated with a specific controller ID.""" + """Get all miners that have at least one feature provided by the given controller.""" self.logger.debug(f"Getting miners by controller ID {controller_id} from SQLite.") - sql = f"SELECT * FROM {self.TABLE_NAME} WHERE controller_id = ?" + sql = f""" + SELECT DISTINCT m.* FROM {self.TABLE_NAME} m + INNER JOIN {self.FEATURES_TABLE_NAME} f ON m.id = f.miner_id + WHERE f.controller_id = ? + """ conn = self._db.get_connection() miners = [] try: cursor = conn.cursor() - cursor.execute(sql, (controller_id,)) + cursor.execute(sql, (str(controller_id),)) rows = cursor.fetchall() for row in rows: - miner = self._row_to_miner(row) + miner = self._row_to_miner(row, conn) if miner: miners.append(miner) return miners @@ -296,101 +338,81 @@ def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: class SqlAlchemyMinerRepository(MinerRepository): """SQLAlchemy-based implementation of the MinerRepository port. - This repository works directly with the imperatively mapped Miner domain entity. - Since the domain entity is mapped directly to the database via imperative mapping - with composite value objects, SQLAlchemy handles the conversion between the - flattened database columns and the rich domain value objects automatically. - - Args: - db: BaseSQLAlchemyRepository instance for database operations - session: SQLAlchemy database session for executing queries + Features are persisted in the miner_features table and loaded/saved + separately from the Miner entity (which is mapped without the features field). """ def __init__(self, db: BaseSQLAlchemyRepository): - """Initialize repository with database instance. - - Args: - db: BaseSQLAlchemyRepository instance - """ self._db = db self.logger = db.logger - def add(self, miner: Miner) -> None: - """Add a new miner to the repository. + def _populate_features(self, session, miner: Miner) -> Miner: + """Load features from DB and attach to the miner entity.""" + miner.features = load_features_for_miner(session, miner.id) + return miner - Args: - miner: Domain entity to persist - """ + def add(self, miner: Miner) -> None: + """Add a new miner to the repository.""" session = self._db.get_session() try: + features = list(miner.features) session.add(miner) + session.flush() + save_features_for_miner(session, miner.id, features) session.commit() + miner.features = features finally: session.close() def get_by_id(self, miner_id: EntityId) -> Optional[Miner]: - """Retrieve a miner by its ID. - - Args: - miner_id: Unique identifier of the miner - - Returns: - Domain entity if found, None otherwise - """ + """Retrieve a miner by its ID.""" session = self._db.get_session() try: stmt = select(Miner).where(miners_table.c.id == str(miner_id)) entity = session.execute(stmt).scalar_one_or_none() + if entity: + self._populate_features(session, entity) return entity finally: session.close() def get_all(self) -> List[Miner]: - """Retrieve all miners from the repository. - - Returns: - List of all miner domain entities - """ + """Retrieve all miners from the repository.""" session = self._db.get_session() try: stmt = select(Miner) entities = session.execute(stmt).scalars().all() + for entity in entities: + self._populate_features(session, entity) return list(entities) finally: session.close() def update(self, miner: Miner) -> None: - """Update an existing miner in the repository. - - Args: - miner: Domain entity with updated state - """ + """Update an existing miner in the repository.""" session = self._db.get_session() try: stmt = select(Miner).where(miners_table.c.id == str(miner.id)) existing_entity = session.execute(stmt).scalar_one_or_none() if existing_entity: - # Update all fields from the new entity existing_entity.name = miner.name existing_entity.model = miner.model existing_entity.active = miner.active existing_entity.hash_rate_max = miner.hash_rate_max existing_entity.power_consumption_max = miner.power_consumption_max - existing_entity.controller_id = miner.controller_id + save_features_for_miner(session, miner.id, miner.features) session.commit() + existing_entity.features = list(miner.features) finally: session.close() def remove(self, miner_id: EntityId) -> None: - """Remove a miner from the repository. - - Args: - miner_id: Unique identifier of the miner to remove - """ + """Remove a miner from the repository.""" session = self._db.get_session() try: + delete_features_for_miner(session, miner_id) stmt = select(Miner).where(miners_table.c.id == str(miner_id)) entity = session.execute(stmt).scalar_one_or_none() @@ -401,18 +423,20 @@ def remove(self, miner_id: EntityId) -> None: session.close() def get_by_controller_id(self, controller_id: EntityId) -> List[Miner]: - """Retrieve all miners associated with a specific controller. - - Args: - controller_id: Unique identifier of the controller - - Returns: - List of miner domain entities associated with the controller - """ + """Retrieve all miners that have at least one feature from the given controller.""" session = self._db.get_session() try: - stmt = select(Miner).where(miners_table.c.controller_id == str(controller_id)) + # Subquery: distinct miner_ids from miner_features where controller matches + subq = ( + select(miner_features_table.c.miner_id) + .where(miner_features_table.c.controller_id == str(controller_id)) + .distinct() + .subquery() + ) + stmt = select(Miner).where(miners_table.c.id.in_(select(subq))) entities = session.execute(stmt).scalars().all() + for entity in entities: + self._populate_features(session, entity) return list(entities) finally: session.close() diff --git a/edge_mining/adapters/domain/miner/tables.py b/edge_mining/adapters/domain/miner/tables.py index 425d187..6fa68b7 100644 --- a/edge_mining/adapters/domain/miner/tables.py +++ b/edge_mining/adapters/domain/miner/tables.py @@ -29,16 +29,16 @@ import uuid from typing import Any, Optional -from sqlalchemy import Boolean, Column, Float, ForeignKey, String, Table, event -from sqlalchemy.orm import relationship +from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Table, event from edge_mining.adapters.infrastructure.persistence.sqlalchemy.common import ConfigurationType from edge_mining.adapters.infrastructure.persistence.sqlalchemy.registry import mapper_registry, metadata from edge_mining.domain.common import EntityId, Watts -from edge_mining.domain.miner.common import MinerControllerAdapter -from edge_mining.domain.miner.entities import Miner, MinerController +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError -from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature from edge_mining.shared.adapter_maps.miner import MINER_CONTROLLER_CONFIG_TYPE_MAP from edge_mining.shared.interfaces.config import MinerControllerConfig @@ -164,48 +164,38 @@ def _restore_miner_controller_composites(mapper, connection, target: Any) -> Non Column("hash_rate_max", String, nullable=True), # Power Consumption Max (Watts Value Object stored as float) Column("power_consumption_max", Float, nullable=True), - # Foreign Key to MinerController - Column("controller_id", String, ForeignKey("miner_controllers.id"), nullable=True), ) -# Map MinerController first (parent in the relationship) +# Define the miner_features table (feature-based architecture) +miner_features_table = Table( + "miner_features", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("miner_id", String, ForeignKey("miners.id", ondelete="CASCADE"), nullable=False, index=True), + Column("controller_id", String, ForeignKey("miner_controllers.id", ondelete="CASCADE"), nullable=False, index=True), + Column("feature_type", String, nullable=False), + Column("priority", Integer, nullable=False, default=50), + Column("enabled", Boolean, nullable=False, default=True), +) + +# Map MinerController (no relationship to Miner — features bridge them now) mapper_registry.map_imperatively( MinerController, miner_controllers_table, - properties={ - "miners": relationship( - "Miner", - back_populates="controller", - lazy="select", - ) - }, ) -# Map Miner (child in the relationship) - use event listeners for value object conversions +# Map Miner — features are loaded/saved by the repository, not by ORM relationship mapper_registry.map_imperatively( Miner, miners_table, - properties={ - # Relationship to controller - "controller": relationship( - "MinerController", - foreign_keys=[miners_table.c.controller_id], - lazy="joined", - ), - }, - # Don't exclude properties - let SQLAlchemy load them, then convert in event listener + exclude_properties=["features"], ) # Event listeners for value object conversions @event.listens_for(Miner, "load") def _receive_miner_load(target: Miner, context) -> None: - """Event listener that reconstructs value objects after loading. - - Args: - target: The Miner instance being loaded - context: SQLAlchemy context - """ + """Event listener that reconstructs value objects after loading.""" # Reconstruct hash_rate_max (HashRate) from JSON string if hasattr(target, "hash_rate_max") and target.hash_rate_max: if isinstance(target.hash_rate_max, str): @@ -222,6 +212,10 @@ def _receive_miner_load(target: Miner, context) -> None: if not isinstance(target.power_consumption_max, type(Watts(0.0))): target.power_consumption_max = Watts(float(target.power_consumption_max)) + # Initialize features as empty list — repository will populate it + if not hasattr(target, "features") or target.features is None: + object.__setattr__(target, "features", []) + @event.listens_for(Miner, "before_insert") @event.listens_for(Miner, "before_update") @@ -247,23 +241,12 @@ def _flatten_miner_value_objects(mapper, connection, target: Miner) -> None: @event.listens_for(Miner, "after_insert") @event.listens_for(Miner, "after_update") def _restore_miner_composites(mapper, connection, target: Any) -> None: - """Event listener that restores value objects after persisting. - - Args: - mapper: SQLAlchemy mapper - connection: Database connection - target: The Miner instance that was persisted - """ + """Event listener that restores value objects after persisting.""" # Restore id to EntityId if it was converted to string if hasattr(target, "id") and target.id is not None: if isinstance(target.id, str): target.id = EntityId(uuid.UUID(target.id)) - # Restore controller_id to EntityId if needed - if hasattr(target, "controller_id") and target.controller_id is not None: - if isinstance(target.controller_id, str): - target.controller_id = EntityId(uuid.UUID(target.controller_id)) - # Restore hash_rate_max from JSON string if hasattr(target, "hash_rate_max") and target.hash_rate_max is not None: if isinstance(target.hash_rate_max, str): @@ -279,3 +262,50 @@ def _restore_miner_composites(mapper, connection, target: Any) -> None: if hasattr(target, "power_consumption_max") and target.power_consumption_max is not None: if not isinstance(target.power_consumption_max, type(Watts(0.0))): target.power_consumption_max = Watts(float(target.power_consumption_max)) + + +# --- Helper functions for feature persistence (used by repositories) --- + + +def load_features_for_miner(session, miner_id: EntityId) -> list[MinerFeature]: + """Load MinerFeature VOs from the miner_features table for a given miner.""" + from sqlalchemy import select + + stmt = select(miner_features_table).where(miner_features_table.c.miner_id == str(miner_id)) + rows = session.execute(stmt).fetchall() + features = [] + for row in rows: + features.append( + MinerFeature( + feature_type=MinerFeatureType(row.feature_type), + controller_id=EntityId(uuid.UUID(row.controller_id)), + priority=row.priority, + enabled=bool(row.enabled), + ) + ) + return features + + +def save_features_for_miner(session, miner_id: EntityId, features: list[MinerFeature]) -> None: + """Persist MinerFeature VOs to the miner_features table for a given miner. + + Replaces all existing features for the miner (delete + re-insert). + """ + # Delete existing features + session.execute(miner_features_table.delete().where(miner_features_table.c.miner_id == str(miner_id))) + # Insert new features + for f in features: + session.execute( + miner_features_table.insert().values( + miner_id=str(miner_id), + controller_id=str(f.controller_id), + feature_type=f.feature_type.value, + priority=f.priority, + enabled=f.enabled, + ) + ) + + +def delete_features_for_miner(session, miner_id: EntityId) -> None: + """Delete all features for a given miner.""" + session.execute(miner_features_table.delete().where(miner_features_table.c.miner_id == str(miner_id))) From 00db1887bb5bad66d4f765e56b640e7081409044 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 00:10:05 +0200 Subject: [PATCH 08/33] feat: update miner controllers to implement multiple feature ports for enhanced functionality --- .../domain/miner/controllers/dummy.py | 236 +++++++++++++--- .../generic_socket_home_assistant_api.py | 65 ++--- .../domain/miner/controllers/pyasic.py | 264 +++++++++++++++--- 3 files changed, 469 insertions(+), 96 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index 09ed516..62a05c4 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -1,17 +1,54 @@ -"""Dummy adapter (Implementation of Port) that simulates a miner control for Edge Mining Application""" +"""Dummy adapter (Implementation of Feature Ports) that simulates a miner for Edge Mining Application""" import random from typing import Optional from edge_mining.domain.common import Watts from edge_mining.domain.miner.common import MinerStatus -from edge_mining.domain.miner.ports import MinerControlPort -from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.miner.ports import ( + BoardTemperatureMonitorPort, + ChipTemperatureMonitorPort, + ExternalFanControlPort, + ExternalFanSpeedMonitorPort, + FrequencyMonitorPort, + HashrateMonitorPort, + InletTemperatureMonitorPort, + InternalFanControlPort, + InternalFanSpeedMonitorPort, + MiningControlPort, + ModelDetectionPort, + OutletTemperatureMonitorPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, + VoltageMonitorPort, +) +from edge_mining.domain.miner.value_objects import FanSpeed, Frequency, HashRate, Temperature, Voltage from edge_mining.shared.logging.port import LoggerPort -class DummyMinerController(MinerControlPort): - """Simulates miner control without real hardware.""" +class DummyMinerController( + HashrateMonitorPort, + PowerMonitorPort, + StatusMonitorPort, + ChipTemperatureMonitorPort, + BoardTemperatureMonitorPort, + InletTemperatureMonitorPort, + OutletTemperatureMonitorPort, + InternalFanSpeedMonitorPort, + ExternalFanSpeedMonitorPort, + VoltageMonitorPort, + FrequencyMonitorPort, + MiningControlPort, + PowerControlPort, + InternalFanControlPort, + ExternalFanControlPort, + ModelDetectionPort, +): + """Simulates miner control without real hardware. + + Implements all 16 feature ports for testing and development. + """ def __init__( self, @@ -26,30 +63,30 @@ def __init__( self.logger = logger self._power: Watts = Watts(0.0) + self._internal_fan_speed: float = 0.0 + self._external_fan_speed: float = 0.0 + + # --- ModelDetectionPort --- def get_model(self) -> Optional[str]: """Gets the model of the miner.""" - if self.logger: - self.logger.debug("Retrieving miner model for Dummy Miner Controller is not supported...") + self.logger.debug("DummyController: Returning dummy model") + return "Dummy Miner S0" - return None + # --- MiningControlPort --- - def start_miner(self) -> bool: + def start_mining(self) -> bool: """Start the miner.""" - print(f"DummyController: Received START (current: {self._status.name})") + if self.logger: + self.logger.debug(f"DummyController: Received START (current: {self._status.name})") if self._status != MinerStatus.ON: self._status = MinerStatus.STARTING - # Simulate startup time - # In a real scenario, this would just send the command - # The status check next cycle would confirm if it's ON if self.logger: self.logger.debug("DummyController: Setting status to STARTING") - # Simulate transition after a delay for testing purposes if needed - # threading.Timer(5, self._set_status, args=(MinerStatus.ON)).start() - return True # Assume command sent successfully + return True - def stop_miner(self) -> bool: + def stop_mining(self) -> bool: """Stop the miner.""" if self.logger: self.logger.debug(f"DummyController: Received STOP (current: {self._status.name})") @@ -57,15 +94,14 @@ def stop_miner(self) -> bool: self._status = MinerStatus.STOPPING if self.logger: self.logger.debug("DummyController: Setting status to STOPPING") - # Simulate transition - # threading.Timer(3, self._set_status, args=(MinerStatus.OFF).start() - return True # Assume command sent successfully + return True - def get_miner_status(self) -> MinerStatus: + # --- StatusMonitorPort --- + + def get_status(self) -> MinerStatus: """Get the status of the miner.""" - # Simulate state transitions finishing for dummy purposes if self._status == MinerStatus.STARTING: - if random.random() < 0.8: # 80% chance it finished starting + if random.random() < 0.8: if self.logger: self.logger.debug("DummyController: Simulating finished starting -> ON") self._status = MinerStatus.ON @@ -74,7 +110,7 @@ def get_miner_status(self) -> MinerStatus: self.logger.debug("DummyController: Simulating still STARTING") elif self._status == MinerStatus.STOPPING: - if random.random() < 0.9: # 90% chance it finished stopping + if random.random() < 0.9: if self.logger: self.logger.debug("DummyController: Simulating finished stopping -> OFF") self._status = MinerStatus.OFF @@ -87,7 +123,9 @@ def get_miner_status(self) -> MinerStatus: self.logger.debug(f"DummyController: Reporting status {status.name}") return status - def get_miner_power(self) -> Optional[Watts]: + # --- PowerMonitorPort --- + + def get_power(self) -> Optional[Watts]: """Get the power of the miner.""" status = self._status if status == MinerStatus.ON: @@ -95,7 +133,7 @@ def get_miner_power(self) -> Optional[Watts]: if self.logger: self.logger.debug(f"DummyController: Reporting power {power:.0f}W") elif status == MinerStatus.STARTING: - power = Watts(random.uniform(10, 200)) # Lower power during startup + power = Watts(random.uniform(10, 200)) if self.logger: self.logger.debug(f"DummyController: Reporting power {power:.0f}W") else: @@ -106,11 +144,12 @@ def get_miner_power(self) -> Optional[Watts]: self._power = power return power - def get_miner_hashrate(self) -> Optional[HashRate]: + # --- HashrateMonitorPort --- + + def get_hashrate(self) -> Optional[HashRate]: """Get the hash rate of the miner.""" status = self._status if status == MinerStatus.ON: - # Simulate hash rate hash_rate = HashRate( value=random.uniform(0, self._hashrate_max.value), unit=self._hashrate_max.unit, @@ -123,7 +162,140 @@ def get_miner_hashrate(self) -> Optional[HashRate]: self.logger.debug(f"DummyController: Reporting hash rate 0 (status: {status.name})") return HashRate(value=0.0, unit="TH/s") - # Helper for simulated transitions (if using timers) - # def _set_status(self, status: MinerStatus): - # print(f"DummyController: Timer finished, setting to {status.name}") - # self._status = status + # --- ChipTemperatureMonitorPort --- + + def get_chip_temperature(self) -> Optional[Temperature]: + """Get simulated chip temperature.""" + if self._status == MinerStatus.ON: + temp = Temperature(value=round(random.uniform(55.0, 85.0), 1)) + elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): + temp = Temperature(value=round(random.uniform(30.0, 55.0), 1)) + else: + temp = Temperature(value=round(random.uniform(20.0, 30.0), 1)) + if self.logger: + self.logger.debug(f"DummyController: Reporting chip temperature {temp.value}{temp.unit}") + return temp + + # --- BoardTemperatureMonitorPort --- + + def get_board_temperature(self) -> Optional[Temperature]: + """Get simulated board temperature.""" + if self._status == MinerStatus.ON: + temp = Temperature(value=round(random.uniform(45.0, 70.0), 1)) + elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): + temp = Temperature(value=round(random.uniform(25.0, 45.0), 1)) + else: + temp = Temperature(value=round(random.uniform(18.0, 28.0), 1)) + if self.logger: + self.logger.debug(f"DummyController: Reporting board temperature {temp.value}{temp.unit}") + return temp + + # --- InletTemperatureMonitorPort --- + + def get_inlet_temperature(self) -> Optional[Temperature]: + """Get simulated inlet air temperature.""" + temp = Temperature(value=round(random.uniform(18.0, 35.0), 1)) + if self.logger: + self.logger.debug(f"DummyController: Reporting inlet temperature {temp.value}{temp.unit}") + return temp + + # --- OutletTemperatureMonitorPort --- + + def get_outlet_temperature(self) -> Optional[Temperature]: + """Get simulated outlet air temperature.""" + if self._status == MinerStatus.ON: + temp = Temperature(value=round(random.uniform(40.0, 65.0), 1)) + else: + temp = Temperature(value=round(random.uniform(18.0, 30.0), 1)) + if self.logger: + self.logger.debug(f"DummyController: Reporting outlet temperature {temp.value}{temp.unit}") + return temp + + # --- InternalFanSpeedMonitorPort --- + + def get_internal_fan_speed(self) -> Optional[FanSpeed]: + """Get simulated internal fan speed.""" + if self._status == MinerStatus.ON: + rpm = random.uniform(3000.0, 6000.0) + elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): + rpm = random.uniform(1000.0, 3000.0) + else: + rpm = 0.0 + fan = FanSpeed(value=round(rpm, 0)) + if self.logger: + self.logger.debug(f"DummyController: Reporting internal fan speed {fan.value} {fan.unit}") + return fan + + # --- ExternalFanSpeedMonitorPort --- + + def get_external_fan_speed(self) -> Optional[FanSpeed]: + """Get simulated external fan speed.""" + if self._external_fan_speed > 0: + rpm = self._external_fan_speed * 60.0 # percent to RPM approximation + else: + rpm = 0.0 + fan = FanSpeed(value=round(rpm, 0)) + if self.logger: + self.logger.debug(f"DummyController: Reporting external fan speed {fan.value} {fan.unit}") + return fan + + # --- VoltageMonitorPort --- + + def get_voltage(self) -> Optional[Voltage]: + """Get simulated voltage.""" + if self._status == MinerStatus.ON: + v = Voltage(value=round(random.uniform(11.8, 12.6), 2)) + elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): + v = Voltage(value=round(random.uniform(11.0, 12.0), 2)) + else: + v = Voltage(value=0.0) + if self.logger: + self.logger.debug(f"DummyController: Reporting voltage {v.value}{v.unit}") + return v + + # --- FrequencyMonitorPort --- + + def get_frequency(self) -> Optional[Frequency]: + """Get simulated chip frequency.""" + if self._status == MinerStatus.ON: + f = Frequency(value=round(random.uniform(400.0, 650.0), 1)) + else: + f = Frequency(value=0.0) + if self.logger: + self.logger.debug(f"DummyController: Reporting frequency {f.value} {f.unit}") + return f + + # --- PowerControlPort --- + + def power_on(self) -> bool: + """Simulate hard power on.""" + if self.logger: + self.logger.debug(f"DummyController: Received POWER ON (current: {self._status.name})") + if self._status in (MinerStatus.OFF, MinerStatus.UNKNOWN): + self._status = MinerStatus.STARTING + return True + + def power_off(self) -> bool: + """Simulate hard power off.""" + if self.logger: + self.logger.debug(f"DummyController: Received POWER OFF (current: {self._status.name})") + self._status = MinerStatus.OFF + return True + + # --- InternalFanControlPort --- + + def set_internal_fan_speed(self, speed_percent: float) -> bool: + """Simulate setting internal fan speed.""" + if self.logger: + self.logger.debug(f"DummyController: Setting internal fan speed to {speed_percent:.0f}%") + self._internal_fan_speed = max(0.0, min(100.0, speed_percent)) + return True + + # --- ExternalFanControlPort --- + + def set_external_fan_speed(self, speed_percent: float) -> bool: + """Simulate setting external fan speed.""" + if self.logger: + self.logger.debug(f"DummyController: Setting external fan speed to {speed_percent:.0f}%") + self._external_fan_speed = max(0.0, min(100.0, speed_percent)) + return True diff --git a/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py b/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py index 54cc453..01f256f 100644 --- a/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py +++ b/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py @@ -1,6 +1,6 @@ """ -Generic socket Home Assistant API adapter (Implementation of Port) -that controls a miner via Home Assistant's entities of a smart socket. +Generic socket Home Assistant API adapter (Implementation of Feature Ports) +that controls a miner via Home Assistant's entities of a smart socket. """ from typing import Dict, Optional, cast @@ -10,10 +10,13 @@ ) 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.aggregate_roots import Miner from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError, MinerControllerError -from edge_mining.domain.miner.ports import MinerControlPort -from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.miner.ports import ( + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) from edge_mining.shared.adapter_configs.miner import MinerControllerGenericSocketHomeAssistantAPIConfig from edge_mining.shared.external_services.common import ExternalServiceAdapter from edge_mining.shared.external_services.ports import ExternalServicePort @@ -40,7 +43,7 @@ def create( config: Optional[Configuration], logger: Optional[LoggerPort], external_service: Optional[ExternalServicePort], - ) -> MinerControlPort: + ) -> "GenericSocketHomeAssistantAPIMinerController": """Create an miner controller adapter instance.""" # Needs to have the Home Assistant API service as external_service @@ -71,8 +74,15 @@ def create( ) -class GenericSocketHomeAssistantAPIMinerController(MinerControlPort): - """Controls a miner via Home Assistant's entities of a smart socket.""" +class GenericSocketHomeAssistantAPIMinerController( + PowerMonitorPort, + StatusMonitorPort, + PowerControlPort, +): + """Controls a miner via Home Assistant's entities of a smart socket. + + Implements: PowerMonitorPort, StatusMonitorPort, PowerControlPort. + """ def __init__( self, @@ -98,22 +108,9 @@ def _log_configuration(self): f"Entities Configured: Switch={self.entity_switch}, Power={self.entity_power}, Unit={self.unit_power}" ) - def get_model(self) -> Optional[str]: - """Gets the model of the miner.""" - - if self.logger: - self.logger.debug("Retrieving miner model for Home Assistant Generic Socket is not supported...") + # --- PowerMonitorPort --- - return None - - def get_miner_hashrate(self) -> Optional[HashRate]: - """ - Gets the current hash rate, if available. - This implementation does not provides hash rate information. - """ - return None - - def get_miner_power(self) -> Optional[Watts]: + def get_power(self) -> Optional[Watts]: """Gets the current power consumption, if available.""" if self.logger: self.logger.debug("Fetching power consumption from Home Assistant...") @@ -130,7 +127,9 @@ def get_miner_power(self) -> Optional[Watts]: return power_watts - def get_miner_status(self) -> MinerStatus: + # --- StatusMonitorPort --- + + def get_status(self) -> MinerStatus: """Gets the current operational status of the miner.""" if self.logger: self.logger.debug("Fetching miner status from Home Assistant...") @@ -151,10 +150,12 @@ def get_miner_status(self) -> MinerStatus: return miner_status - def stop_miner(self) -> bool: - """Attempts to stop the specified miner. Returns True on success request.""" + # --- PowerControlPort --- + + def power_off(self) -> bool: + """Attempts to power off the miner via smart plug. Returns True on success.""" if self.logger: - self.logger.debug("Sending stop command to miner via Home Assistant...") + self.logger.debug("Sending power off command to miner via Home Assistant...") success = self.home_assistant.set_entity_state( self.entity_switch, @@ -162,14 +163,14 @@ def stop_miner(self) -> bool: ) if self.logger: - self.logger.debug(f"Stop command sent. Success: {success}") + self.logger.debug(f"Power off command sent. Success: {success}") return success - def start_miner(self) -> bool: - """Attempts to start the miner. Returns True on success request.""" + def power_on(self) -> bool: + """Attempts to power on the miner via smart plug. Returns True on success.""" if self.logger: - self.logger.debug("Sending start command to miner via Home Assistant...") + self.logger.debug("Sending power on command to miner via Home Assistant...") success = self.home_assistant.set_entity_state( self.entity_switch, @@ -177,6 +178,6 @@ def start_miner(self) -> bool: ) if self.logger: - self.logger.debug(f"Start command sent. Success: {success}") + self.logger.debug(f"Power on command sent. Success: {success}") return success diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 5618b18..bcd0723 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -1,5 +1,5 @@ """ -pyasic adapter (Implementation of Port) +pyasic adapter (Implementation of Feature Ports) that controls a miner via pyasic. """ @@ -15,10 +15,30 @@ from edge_mining.adapters.utils import run_async_func from edge_mining.domain.common import Watts from edge_mining.domain.miner.common import MinerControllerProtocol, MinerStatus -from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.aggregate_roots 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.domain.miner.ports import ( + BoardTemperatureMonitorPort, + ChipTemperatureMonitorPort, + FrequencyMonitorPort, + HashrateMonitorPort, + InletTemperatureMonitorPort, + InternalFanControlPort, + InternalFanSpeedMonitorPort, + MiningControlPort, + ModelDetectionPort, + OutletTemperatureMonitorPort, + PowerMonitorPort, + StatusMonitorPort, + VoltageMonitorPort, +) +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashRate, + Temperature, + Voltage, +) 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 @@ -44,7 +64,7 @@ def create( config: Optional[Configuration] = None, logger: Optional[LoggerPort] = None, external_service: Optional[ExternalServicePort] = None, - ) -> MinerControlPort: + ) -> "PyASICMinerController": """Create a miner controller adapter instance.""" if not isinstance(config, MinerControllerPyASICConfig): @@ -63,8 +83,22 @@ def create( ) -class PyASICMinerController(MinerControlPort): - """Controls a miner via pyasic.""" +class PyASICMinerController( + HashrateMonitorPort, + PowerMonitorPort, + StatusMonitorPort, + ChipTemperatureMonitorPort, + BoardTemperatureMonitorPort, + InletTemperatureMonitorPort, + OutletTemperatureMonitorPort, + InternalFanSpeedMonitorPort, + VoltageMonitorPort, + FrequencyMonitorPort, + MiningControlPort, + InternalFanControlPort, + ModelDetectionPort, +): + """Controls a miner via pyasic. Implements multiple feature ports.""" def __init__( self, @@ -141,13 +175,14 @@ def _get_miner(self) -> None: if self.logger: self.logger.error(f"Failed to retrieve miner instance from {self.ip}: {e}") + # --- ModelDetectionPort --- + def get_model(self) -> Optional[str]: """Gets the model of the miner.""" if self.logger: self.logger.debug(f"Fetching model from {self.ip}...") - # Get pyasic miner instance self._get_miner() if not self._miner: @@ -157,15 +192,14 @@ def get_model(self) -> Optional[str]: return self._miner.model or None - def get_miner_hashrate(self) -> Optional[HashRate]: - """ - Gets the current hash rate, if available. - """ + # --- HashrateMonitorPort --- + + def get_hashrate(self) -> Optional[HashRate]: + """Gets the current hash rate, if available.""" if self.logger: - self.logger.debug(f"Fetching hashrate from from {self.ip}...") + self.logger.debug(f"Fetching hashrate from {self.ip}...") - # Get pyasic miner instance self._get_miner() if not self._miner: @@ -190,12 +224,13 @@ def get_miner_hashrate(self) -> Optional[HashRate]: return real_hashrate - def get_miner_power(self) -> Optional[Watts]: + # --- PowerMonitorPort --- + + def get_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}...") + self.logger.debug(f"Fetching power consumption from {self.ip}...") - # Get pyasic miner instance self._get_miner() if not self._miner: @@ -216,12 +251,13 @@ def get_miner_power(self) -> Optional[Watts]: return power_watts - def get_miner_status(self) -> MinerStatus: + # --- StatusMonitorPort --- + + def get_status(self) -> MinerStatus: """Gets the current operational status of the miner.""" if self.logger: self.logger.debug(f"Fetching miner status from {self.ip}...") - # Get pyasic miner instance self._get_miner() if not self._miner: @@ -253,33 +289,150 @@ def get_miner_status(self) -> MinerStatus: return miner_status - def stop_miner(self) -> bool: - """Attempts to stop the specified miner. Returns True on success request.""" + # --- ChipTemperatureMonitorPort --- + + def get_chip_temperature(self) -> Optional[Temperature]: + """Gets the current chip temperature, if available.""" if self.logger: - self.logger.debug(f"Sending stop command to miner at {self.ip}...") + self.logger.debug(f"Fetching chip temperature from {self.ip}...") - # Get pyasic miner instance self._get_miner() if not self._miner: if self.logger: self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") - return False + return None miner = self._miner - success = run_async_func(miner.stop_mining()) + data = run_async_func(miner.get_data()) + if data is None or data.temperature_avg is None: + return None + return Temperature(value=float(data.temperature_avg)) + + # --- BoardTemperatureMonitorPort --- + + def get_board_temperature(self) -> Optional[Temperature]: + """Gets the current board temperature, if available.""" if self.logger: - self.logger.debug(f"Stop command sent. Success: {success}") + self.logger.debug(f"Fetching board temperature from {self.ip}...") - return success or False + self._get_miner() + + if not self._miner: + return None + + miner = self._miner + data = run_async_func(miner.get_data()) + if data is None or not data.hashboards: + return None + + # Average board temperature across all hashboards + temps = [hb.temp for hb in data.hashboards if hb.temp is not None] + if not temps: + return None + + return Temperature(value=round(sum(temps) / len(temps), 1)) + + # --- InletTemperatureMonitorPort --- + + def get_inlet_temperature(self) -> Optional[Temperature]: + """Gets the current inlet air temperature, if available.""" + if self.logger: + self.logger.debug(f"Fetching inlet temperature from {self.ip}...") + + self._get_miner() + + if not self._miner: + return None + + miner = self._miner + data = run_async_func(miner.get_data()) + if data is None or data.env_temp is None: + return None + + return Temperature(value=float(data.env_temp)) + + # --- OutletTemperatureMonitorPort --- + + def get_outlet_temperature(self) -> Optional[Temperature]: + """Gets the current outlet air temperature, if available.""" + if self.logger: + self.logger.debug(f"Fetching outlet temperature from {self.ip}...") + + # pyasic does not typically provide separate outlet temperature + # Some miners expose this through env_temp or specific board data + return None + + # --- InternalFanSpeedMonitorPort --- + + def get_internal_fan_speed(self) -> Optional[FanSpeed]: + """Gets the current internal fan speed, if available.""" + if self.logger: + self.logger.debug(f"Fetching internal fan speed from {self.ip}...") + + self._get_miner() + + if not self._miner: + return None + + miner = self._miner + data = run_async_func(miner.get_data()) + if data is None or not data.fans: + return None + + # Average fan speed across all fans + speeds = [fan.speed for fan in data.fans if fan.speed is not None and fan.speed > 0] + if not speeds: + return None + + return FanSpeed(value=round(sum(speeds) / len(speeds), 0)) + + # --- VoltageMonitorPort --- - def start_miner(self) -> bool: - """Attempts to start the miner. Returns True on success request.""" + def get_voltage(self) -> Optional[Voltage]: + """Gets the current voltage, if available.""" + if self.logger: + self.logger.debug(f"Fetching voltage from {self.ip}...") + + self._get_miner() + + if not self._miner: + return None + + miner = self._miner + data = run_async_func(miner.get_data()) + if data is None or data.voltage is None: + return None + + return Voltage(value=float(data.voltage)) + + # --- FrequencyMonitorPort --- + + def get_frequency(self) -> Optional[Frequency]: + """Gets the current chip operating frequency, if available.""" + if self.logger: + self.logger.debug(f"Fetching frequency from {self.ip}...") + + self._get_miner() + + if not self._miner: + return None + + miner = self._miner + data = run_async_func(miner.get_data()) + if data is None or data.frequency_avg is None: + return None + + return Frequency(value=float(data.frequency_avg)) + + # --- MiningControlPort --- + + def start_mining(self) -> bool: + """Attempts to start mining. Returns True on success.""" if self.logger: self.logger.debug(f"Sending start command to miner at {self.ip}...") - # Get pyasic miner instance self._get_miner() if not self._miner: @@ -295,6 +448,53 @@ def start_miner(self) -> bool: return success or False + def stop_mining(self) -> bool: + """Attempts to stop mining. Returns True on success.""" + if self.logger: + self.logger.debug(f"Sending stop command to miner at {self.ip}...") + + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return False + + miner = self._miner + success = run_async_func(miner.stop_mining()) + + if self.logger: + self.logger.debug(f"Stop command sent. Success: {success}") + + return success or False + + # --- InternalFanControlPort --- + + def set_internal_fan_speed(self, speed_percent: float) -> bool: + """Sets internal fan speed as a percentage (0-100). Returns True on success.""" + if self.logger: + self.logger.debug(f"Setting internal fan speed to {speed_percent}% on {self.ip}...") + + self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return False + + miner = self._miner + try: + success = run_async_func(miner.set_fan_speed(int(speed_percent))) + if self.logger: + self.logger.debug(f"Fan speed set. Success: {success}") + return success or False + except Exception as e: + if self.logger: + self.logger.error(f"Failed to set fan speed on {self.ip}: {e}") + return False + + # --- Private helpers --- + def _derive_miner_status(self) -> Optional[bool]: """Derives the miner status based on hashrate and power consumption. @@ -306,8 +506,8 @@ def _derive_miner_status(self) -> Optional[bool]: """ IDLE_WATTAGE_THRESHOLD = 1 # Low threshold to work with low-power miners (e.g., Bitaxe ~13W) - hashrate: Optional[HashRate] = self.get_miner_hashrate() - wattage: Optional[Watts] = self.get_miner_power() + hashrate: Optional[HashRate] = self.get_hashrate() + wattage: Optional[Watts] = self.get_power() if self.logger: self.logger.debug( From 30741bf56d0f355fc9a5f53efaf20d1deea9e5c5 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 00:11:41 +0200 Subject: [PATCH 09/33] feat: enhance miner controller management by adding unlink API functionality and refining schemas for features --- .../adapters/domain/miner/fast_api/router.py | 37 +++++- edge_mining/adapters/domain/miner/schemas.py | 108 ++++++++++-------- 2 files changed, 92 insertions(+), 53 deletions(-) diff --git a/edge_mining/adapters/domain/miner/fast_api/router.py b/edge_mining/adapters/domain/miner/fast_api/router.py index ef97fc7..046456b 100644 --- a/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/edge_mining/adapters/domain/miner/fast_api/router.py @@ -29,7 +29,7 @@ ) from edge_mining.domain.common import EntityId, Watts from edge_mining.domain.miner.common import MinerControllerAdapter -from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.miner.exceptions import ( MinerControllerAlreadyExistsError, MinerControllerConfigurationError, @@ -99,7 +99,6 @@ async def add_miner( model=miner_to_add.model, hash_rate_max=miner_to_add.hash_rate_max, power_consumption_max=miner_to_add.power_consumption_max, - controller_id=miner_to_add.controller_id, ) response = MinerSchema.from_model(new_miner) @@ -133,7 +132,6 @@ async def update_miner( model=miner_update.model, hash_rate_max=hash_rate_max, power_consumption_max=power_consumption_max, - controller_id=EntityId(uuid.UUID(miner_update.controller_id)), active=miner.active, ) @@ -336,15 +334,16 @@ async def set_miner_controller( controller_id: EntityId, config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], ) -> MinerSchema: - """Set the controller for a miner.""" + """Associate a controller to a miner, auto-creating features for all supported feature types.""" try: + await config_service.set_miner_controller(controller_id, miner_id) + + # Re-read the miner to get updated features miner = config_service.get_miner(miner_id) if miner is None: raise MinerNotFoundError(f"Miner with ID {miner_id} not found") - await config_service.set_miner_controller(miner_id, controller_id) - response = MinerSchema.from_model(miner) return response @@ -352,6 +351,32 @@ async def set_miner_controller( raise HTTPException(status_code=404, detail="Miner not found") from e except MinerControllerNotFoundError as e: raise HTTPException(status_code=404, detail="Miner controller not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/unlink-controller", response_model=MinerSchema) +async def unlink_controller_from_miner( + miner_id: EntityId, + controller_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Remove all features provided by a controller from a miner.""" + try: + await config_service.unlink_controller_from_miner(controller_id, miner_id) + + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + response = MinerSchema.from_model(miner) + + return response + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index d4b65cb..93d440b 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -7,9 +7,15 @@ 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, MinerControllerProtocol, MinerStatus -from edge_mining.domain.miner.entities import Miner, MinerController -from edge_mining.domain.miner.value_objects import HashRate, MinerStateSnapshot +from edge_mining.domain.miner.common import ( + MinerControllerAdapter, + MinerControllerProtocol, + MinerFeatureType, + MinerStatus, +) +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerStateSnapshot from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, @@ -47,6 +53,53 @@ def to_model(self) -> HashRate: return HashRate(value=self.value, unit=self.unit) +class MinerFeatureSchema(BaseModel): + """Schema for MinerFeature value object.""" + + feature_type: str = Field(..., description="Feature type") + controller_id: str = Field(..., description="ID of the controller providing this feature") + priority: int = Field(default=50, ge=1, le=100, description="Priority (1-100, higher wins)") + enabled: bool = Field(default=True, description="Whether this feature is enabled") + + @field_validator("controller_id") + @classmethod + def validate_controller_id(cls, v: str) -> str: + """Validate that controller_id is a valid UUID string.""" + try: + uuid.UUID(v) + except ValueError as exc: + raise ValueError("controller_id must be a valid UUID string") from exc + return v + + @field_validator("feature_type") + @classmethod + def validate_feature_type(cls, v: str) -> str: + """Validate that feature_type is a recognized MinerFeatureType.""" + valid_types = [ft.value for ft in MinerFeatureType] + if v not in valid_types: + raise ValueError(f"feature_type must be one of {valid_types}") + return v + + @classmethod + def from_model(cls, feature: MinerFeature) -> "MinerFeatureSchema": + """Create MinerFeatureSchema from a MinerFeature value object.""" + return cls( + feature_type=feature.feature_type.value, + controller_id=str(feature.controller_id), + priority=feature.priority, + enabled=feature.enabled, + ) + + def to_model(self) -> MinerFeature: + """Convert MinerFeatureSchema to MinerFeature value object.""" + return MinerFeature( + feature_type=MinerFeatureType(self.feature_type), + controller_id=EntityId(uuid.UUID(self.controller_id)), + priority=self.priority, + enabled=self.enabled, + ) + + class MinerSchema(BaseModel): """Schema for Miner entity with complete validation.""" @@ -56,7 +109,8 @@ class MinerSchema(BaseModel): hash_rate_max: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") power_consumption_max: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") active: bool = Field(default=True, description="Whether the miner is active in the system") - controller_id: Optional[str] = Field(default=None, description="ID of the associated Miner controller") + features: list[MinerFeatureSchema] = Field(default_factory=list, description="Features provided by controllers") + controller_ids: list[str] = Field(default_factory=list, description="IDs of associated controllers (computed)") @field_validator("id") @classmethod @@ -77,17 +131,6 @@ def validate_name(cls, v: str) -> str: v = "" return v - @field_validator("controller_id") - @classmethod - def validate_controller_id(cls, v: Optional[str]) -> Optional[str]: - """Validate that controller_id is a valid UUID string if provided.""" - if v is not None: - try: - uuid.UUID(v) - except ValueError as exc: - raise ValueError("controller_id must be a valid UUID string") from exc - return v - @field_validator("power_consumption_max") @classmethod def validate_power_max(cls, v: Optional[float]) -> Optional[float]: @@ -110,7 +153,8 @@ def from_model(cls, miner: Miner) -> "MinerSchema": hash_rate_max=hash_rate_max, power_consumption_max=miner.power_consumption_max, active=miner.active, - controller_id=str(miner.controller_id) if miner.controller_id else None, + features=[MinerFeatureSchema.from_model(f) for f in miner.features], + controller_ids=[str(cid) for cid in miner.get_controller_ids()], ) @field_serializer("id") @@ -118,11 +162,6 @@ def serialize_id(self, value: str) -> str: """Serialize id field.""" return str(value) - @field_serializer("controller_id") - def serialize_controller_id(self, value: Optional[str]) -> Optional[str]: - """Serialize controller_id field.""" - return str(value) if value is not None else None - def to_model(self) -> Miner: """Convert MinerSchema back to Miner domain model instance.""" return Miner( @@ -134,7 +173,7 @@ def to_model(self) -> Miner: ), power_consumption_max=Watts(self.power_consumption_max) if self.power_consumption_max is not None else None, active=self.active, - controller_id=EntityId(uuid.UUID(self.controller_id)) if self.controller_id else None, + features=[f.to_model() for f in self.features], ) class Config: @@ -194,18 +233,6 @@ class MinerCreateSchema(BaseModel): model: Optional[str] = Field(default=None, description="Miner model/hardware identifier") hash_rate_max: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") power_consumption_max: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") - controller_id: Optional[str] = Field(default=None, description="ID of the associated controller") - - @field_validator("controller_id") - @classmethod - def validate_controller_id(cls, v: Optional[str]) -> Optional[str]: - """Validate that controller_id is a valid UUID string if provided.""" - if v is not None: - try: - uuid.UUID(v) - except ValueError as exc: - raise ValueError("controller_id must be a valid UUID string") from exc - return v @field_validator("name") @classmethod @@ -227,7 +254,6 @@ def to_model(self) -> Miner: ), power_consumption_max=Watts(self.power_consumption_max) if self.power_consumption_max is not None else None, active=True, - controller_id=EntityId(uuid.UUID(self.controller_id)) if self.controller_id else None, ) class Config: @@ -248,18 +274,6 @@ class MinerUpdateSchema(BaseModel): hash_rate_max: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") power_consumption_max: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") active: Optional[bool] = Field(default=None, description="Whether the miner is active") - controller_id: Optional[str] = Field(default=None, description="ID of the associated Miner controller") - - @field_validator("controller_id") - @classmethod - def validate_controller_id(cls, v: Optional[str]) -> Optional[str]: - """Validate that controller_id is a valid UUID string if provided.""" - if v is not None: - try: - uuid.UUID(v) - except ValueError as exc: - raise ValueError("controller_id must be a valid UUID string") from exc - return v @field_validator("name") @classmethod From 427f427a669d97c5f877992e3204935a728d95ee Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 00:12:16 +0200 Subject: [PATCH 10/33] feat: refactor miner controller handling in CLI commands to support linking after creation and update --- .../adapters/domain/miner/cli/commands.py | 76 ++++++++++--------- .../domain/optimization_unit/cli/commands.py | 5 +- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/edge_mining/adapters/domain/miner/cli/commands.py b/edge_mining/adapters/domain/miner/cli/commands.py index 5a8bb68..8b06727 100644 --- a/edge_mining/adapters/domain/miner/cli/commands.py +++ b/edge_mining/adapters/domain/miner/cli/commands.py @@ -14,7 +14,8 @@ from edge_mining.application.interfaces import ConfigurationServiceInterface, MinerActionServiceInterface from edge_mining.domain.common import EntityId, Watts from edge_mining.domain.miner.common import MinerControllerAdapter, MinerControllerProtocol -from edge_mining.domain.miner.entities import Miner, MinerController +from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.value_objects import HashRate from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, @@ -41,16 +42,13 @@ def handle_add_miner(configuration_service: ConfigurationServiceInterface, logge new_miner.model = model if model else None new_miner.hash_rate_max = HashRate(value=hash_rate_max, unit=hash_rate_unit) new_miner.power_consumption_max = Watts(power_consumption_max) - new_miner.controller_id = None - # Select a Miner Controller + # Select a Miner Controller (will be linked after creation) miner_controller: Optional[MinerController] = None miner_controllers = configuration_service.list_miner_controllers() if miner_controllers: miner_controller = select_miner_controller(configuration_service, logger) - if miner_controller: - new_miner.controller_id = miner_controller.id else: click.echo("") click.echo(click.style("No Miner Controller configured.", fg="yellow")) @@ -72,11 +70,10 @@ def handle_add_miner(configuration_service: ConfigurationServiceInterface, logge click.style( f"Miner Controller '{miner_controller.name}', " f"Type: {miner_controller.adapter_type.name} " - f"(ID: {miner_controller.id}) successfully added to current miner.", + f"(ID: {miner_controller.id}) successfully added.", fg="green", ) ) - new_miner.controller_id = miner_controller.id else: click.echo( click.style( @@ -92,9 +89,13 @@ def handle_add_miner(configuration_service: ConfigurationServiceInterface, logge model=new_miner.model, hash_rate_max=new_miner.hash_rate_max, power_consumption_max=new_miner.power_consumption_max, - controller_id=new_miner.controller_id, ) ) + + # Link controller if selected + if miner_controller: + run_async_func(configuration_service.set_miner_controller(miner_controller.id, added.id)) + click.echo( click.style( f"Miner '{added.name}' (ID: {added.id}) successfully added.", @@ -278,12 +279,11 @@ def update_single_miner( ) # Select a Miner Controller - controller_id = selected_miner.controller_id miner_controllers = configuration_service.list_miner_controllers() if miner_controllers: miner_controller = select_miner_controller(configuration_service, logger) if miner_controller: - controller_id = miner_controller.id + click.echo(click.style(f"Controller '{miner_controller.name}' will be linked after update.", fg="yellow")) else: click.echo(click.style("Miner Controller will not be changed!", fg="yellow")) @@ -297,9 +297,15 @@ def update_single_miner( model=model if model else None, hash_rate_max=hash_rate_max, power_consumption_max=Watts(power_consumption), - controller_id=EntityId(controller_id) if controller_id else None, ) ) + + # If a new controller was selected, link it + if miner_controllers and miner_controller: + run_async_func(configuration_service.set_miner_controller(miner_controller.id, updated.id)) + # Re-read miner to get updated features + updated = configuration_service.get_miner(updated.id) or updated + click.echo( click.style( f"Miner '{updated.name}' (ID: {updated.id}) successfully updated.", @@ -363,19 +369,14 @@ def assign_controller_to_miner( return None try: - selected_miner.controller_id = controller.id + run_async_func(configuration_service.set_miner_controller(controller.id, selected_miner.id)) + + # Re-read miner to get updated features + updated_miner = configuration_service.get_miner(selected_miner.id) + if not updated_miner: + click.echo(click.style("Error: could not re-read miner after linking controller.", fg="red")) + return None - updated_miner = run_async_func( - configuration_service.update_miner( - miner_id=selected_miner.id, - name=selected_miner.name, - model=selected_miner.model, - hash_rate_max=selected_miner.hash_rate_max, - power_consumption_max=selected_miner.power_consumption_max, - controller_id=selected_miner.controller_id, - active=selected_miner.active, - ) - ) click.echo( click.style( f"Controller Miner '{controller.name}' successfully assigned " @@ -511,13 +512,15 @@ def print_miner_details( ) click.echo("| Max Power Consumption: " + str(miner.power_consumption_max) + " W") click.echo("| Active: " + click.style(miner.active, fg="green" if miner.active else "red")) - click.echo("| Controller ID: " + (str(miner.controller_id) if miner.controller_id else "None")) - if show_controller_details: - if miner.controller_id: - controller = configuration_service.get_miner_controller(miner.controller_id) + controller_ids = miner.get_controller_ids() + click.echo("| Controllers: " + (str(len(controller_ids)) if controller_ids else "None")) + + if show_controller_details and controller_ids: + for cid in controller_ids: + controller = configuration_service.get_miner_controller(cid) if controller: - click.echo("\nCONTROLLER DETAILS:") + click.echo(f"\nCONTROLLER DETAILS (ID: {cid}):") print_miner_controller_details( controller=controller, configuration_service=configuration_service, @@ -525,8 +528,13 @@ def print_miner_details( show_external_service=show_external_service, ) else: - # If the controller is not found, we can still show the ID - click.echo("| Controller ID: " + click.style(str(miner.controller_id), fg="red") + " (not found)") + click.echo("| Controller ID: " + click.style(str(cid), fg="red") + " (not found)") + + if miner.features: + click.echo("\nFEATURES:") + for f in miner.features: + status = click.style("enabled", fg="green") if f.enabled else click.style("disabled", fg="red") + click.echo(f" - {f.feature_type.value} (controller: {f.controller_id}, priority: {f.priority}, {status})") click.echo("") @@ -550,7 +558,7 @@ def manage_single_miner_menu( click.echo("5. Delete Miner") click.echo("") - if miner.controller_id: + if miner.get_controller_ids(): click.echo("6. Start Miner") click.echo("7. Stop Miner") click.echo("8. Get Miner Status") @@ -616,21 +624,21 @@ def manage_single_miner_menu( if delete_status: return "b" # Return to menu if deletion was successful - elif choice == "6" and miner.controller_id: + elif choice == "6" and miner.get_controller_ids(): miner = start_miner( miner=miner, configuration_service=configuration_service, miner_action_service=miner_action_service, ) continue - elif choice == "7" and miner.controller_id: + elif choice == "7" and miner.get_controller_ids(): miner = stop_miner( miner=miner, configuration_service=configuration_service, miner_action_service=miner_action_service, ) continue - elif choice == "8" and miner.controller_id: + elif choice == "8" and miner.get_controller_ids(): updated_miner = get_miner_status( miner=miner, configuration_service=configuration_service, diff --git a/edge_mining/adapters/domain/optimization_unit/cli/commands.py b/edge_mining/adapters/domain/optimization_unit/cli/commands.py index 0bb34b6..3d22625 100644 --- a/edge_mining/adapters/domain/optimization_unit/cli/commands.py +++ b/edge_mining/adapters/domain/optimization_unit/cli/commands.py @@ -11,17 +11,16 @@ print_optimization_policy_details, select_optimization_policy, ) +from edge_mining.adapters.utils import run_async_func from edge_mining.application.interfaces import ConfigurationServiceInterface from edge_mining.domain.common import EntityId from edge_mining.domain.energy.entities import EnergySource -from edge_mining.domain.miner.entities import Miner +from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.notification.entities import Notifier from edge_mining.domain.optimization_unit.aggregate_roots import EnergyOptimizationUnit from edge_mining.domain.policy.aggregate_roots import OptimizationPolicy from edge_mining.shared.logging.port import LoggerPort -from edge_mining.adapters.utils import run_async_func - def handle_add_optimization_unit(configuration_service: ConfigurationServiceInterface, logger: LoggerPort): """Menu to add a new optimization unit.""" From 4c37f7ffa09b3e4d5c396f7a1c33a77d616ae58a Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 00:12:30 +0200 Subject: [PATCH 11/33] feat: add adapter_service parameter to configure_dependencies for improved service configuration --- edge_mining/bootstrap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/edge_mining/bootstrap.py b/edge_mining/bootstrap.py index abd7c23..b547aea 100644 --- a/edge_mining/bootstrap.py +++ b/edge_mining/bootstrap.py @@ -323,6 +323,7 @@ def configure_dependencies(logger: LoggerPort, settings: AppSettings) -> Service persistence_settings=persistence_settings, event_bus=event_bus, logger=logger, + adapter_service=adapter_service, ) services = Services( From 1dcc17fa8bbfc94231c062a673ae19754bc68b06 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 00:12:48 +0200 Subject: [PATCH 12/33] chore: add missing import for sqlalchemy in migration script --- alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py | 1 + 1 file changed, 1 insertion(+) diff --git a/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py b/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py index ffd92e7..df93a90 100644 --- a/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py +++ b/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py @@ -9,6 +9,7 @@ from typing import Sequence, Union import sqlalchemy as sa + from alembic import op # revision identifiers, used by Alembic. From 63d5264574de20af4076b583eba40523815a33af Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 11:20:09 +0200 Subject: [PATCH 13/33] feat: add API endpoint to retrieve miner features and update controller association handling --- .../adapters/domain/miner/fast_api/router.py | 22 ++++++++++++++++++- edge_mining/adapters/domain/miner/schemas.py | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/edge_mining/adapters/domain/miner/fast_api/router.py b/edge_mining/adapters/domain/miner/fast_api/router.py index 046456b..4536a1c 100644 --- a/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/edge_mining/adapters/domain/miner/fast_api/router.py @@ -11,6 +11,7 @@ MinerControllerSchema, MinerControllerUpdateSchema, MinerCreateSchema, + MinerFeatureSchema, MinerSchema, MinerStateSnapshotSchema, MinerUpdateSchema, @@ -28,8 +29,8 @@ MinerActionServiceInterface, ) from edge_mining.domain.common import EntityId, Watts -from edge_mining.domain.miner.common import MinerControllerAdapter from edge_mining.domain.miner.aggregate_roots import Miner +from edge_mining.domain.miner.common import MinerControllerAdapter from edge_mining.domain.miner.exceptions import ( MinerControllerAlreadyExistsError, MinerControllerConfigurationError, @@ -328,6 +329,25 @@ async def deactivate_miner( raise HTTPException(status_code=500, detail=str(e)) from e +@router.get("/miners/{miner_id}/features", response_model=List[MinerFeatureSchema]) +async def get_miner_features( + miner_id: EntityId, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> List[MinerFeatureSchema]: + """Get all features associated with a miner.""" + try: + miner = config_service.get_miner(miner_id) + + if miner is None: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found") + + return [MinerFeatureSchema.from_model(feature) for feature in miner.features] + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + @router.post("/miners/{miner_id}/set-controller", response_model=MinerSchema) async def set_miner_controller( miner_id: EntityId, diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 93d440b..ec99095 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -7,13 +7,13 @@ from pydantic import BaseModel, Field, field_serializer, field_validator from edge_mining.domain.common import EntityId, Watts +from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.miner.common import ( MinerControllerAdapter, MinerControllerProtocol, MinerFeatureType, MinerStatus, ) -from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerStateSnapshot from edge_mining.shared.adapter_configs.miner import ( From da6d93b59085f9d3eb757203d1b06e71fd6af4aa Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 21:13:56 +0200 Subject: [PATCH 14/33] feat: update miner controller methods to support asynchronous operations --- .../domain/miner/controllers/dummy.py | 24 ++--- .../generic_socket_home_assistant_api.py | 8 +- .../domain/miner/controllers/pyasic.py | 89 +++++++++---------- .../services/configuration_service.py | 2 +- .../services/miner_action_service.py | 48 +++++----- .../services/optimization_service.py | 24 ++--- edge_mining/domain/miner/ports.py | 36 ++++---- 7 files changed, 115 insertions(+), 116 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index 9bdb059..d086f42 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -164,7 +164,7 @@ async def get_miner_hashrate(self) -> Optional[HashRate]: # --- ChipTemperatureMonitorPort --- - def get_chip_temperature(self) -> Optional[Temperature]: + async def get_chip_temperature(self) -> Optional[Temperature]: """Get simulated chip temperature.""" if self._status == MinerStatus.ON: temp = Temperature(value=round(random.uniform(55.0, 85.0), 1)) @@ -178,7 +178,7 @@ def get_chip_temperature(self) -> Optional[Temperature]: # --- BoardTemperatureMonitorPort --- - def get_board_temperature(self) -> Optional[Temperature]: + async def get_board_temperature(self) -> Optional[Temperature]: """Get simulated board temperature.""" if self._status == MinerStatus.ON: temp = Temperature(value=round(random.uniform(45.0, 70.0), 1)) @@ -192,7 +192,7 @@ def get_board_temperature(self) -> Optional[Temperature]: # --- InletTemperatureMonitorPort --- - def get_inlet_temperature(self) -> Optional[Temperature]: + async def get_inlet_temperature(self) -> Optional[Temperature]: """Get simulated inlet air temperature.""" temp = Temperature(value=round(random.uniform(18.0, 35.0), 1)) if self.logger: @@ -201,7 +201,7 @@ def get_inlet_temperature(self) -> Optional[Temperature]: # --- OutletTemperatureMonitorPort --- - def get_outlet_temperature(self) -> Optional[Temperature]: + async def get_outlet_temperature(self) -> Optional[Temperature]: """Get simulated outlet air temperature.""" if self._status == MinerStatus.ON: temp = Temperature(value=round(random.uniform(40.0, 65.0), 1)) @@ -213,7 +213,7 @@ def get_outlet_temperature(self) -> Optional[Temperature]: # --- InternalFanSpeedMonitorPort --- - def get_internal_fan_speed(self) -> Optional[FanSpeed]: + async def get_internal_fan_speed(self) -> Optional[FanSpeed]: """Get simulated internal fan speed.""" if self._status == MinerStatus.ON: rpm = random.uniform(3000.0, 6000.0) @@ -228,7 +228,7 @@ def get_internal_fan_speed(self) -> Optional[FanSpeed]: # --- ExternalFanSpeedMonitorPort --- - def get_external_fan_speed(self) -> Optional[FanSpeed]: + async def get_external_fan_speed(self) -> Optional[FanSpeed]: """Get simulated external fan speed.""" if self._external_fan_speed > 0: rpm = self._external_fan_speed * 60.0 # percent to RPM approximation @@ -241,7 +241,7 @@ def get_external_fan_speed(self) -> Optional[FanSpeed]: # --- VoltageMonitorPort --- - def get_voltage(self) -> Optional[Voltage]: + async def get_voltage(self) -> Optional[Voltage]: """Get simulated voltage.""" if self._status == MinerStatus.ON: v = Voltage(value=round(random.uniform(11.8, 12.6), 2)) @@ -255,7 +255,7 @@ def get_voltage(self) -> Optional[Voltage]: # --- FrequencyMonitorPort --- - def get_frequency(self) -> Optional[Frequency]: + async def get_frequency(self) -> Optional[Frequency]: """Get simulated chip frequency.""" if self._status == MinerStatus.ON: f = Frequency(value=round(random.uniform(400.0, 650.0), 1)) @@ -267,7 +267,7 @@ def get_frequency(self) -> Optional[Frequency]: # --- PowerControlPort --- - def power_on(self) -> bool: + async def power_on(self) -> bool: """Simulate hard power on.""" if self.logger: self.logger.debug(f"DummyController: Received POWER ON (current: {self._status.name})") @@ -275,7 +275,7 @@ def power_on(self) -> bool: self._status = MinerStatus.STARTING return True - def power_off(self) -> bool: + async def power_off(self) -> bool: """Simulate hard power off.""" if self.logger: self.logger.debug(f"DummyController: Received POWER OFF (current: {self._status.name})") @@ -284,7 +284,7 @@ def power_off(self) -> bool: # --- InternalFanControlPort --- - def set_internal_fan_speed(self, speed_percent: float) -> bool: + async def set_internal_fan_speed(self, speed_percent: float) -> bool: """Simulate setting internal fan speed.""" if self.logger: self.logger.debug(f"DummyController: Setting internal fan speed to {speed_percent:.0f}%") @@ -293,7 +293,7 @@ def set_internal_fan_speed(self, speed_percent: float) -> bool: # --- ExternalFanControlPort --- - def set_external_fan_speed(self, speed_percent: float) -> bool: + async def set_external_fan_speed(self, speed_percent: float) -> bool: """Simulate setting external fan speed.""" if self.logger: self.logger.debug(f"DummyController: Setting external fan speed to {speed_percent:.0f}%") diff --git a/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py b/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py index eff68fe..a3fa724 100644 --- a/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py +++ b/edge_mining/adapters/domain/miner/controllers/generic_socket_home_assistant_api.py @@ -110,7 +110,7 @@ def _log_configuration(self): # --- PowerMonitorPort --- - def get_power(self) -> Optional[Watts]: + async def get_power(self) -> Optional[Watts]: """Gets the current power consumption, if available.""" if self.logger: self.logger.debug("Fetching power consumption from Home Assistant...") @@ -129,7 +129,7 @@ def get_power(self) -> Optional[Watts]: # --- StatusMonitorPort --- - def get_status(self) -> MinerStatus: + async def get_status(self) -> MinerStatus: """Gets the current operational status of the miner.""" if self.logger: self.logger.debug("Fetching miner status from Home Assistant...") @@ -152,7 +152,7 @@ def get_status(self) -> MinerStatus: # --- PowerControlPort --- - def power_off(self) -> bool: + async def power_off(self) -> bool: """Attempts to power off the miner via smart plug. Returns True on success.""" if self.logger: self.logger.debug("Sending power off command to miner via Home Assistant...") @@ -167,7 +167,7 @@ def power_off(self) -> bool: return success - def power_on(self) -> bool: + async def power_on(self) -> bool: """Attempts to power on the miner via smart plug. Returns True on success.""" if self.logger: self.logger.debug("Sending power on command to miner via Home Assistant...") diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index dfde226..539502b 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -12,7 +12,6 @@ from pyasic.ssh.base import BaseSSH from pyasic.web.base import BaseWebAPI -from edge_mining.adapters.utils import run_async_func from edge_mining.domain.common import Watts from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.miner.common import MinerControllerProtocol, MinerStatus @@ -125,11 +124,11 @@ def _log_configuration(self): if self.logger: self.logger.debug(f"Entities Configured: IP={self.ip}") - def _get_miner(self) -> None: + async def _get_miner(self) -> None: """Retrieve the pyasic miner instance.""" if self._miner is None: try: - miner = run_async_func(pyasic.get_miner(self.ip)) + miner = await pyasic.get_miner(self.ip) if miner is not None: self._miner = cast(AnyMiner, miner) @@ -177,13 +176,13 @@ def _get_miner(self) -> None: # --- ModelDetectionPort --- - def get_model(self) -> Optional[str]: + async def get_model(self) -> Optional[str]: """Gets the model of the miner.""" if self.logger: self.logger.debug(f"Fetching model from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -194,13 +193,13 @@ def get_model(self) -> Optional[str]: # --- HashrateMonitorPort --- - def get_hashrate(self) -> Optional[HashRate]: + async def get_hashrate(self) -> Optional[HashRate]: """Gets the current hash rate, if available.""" if self.logger: self.logger.debug(f"Fetching hashrate from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -208,7 +207,7 @@ def get_hashrate(self) -> Optional[HashRate]: return None miner = self._miner - hashrate: Optional[AlgoHashRate] = run_async_func(miner.get_hashrate()) + hashrate: Optional[AlgoHashRate] = await miner.get_hashrate() if hashrate is None: if self.logger: self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") @@ -226,12 +225,12 @@ def get_hashrate(self) -> Optional[HashRate]: # --- PowerMonitorPort --- - def get_power(self) -> Optional[Watts]: + async def get_power(self) -> Optional[Watts]: """Gets the current power consumption, if available.""" if self.logger: self.logger.debug(f"Fetching power consumption from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -239,7 +238,7 @@ def get_power(self) -> Optional[Watts]: return None miner = self._miner - wattage = run_async_func(miner.get_wattage()) + wattage = await miner.get_wattage() if wattage is None: if self.logger: self.logger.debug(f"Failed to fetch power consumption from {self.ip}...") @@ -253,12 +252,12 @@ def get_power(self) -> Optional[Watts]: # --- StatusMonitorPort --- - def get_status(self) -> MinerStatus: + async def get_status(self) -> MinerStatus: """Gets the current operational status of the miner.""" if self.logger: self.logger.debug(f"Fetching miner status from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -266,7 +265,7 @@ def get_status(self) -> MinerStatus: return MinerStatus.UNKNOWN miner = self._miner - mining_state = run_async_func(miner.is_mining()) + mining_state = await miner.is_mining() # Map the bool result from is_mining() to MinerStatus state_map: Dict[Optional[bool], MinerStatus] = { @@ -291,12 +290,12 @@ def get_status(self) -> MinerStatus: # --- ChipTemperatureMonitorPort --- - def get_chip_temperature(self) -> Optional[Temperature]: + async def get_chip_temperature(self) -> Optional[Temperature]: """Gets the current chip temperature, if available.""" if self.logger: self.logger.debug(f"Fetching chip temperature from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -304,7 +303,7 @@ def get_chip_temperature(self) -> Optional[Temperature]: return None miner = self._miner - data = run_async_func(miner.get_data()) + data = await miner.get_data() if data is None or data.temperature_avg is None: return None @@ -312,18 +311,18 @@ def get_chip_temperature(self) -> Optional[Temperature]: # --- BoardTemperatureMonitorPort --- - def get_board_temperature(self) -> Optional[Temperature]: + async def get_board_temperature(self) -> Optional[Temperature]: """Gets the current board temperature, if available.""" if self.logger: self.logger.debug(f"Fetching board temperature from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: return None miner = self._miner - data = run_async_func(miner.get_data()) + data = await miner.get_data() if data is None or not data.hashboards: return None @@ -336,18 +335,18 @@ def get_board_temperature(self) -> Optional[Temperature]: # --- InletTemperatureMonitorPort --- - def get_inlet_temperature(self) -> Optional[Temperature]: + async def get_inlet_temperature(self) -> Optional[Temperature]: """Gets the current inlet air temperature, if available.""" if self.logger: self.logger.debug(f"Fetching inlet temperature from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: return None miner = self._miner - data = run_async_func(miner.get_data()) + data = await miner.get_data() if data is None or data.env_temp is None: return None @@ -355,7 +354,7 @@ def get_inlet_temperature(self) -> Optional[Temperature]: # --- OutletTemperatureMonitorPort --- - def get_outlet_temperature(self) -> Optional[Temperature]: + async def get_outlet_temperature(self) -> Optional[Temperature]: """Gets the current outlet air temperature, if available.""" if self.logger: self.logger.debug(f"Fetching outlet temperature from {self.ip}...") @@ -366,18 +365,18 @@ def get_outlet_temperature(self) -> Optional[Temperature]: # --- InternalFanSpeedMonitorPort --- - def get_internal_fan_speed(self) -> Optional[FanSpeed]: + async def get_internal_fan_speed(self) -> Optional[FanSpeed]: """Gets the current internal fan speed, if available.""" if self.logger: self.logger.debug(f"Fetching internal fan speed from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: return None miner = self._miner - data = run_async_func(miner.get_data()) + data = await miner.get_data() if data is None or not data.fans: return None @@ -390,18 +389,18 @@ def get_internal_fan_speed(self) -> Optional[FanSpeed]: # --- VoltageMonitorPort --- - def get_voltage(self) -> Optional[Voltage]: + async def get_voltage(self) -> Optional[Voltage]: """Gets the current voltage, if available.""" if self.logger: self.logger.debug(f"Fetching voltage from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: return None miner = self._miner - data = run_async_func(miner.get_data()) + data = await miner.get_data() if data is None or data.voltage is None: return None @@ -409,18 +408,18 @@ def get_voltage(self) -> Optional[Voltage]: # --- FrequencyMonitorPort --- - def get_frequency(self) -> Optional[Frequency]: + async def get_frequency(self) -> Optional[Frequency]: """Gets the current chip operating frequency, if available.""" if self.logger: self.logger.debug(f"Fetching frequency from {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: return None miner = self._miner - data = run_async_func(miner.get_data()) + data = await miner.get_data() if data is None or data.frequency_avg is None: return None @@ -428,12 +427,12 @@ def get_frequency(self) -> Optional[Frequency]: # --- MiningControlPort --- - def start_mining(self) -> bool: + async def start_mining(self) -> bool: """Attempts to start mining. Returns True on success.""" if self.logger: self.logger.debug(f"Sending start command to miner at {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -441,19 +440,19 @@ def start_mining(self) -> bool: return False miner = self._miner - success = run_async_func(miner.resume_mining()) + success = await miner.resume_mining() if self.logger: self.logger.debug(f"Start command sent. Success: {success}") return success or False - def stop_mining(self) -> bool: + async def stop_mining(self) -> bool: """Attempts to stop mining. Returns True on success.""" if self.logger: self.logger.debug(f"Sending stop command to miner at {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -461,7 +460,7 @@ def stop_mining(self) -> bool: return False miner = self._miner - success = run_async_func(miner.stop_mining()) + success = await miner.stop_mining() if self.logger: self.logger.debug(f"Stop command sent. Success: {success}") @@ -470,12 +469,12 @@ def stop_mining(self) -> bool: # --- InternalFanControlPort --- - def set_internal_fan_speed(self, speed_percent: float) -> bool: + async def set_internal_fan_speed(self, speed_percent: float) -> bool: """Sets internal fan speed as a percentage (0-100). Returns True on success.""" if self.logger: self.logger.debug(f"Setting internal fan speed to {speed_percent}% on {self.ip}...") - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -484,7 +483,7 @@ def set_internal_fan_speed(self, speed_percent: float) -> bool: miner = self._miner try: - success = run_async_func(miner.set_fan_speed(int(speed_percent))) + success = await miner.set_fan_speed(int(speed_percent)) if self.logger: self.logger.debug(f"Fan speed set. Success: {success}") return success or False @@ -495,7 +494,7 @@ def set_internal_fan_speed(self, speed_percent: float) -> bool: # --- Private helpers --- - def _derive_miner_status(self) -> Optional[bool]: + async def _derive_miner_status(self) -> Optional[bool]: """Derives the miner status based on hashrate and power consumption. Returns True if miner is ON (both hashrate > 0 and power > IDLE_WATTAGE_THRESHOLD), @@ -506,8 +505,8 @@ def _derive_miner_status(self) -> Optional[bool]: """ IDLE_WATTAGE_THRESHOLD = 1 # Low threshold to work with low-power miners (e.g., Bitaxe ~13W) - hashrate: Optional[HashRate] = self.get_hashrate() - wattage: Optional[Watts] = self.get_power() + hashrate: Optional[HashRate] = await self.get_hashrate() + wattage: Optional[Watts] = await self.get_power() if self.logger: self.logger.debug( diff --git a/edge_mining/application/services/configuration_service.py b/edge_mining/application/services/configuration_service.py index cda3c44..dc19658 100644 --- a/edge_mining/application/services/configuration_service.py +++ b/edge_mining/application/services/configuration_service.py @@ -1581,7 +1581,7 @@ async def set_miner_controller(self, controller_id: EntityId, miner_id: EntityId if not self.adapter_service: raise MinerControllerConfigurationError("Adapter service is required to discover supported features.") - adapter = self.adapter_service.get_miner_controller_adapter(miner, controller_id) + adapter = await self.adapter_service.get_miner_controller_adapter(miner, controller_id) if not adapter: raise MinerControllerConfigurationError(f"Could not initialize adapter for controller {controller_id}.") diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 4df0282..f6217a4 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -46,11 +46,11 @@ def __init__( self._event_bus = event_bus self.logger = logger - def _try_update_model(self, miner: Miner) -> None: + async def _try_update_model(self, miner: Miner) -> None: """Update miner model from MODEL_DETECTION feature port if available.""" model_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) if model_port and isinstance(model_port, ModelDetectionPort): - current_model = model_port.get_model() + current_model = await model_port.get_model() if current_model and miner.model != current_model: miner.model = current_model self.miner_repo.update(miner) @@ -91,16 +91,16 @@ async def start_miner(self, miner_id: EntityId, notifiers: Optional[List[Notific status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): - current_status = status_port.get_status() + current_status = await status_port.get_status() # Update model - self._try_update_model(miner) + await self._try_update_model(miner) success = False if mining_port and isinstance(mining_port, MiningControlPort): - success = mining_port.start_mining() + success = await mining_port.start_mining() elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): - success = power_ctrl_port.power_on() + success = await power_ctrl_port.power_on() if success: if self.logger: @@ -153,16 +153,16 @@ async def stop_miner(self, miner_id: EntityId, notifiers: Optional[List[Notifica status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): - current_status = status_port.get_status() + current_status = await status_port.get_status() # Update model - self._try_update_model(miner) + await self._try_update_model(miner) success = False if mining_port and isinstance(mining_port, MiningControlPort): - success = mining_port.stop_mining() + success = await mining_port.stop_mining() elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): - success = power_ctrl_port.power_off() + success = await power_ctrl_port.power_off() if success: if self.logger: @@ -205,7 +205,7 @@ async def get_miner_consumption(self, miner_id: EntityId) -> Optional[Watts]: if not port or not isinstance(port, PowerMonitorPort): raise MinerControllerConfigurationError(f"No power monitor available for miner {miner_id}.") - return port.get_power() + return await port.get_power() async def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: """Gets the current hash rate of the specified miner.""" @@ -221,9 +221,9 @@ async def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: if not port or not isinstance(port, HashrateMonitorPort): raise MinerControllerConfigurationError(f"No hashrate monitor available for miner {miner_id}.") - self._try_update_model(miner) + await self._try_update_model(miner) - return port.get_hashrate() + return await port.get_hashrate() async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: """Gets the current status of the specified miner as a state snapshot.""" @@ -239,19 +239,19 @@ async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): - current_status = status_port.get_status() + current_status = await status_port.get_status() hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): - current_hashrate = hashrate_port.get_hashrate() + current_hashrate = await hashrate_port.get_hashrate() power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): - current_power = power_port.get_power() + current_power = await power_port.get_power() - self._try_update_model(miner) + await self._try_update_model(miner) return MinerStateSnapshot( status=current_status, @@ -297,19 +297,19 @@ async def sync_all_miners(self, include_inactive: bool = False) -> None: error_count += 1 continue - current_status = status_port.get_status() + current_status = await status_port.get_status() hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): - current_hashrate = hashrate_port.get_hashrate() + current_hashrate = await hashrate_port.get_hashrate() power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): - current_power = power_port.get_power() + current_power = await power_port.get_power() - self._try_update_model(miner) + await self._try_update_model(miner) synced_count += 1 @@ -353,17 +353,17 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi status_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): - current_status = status_port.get_status() + current_status = await status_port.get_status() hashrate_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.HASHRATE_MONITORING) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): - current_hashrate = hashrate_port.get_hashrate() + current_hashrate = await hashrate_port.get_hashrate() power_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): - current_power = power_port.get_power() + current_power = await power_port.get_power() has_no_details = all( ( diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index 87ad4e7..7a66029 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -284,17 +284,17 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option self.logger.error(f"No status monitor port for miner {miner_id}. Skipping.") continue - current_status = status_port.get_status() + current_status = await status_port.get_status() hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): - current_hashrate = hashrate_port.get_hashrate() + current_hashrate = await hashrate_port.get_hashrate() power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): - current_power = power_port.get_power() + current_power = await power_port.get_power() # Build the miner state snapshot miner_state = MinerStateSnapshot( @@ -729,17 +729,17 @@ async def _process_single_miner_in_unit( # Get current status and make decision try: # Query current state via feature ports - current_status = status_port.get_status() + current_status = await status_port.get_status() hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): - current_hashrate = hashrate_port.get_hashrate() + current_hashrate = await hashrate_port.get_hashrate() power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): - current_power = power_port.get_power() + current_power = await power_port.get_power() # Build the miner state snapshot miner_state = MinerStateSnapshot( @@ -754,7 +754,7 @@ async def _process_single_miner_in_unit( from edge_mining.domain.miner.ports import ModelDetectionPort if isinstance(model_port, ModelDetectionPort): - current_model = model_port.get_model() + current_model = await model_port.get_model() if current_model and miner.model != current_model: miner.model = current_model self.miner_repo.update(miner) @@ -867,9 +867,9 @@ async def _execute_miner_decision( if self.logger: self.logger.info(f"Executing START for miner {miner_id} via {type(mining_port).__name__}") if isinstance(mining_port, MiningControlPort): - success = mining_port.start_mining() + success = await mining_port.start_mining() elif isinstance(mining_port, PowerControlPort): - success = mining_port.power_on() + success = await mining_port.power_on() action_taken = True if success: await self._notify_unit( @@ -888,9 +888,9 @@ async def _execute_miner_decision( if self.logger: self.logger.info(f"Executing STOP for miner {miner_id} via {type(mining_port).__name__}") if isinstance(mining_port, MiningControlPort): - success = mining_port.stop_mining() + success = await mining_port.stop_mining() elif isinstance(mining_port, PowerControlPort): - success = mining_port.power_off() + success = await mining_port.power_off() action_taken = True if success: await self._notify_unit( @@ -915,7 +915,7 @@ async def _execute_miner_decision( miner = self.miner_repo.get_by_id(miner_id) # Get new miner state to publish in the event - new_status = status_port.get_status() + new_status = await status_port.get_status() # Publish miner state changed event if self._event_bus: diff --git a/edge_mining/domain/miner/ports.py b/edge_mining/domain/miner/ports.py index 93150e0..41aaff1 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -56,7 +56,7 @@ class HashrateMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.HASHRATE_MONITORING @abstractmethod - def get_hashrate(self) -> Optional[HashRate]: + async def get_hashrate(self) -> Optional[HashRate]: """Gets the current hash rate, if available.""" raise NotImplementedError @@ -67,7 +67,7 @@ class PowerMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.POWER_MONITORING @abstractmethod - def get_power(self) -> Optional[Watts]: + async def get_power(self) -> Optional[Watts]: """Gets the current power consumption, if available.""" raise NotImplementedError @@ -78,7 +78,7 @@ class StatusMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.STATUS_MONITORING @abstractmethod - def get_status(self) -> MinerStatus: + async def get_status(self) -> MinerStatus: """Gets the current operational status of the miner.""" raise NotImplementedError @@ -89,7 +89,7 @@ class ChipTemperatureMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.CHIP_TEMPERATURE_MONITORING @abstractmethod - def get_chip_temperature(self) -> Optional[Temperature]: + async def get_chip_temperature(self) -> Optional[Temperature]: """Gets the current chip temperature, if available.""" raise NotImplementedError @@ -100,7 +100,7 @@ class BoardTemperatureMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.BOARD_TEMPERATURE_MONITORING @abstractmethod - def get_board_temperature(self) -> Optional[Temperature]: + async def get_board_temperature(self) -> Optional[Temperature]: """Gets the current board temperature, if available.""" raise NotImplementedError @@ -111,7 +111,7 @@ class InletTemperatureMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.INLET_TEMPERATURE_MONITORING @abstractmethod - def get_inlet_temperature(self) -> Optional[Temperature]: + async def get_inlet_temperature(self) -> Optional[Temperature]: """Gets the current inlet air temperature, if available.""" raise NotImplementedError @@ -122,7 +122,7 @@ class OutletTemperatureMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.OUTLET_TEMPERATURE_MONITORING @abstractmethod - def get_outlet_temperature(self) -> Optional[Temperature]: + async def get_outlet_temperature(self) -> Optional[Temperature]: """Gets the current outlet air temperature, if available.""" raise NotImplementedError @@ -133,7 +133,7 @@ class InternalFanSpeedMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING @abstractmethod - def get_internal_fan_speed(self) -> Optional[FanSpeed]: + async def get_internal_fan_speed(self) -> Optional[FanSpeed]: """Gets the current internal fan speed, if available.""" raise NotImplementedError @@ -144,7 +144,7 @@ class ExternalFanSpeedMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.FAN_SPEED_EXTERNAL_MONITORING @abstractmethod - def get_external_fan_speed(self) -> Optional[FanSpeed]: + async def get_external_fan_speed(self) -> Optional[FanSpeed]: """Gets the current external fan speed, if available.""" raise NotImplementedError @@ -155,7 +155,7 @@ class VoltageMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.VOLTAGE_MONITORING @abstractmethod - def get_voltage(self) -> Optional[Voltage]: + async def get_voltage(self) -> Optional[Voltage]: """Gets the current voltage, if available.""" raise NotImplementedError @@ -166,7 +166,7 @@ class FrequencyMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.FREQUENCY_MONITORING @abstractmethod - def get_frequency(self) -> Optional[Frequency]: + async def get_frequency(self) -> Optional[Frequency]: """Gets the current chip operating frequency, if available.""" raise NotImplementedError @@ -180,12 +180,12 @@ class MiningControlPort(MinerFeaturePort): feature_type = MinerFeatureType.MINING_CONTROL @abstractmethod - def start_mining(self) -> bool: + async def start_mining(self) -> bool: """Attempts to start mining. Returns True on success.""" raise NotImplementedError @abstractmethod - def stop_mining(self) -> bool: + async def stop_mining(self) -> bool: """Attempts to stop mining. Returns True on success.""" raise NotImplementedError @@ -196,12 +196,12 @@ class PowerControlPort(MinerFeaturePort): feature_type = MinerFeatureType.POWER_CONTROL @abstractmethod - def power_on(self) -> bool: + async def power_on(self) -> bool: """Attempts to power on the miner. Returns True on success.""" raise NotImplementedError @abstractmethod - def power_off(self) -> bool: + async def power_off(self) -> bool: """Attempts to power off the miner. Returns True on success.""" raise NotImplementedError @@ -212,7 +212,7 @@ class InternalFanControlPort(MinerFeaturePort): feature_type = MinerFeatureType.INTERNAL_FAN_CONTROL @abstractmethod - def set_internal_fan_speed(self, speed_percent: float) -> bool: + async def set_internal_fan_speed(self, speed_percent: float) -> bool: """Sets internal fan speed as a percentage (0-100). Returns True on success.""" raise NotImplementedError @@ -223,7 +223,7 @@ class ExternalFanControlPort(MinerFeaturePort): feature_type = MinerFeatureType.EXTERNAL_FAN_CONTROL @abstractmethod - def set_external_fan_speed(self, speed_percent: float) -> bool: + async def set_external_fan_speed(self, speed_percent: float) -> bool: """Sets external fan speed as a percentage (0-100). Returns True on success.""" raise NotImplementedError @@ -237,7 +237,7 @@ class ModelDetectionPort(MinerFeaturePort): feature_type = MinerFeatureType.MODEL_DETECTION @abstractmethod - def get_model(self) -> Optional[str]: + async def get_model(self) -> Optional[str]: """Gets the model of the miner, if available.""" raise NotImplementedError From fcb18a57a78c750a0951d9c45ebabcf11a1e89b8 Mon Sep 17 00:00:00 2001 From: markoceri Date: Wed, 8 Apr 2026 21:20:38 +0200 Subject: [PATCH 15/33] feat: update miner feature port methods to support asynchronous operations --- edge_mining/application/interfaces.py | 2 +- .../application/services/adapter_service.py | 4 +- .../services/miner_action_service.py | 42 +++++++++++-------- .../services/optimization_service.py | 22 ++++++---- 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/edge_mining/application/interfaces.py b/edge_mining/application/interfaces.py index 77cfff1..522e5b7 100644 --- a/edge_mining/application/interfaces.py +++ b/edge_mining/application/interfaces.py @@ -53,7 +53,7 @@ async def get_miner_controller_adapter(self, miner: Miner, controller_id: Entity """Get a miner controller adapter instance for a specific controller.""" @abstractmethod - def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: + async def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: """Get the adapter implementing the highest-priority active feature port for a miner.""" @abstractmethod diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index 00d7c41..e1ba231 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -625,7 +625,7 @@ async def get_miner_controller_adapter(self, miner: Miner, controller_id: Entity return None return await self._initialize_miner_controller_adapter(miner, miner_controller) - def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: + async def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: """Get the adapter implementing the highest-priority active feature for a miner. Resolves the active MinerFeature for the given feature_type, retrieves @@ -637,7 +637,7 @@ def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) - self.logger.debug(f"No active feature of type {feature_type.value} for miner {miner.name}.") return None - adapter = self.get_miner_controller_adapter(miner, active_feature.controller_id) + adapter = await self.get_miner_controller_adapter(miner, active_feature.controller_id) if not adapter: return None diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index f6217a4..42c4c90 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -48,7 +48,7 @@ def __init__( async def _try_update_model(self, miner: Miner) -> None: """Update miner model from MODEL_DETECTION feature port if available.""" - model_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) + model_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) if model_port and isinstance(model_port, ModelDetectionPort): current_model = await model_port.get_model() if current_model and miner.model != current_model: @@ -81,14 +81,14 @@ async def start_miner(self, miner_id: EntityId, notifiers: Optional[List[Notific raise MinerNotActiveError(f"Miner {miner_id} is not active and cannot be started.") # Try MINING_CONTROL first, then POWER_CONTROL as fallback - mining_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) - power_ctrl_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) + mining_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + power_ctrl_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) if not mining_port and not power_ctrl_port: raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") # Get current status - status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): current_status = await status_port.get_status() @@ -143,14 +143,14 @@ async def stop_miner(self, miner_id: EntityId, notifiers: Optional[List[Notifica raise MinerNotActiveError(f"Miner {miner_id} is not active and cannot be stopped.") # Try MINING_CONTROL first, then POWER_CONTROL as fallback - mining_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) - power_ctrl_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) + mining_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + power_ctrl_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_CONTROL) if not mining_port and not power_ctrl_port: raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") # Get current status - status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): current_status = await status_port.get_status() @@ -201,7 +201,7 @@ async def get_miner_consumption(self, miner_id: EntityId) -> Optional[Watts]: if not miner: raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") - port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) if not port or not isinstance(port, PowerMonitorPort): raise MinerControllerConfigurationError(f"No power monitor available for miner {miner_id}.") @@ -217,7 +217,7 @@ async def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: if not miner: raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") - port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) if not port or not isinstance(port, HashrateMonitorPort): raise MinerControllerConfigurationError(f"No hashrate monitor available for miner {miner_id}.") @@ -236,17 +236,17 @@ async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") # Query individual feature ports - status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): current_status = await status_port.get_status() - hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + hashrate_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): current_hashrate = await hashrate_port.get_hashrate() - power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() @@ -290,7 +290,9 @@ async def sync_all_miners(self, include_inactive: bool = False) -> None: if self.logger: self.logger.debug(f"Syncing status for miner {miner.id} ({miner.name})...") - status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + status_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.STATUS_MONITORING + ) if not status_port or not isinstance(status_port, StatusMonitorPort): if self.logger: self.logger.warning(f"No status monitor for miner {miner.id} ({miner.name}). Skipping.") @@ -299,12 +301,14 @@ async def sync_all_miners(self, include_inactive: bool = False) -> None: current_status = await status_port.get_status() - hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + hashrate_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHRATE_MONITORING + ) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): current_hashrate = await hashrate_port.get_hashrate() - power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() @@ -350,17 +354,19 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi ) # Query via feature ports - status_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.STATUS_MONITORING) + status_port = await self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.STATUS_MONITORING) current_status = MinerStatus.UNKNOWN if status_port and isinstance(status_port, StatusMonitorPort): current_status = await status_port.get_status() - hashrate_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.HASHRATE_MONITORING) + hashrate_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.HASHRATE_MONITORING + ) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): current_hashrate = await hashrate_port.get_hashrate() - power_port = self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.POWER_MONITORING) + power_port = await self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index 7a66029..b67e31e 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -278,7 +278,9 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option continue # Try next miner if available # --- Query current state via feature ports --- - status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + status_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.STATUS_MONITORING + ) if not status_port or not isinstance(status_port, StatusMonitorPort): if self.logger: self.logger.error(f"No status monitor port for miner {miner_id}. Skipping.") @@ -286,12 +288,14 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option current_status = await status_port.get_status() - hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + hashrate_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHRATE_MONITORING + ) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): current_hashrate = await hashrate_port.get_hashrate() - power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() @@ -699,7 +703,7 @@ async def _process_single_miner_in_unit( return # --- Miner Controller (via feature ports) --- - status_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) + status_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.STATUS_MONITORING) if not status_port or not isinstance(status_port, StatusMonitorPort): if self.logger: self.logger.error(f"No status monitor available for miner {miner_id}. Cannot control miner.") @@ -710,7 +714,7 @@ async def _process_single_miner_in_unit( ) return - mining_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) + mining_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MINING_CONTROL) if not mining_port: if self.logger: @@ -731,12 +735,14 @@ async def _process_single_miner_in_unit( # Query current state via feature ports current_status = await status_port.get_status() - hashrate_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + hashrate_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHRATE_MONITORING + ) current_hashrate = None if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): current_hashrate = await hashrate_port.get_hashrate() - power_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.POWER_MONITORING) current_power = None if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() @@ -749,7 +755,7 @@ async def _process_single_miner_in_unit( ) # Update model if available and it has changed (static config update) - model_port = self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) + model_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) if model_port: from edge_mining.domain.miner.ports import ModelDetectionPort From 831c2d9bb5a156e5298fde198b34b35123e1ef5b Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 00:18:59 +0200 Subject: [PATCH 16/33] feat: add chip temperature and internal fan speed monitoring to miner services --- .../services/miner_action_service.py | 18 ++++++++++++++++++ .../services/optimization_service.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 42c4c90..123d5cd 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -13,7 +13,9 @@ MinerNotFoundError, ) from edge_mining.domain.miner.ports import ( + ChipTemperatureMonitorPort, HashrateMonitorPort, + InternalFanSpeedMonitorPort, MinerRepository, MiningControlPort, ModelDetectionPort, @@ -371,6 +373,20 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() + temperature_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.CHIP_TEMPERATURE_MONITORING + ) + current_chip_temperature = None + if temperature_port and isinstance(temperature_port, ChipTemperatureMonitorPort): + current_chip_temperature = await temperature_port.get_chip_temperature() + + internal_fan_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + ) + internal_fan_speed = None + if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): + internal_fan_speed = await internal_fan_port.get_internal_fan_speed() + has_no_details = all( ( current_status == MinerStatus.UNKNOWN, @@ -394,6 +410,8 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi status=current_status, hash_rate=current_hashrate, power_consumption=current_power, + chip_temperature=current_chip_temperature, + internal_fan_speed=internal_fan_speed, ) if self.logger: diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index b67e31e..280c679 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -30,7 +30,9 @@ from edge_mining.domain.miner.events import MinerStateChangedEvent from edge_mining.domain.miner.exceptions import MinerError from edge_mining.domain.miner.ports import ( + ChipTemperatureMonitorPort, HashrateMonitorPort, + InternalFanSpeedMonitorPort, MinerFeaturePort, MinerRepository, MiningControlPort, @@ -300,11 +302,27 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() + temperature_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.CHIP_TEMPERATURE_MONITORING + ) + current_chip_temperature = None + if temperature_port and isinstance(temperature_port, ChipTemperatureMonitorPort): + current_chip_temperature = await temperature_port.get_chip_temperature() + + internal_fan_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + ) + internal_fan_speed = None + if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): + internal_fan_speed = await internal_fan_port.get_internal_fan_speed() + # Build the miner state snapshot miner_state = MinerStateSnapshot( status=current_status, hash_rate=current_hashrate, power_consumption=current_power, + chip_temperature=current_chip_temperature, + internal_fan_speed=internal_fan_speed, ) break # We found a valid miner and controller, we can stop looking for more miners From 7a602e779398b09c054382bb98e975682051ead6 Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 00:21:55 +0200 Subject: [PATCH 17/33] feat: update temperature retrieval method in PyASICMinerController to use get_env_temp --- edge_mining/adapters/domain/miner/controllers/pyasic.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 539502b..52d7297 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -303,11 +303,9 @@ async def get_chip_temperature(self) -> Optional[Temperature]: return None miner = self._miner - data = await miner.get_data() - if data is None or data.temperature_avg is None: - return None + temperature = await miner.get_env_temp() - return Temperature(value=float(data.temperature_avg)) + return Temperature(value=float(temperature)) if temperature is not None else None # --- BoardTemperatureMonitorPort --- From da8c3cc3879d28ae6cfc9f69f686b9be03be2a2c Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 00:24:53 +0200 Subject: [PATCH 18/33] feat: add MaxPowerDetectionPort and MaxHashrateDetectionPort for enhanced miner monitoring --- .../domain/miner/controllers/pyasic.py | 64 +++++++++++++++++++ edge_mining/domain/miner/common.py | 2 + edge_mining/domain/miner/ports.py | 22 +++++++ 3 files changed, 88 insertions(+) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 52d7297..baec205 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -24,6 +24,8 @@ InletTemperatureMonitorPort, InternalFanControlPort, InternalFanSpeedMonitorPort, + MaxHashrateDetectionPort, + MaxPowerDetectionPort, MiningControlPort, ModelDetectionPort, OutletTemperatureMonitorPort, @@ -96,6 +98,8 @@ class PyASICMinerController( MiningControlPort, InternalFanControlPort, ModelDetectionPort, + MaxPowerDetectionPort, + MaxHashrateDetectionPort, ): """Controls a miner via pyasic. Implements multiple feature ports.""" @@ -191,6 +195,66 @@ async def get_model(self) -> Optional[str]: return self._miner.model or None + # --- MaxPowerDetectionPort --- + + async def get_max_power(self) -> Optional[Watts]: + """Gets the maximum power consumption of the miner, if available.""" + + if self.logger: + self.logger.debug(f"Fetching max power from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return None + + miner = self._miner + wattage = await miner.get_wattage_limit() + if wattage is None: + if self.logger: + self.logger.debug(f"Failed to fetch max power from {self.ip}...") + return None + max_power_watts = Watts(wattage) + + if self.logger: + self.logger.debug(f"Max power fetched: {max_power_watts}") + + return max_power_watts + + # --- MaxHashrateDetectionPort --- + + async def get_max_hashrate(self) -> Optional[HashRate]: + """Gets the maximum hash rate of the miner, if available.""" + + if self.logger: + self.logger.debug(f"Fetching max hash rate from {self.ip}...") + + await self._get_miner() + + if not self._miner: + if self.logger: + self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") + return None + + miner = self._miner + hashrate = await miner.get_expected_hashrate() + if hashrate is None: + if self.logger: + self.logger.debug(f"Failed to fetch max hash rate from {self.ip}...") + return None + normalized_value, normalized_unit = self._normalize_hashrate_unit( + value=float(hashrate), + unit=str(hashrate.unit), + ) + max_hashrate = HashRate(value=normalized_value, unit=normalized_unit) + + if self.logger: + self.logger.debug(f"Max hash rate fetched: {max_hashrate}") + + return max_hashrate + # --- HashrateMonitorPort --- async def get_hashrate(self) -> Optional[HashRate]: diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 33d24aa..5fdf953 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -40,6 +40,8 @@ class MinerFeatureType(Enum): # Info MODEL_DETECTION = "model_detection" + MAX_POWER_DETECTION = "max_power_detection" + MAX_HASHRATE_DETECTION = "max_hashrate_detection" class MinerControllerAdapter(AdapterType): diff --git a/edge_mining/domain/miner/ports.py b/edge_mining/domain/miner/ports.py index 41aaff1..a610149 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -242,6 +242,28 @@ async def get_model(self) -> Optional[str]: raise NotImplementedError +class MaxPowerDetectionPort(MinerFeaturePort): + """Port for detecting miner maximum power consumption.""" + + feature_type = MinerFeatureType.MAX_POWER_DETECTION + + @abstractmethod + async def get_max_power(self) -> Optional[Watts]: + """Gets the maximum power consumption of the miner, if available.""" + raise NotImplementedError + + +class MaxHashrateDetectionPort(MinerFeaturePort): + """Port for detecting miner maximum hash rate.""" + + feature_type = MinerFeatureType.MAX_HASHRATE_DETECTION + + @abstractmethod + async def get_max_hashrate(self) -> Optional[HashRate]: + """Gets the maximum hash rate of the miner, if available.""" + raise NotImplementedError + + # --- Repository Ports --- From c915ae4b8b77327d2573e051e3a86b41a0969678 Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 16:44:28 +0200 Subject: [PATCH 19/33] feat: implement DeviceInfoPort and MinerInfo value object for enhanced miner device information retrieval --- .../domain/miner/controllers/dummy.py | 35 +++++++++++++---- .../domain/miner/controllers/pyasic.py | 25 +++++++++--- edge_mining/application/interfaces.py | 6 ++- .../services/miner_action_service.py | 39 ++++++++----------- .../services/optimization_service.py | 11 ------ edge_mining/domain/miner/common.py | 2 +- edge_mining/domain/miner/ports.py | 23 +++++------ edge_mining/domain/miner/value_objects.py | 11 ++++++ 8 files changed, 92 insertions(+), 60 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index d086f42..401ebf2 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -8,6 +8,7 @@ from edge_mining.domain.miner.ports import ( BoardTemperatureMonitorPort, ChipTemperatureMonitorPort, + DeviceInfoPort, ExternalFanControlPort, ExternalFanSpeedMonitorPort, FrequencyMonitorPort, @@ -16,14 +17,13 @@ InternalFanControlPort, InternalFanSpeedMonitorPort, MiningControlPort, - ModelDetectionPort, OutletTemperatureMonitorPort, PowerControlPort, PowerMonitorPort, StatusMonitorPort, VoltageMonitorPort, ) -from edge_mining.domain.miner.value_objects import FanSpeed, Frequency, HashRate, Temperature, Voltage +from edge_mining.domain.miner.value_objects import FanSpeed, Frequency, HashRate, MinerInfo, Temperature, Voltage from edge_mining.shared.logging.port import LoggerPort @@ -43,7 +43,7 @@ class DummyMinerController( PowerControlPort, InternalFanControlPort, ExternalFanControlPort, - ModelDetectionPort, + DeviceInfoPort, ): """Simulates miner control without real hardware. @@ -66,13 +66,32 @@ def __init__( self._internal_fan_speed: float = 0.0 self._external_fan_speed: float = 0.0 - # --- ModelDetectionPort --- + # --- DeviceInfoPort --- - async def get_model(self) -> Optional[str]: - """Gets the model of the miner.""" + async def get_device_info(self) -> Optional[MinerInfo]: + """Gets the device information of the miner.""" if self.logger: - self.logger.debug("DummyController: Returning dummy model") - return "Dummy Miner S0" + self.logger.debug("DummyController: Fetching device info...") + + # Simulate some dummy device info + model = "DummyMiner X1" + serial_number = "DMX1-01}" + firmware_version = "1.0.0" + mac_address = "00:11:22:33:10:99" + hostname = "edgemining-dummyminer" + + info = MinerInfo( + model=model, + serial_number=serial_number, + firmware_version=firmware_version, + mac_address=mac_address, + hostname=hostname, + ) + + if self.logger: + self.logger.debug(f"DummyController: Device info fetched: {info}") + + return info # --- MiningControlPort --- diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index baec205..ca236ce 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -19,6 +19,7 @@ from edge_mining.domain.miner.ports import ( BoardTemperatureMonitorPort, ChipTemperatureMonitorPort, + DeviceInfoPort, FrequencyMonitorPort, HashrateMonitorPort, InletTemperatureMonitorPort, @@ -27,7 +28,6 @@ MaxHashrateDetectionPort, MaxPowerDetectionPort, MiningControlPort, - ModelDetectionPort, OutletTemperatureMonitorPort, PowerMonitorPort, StatusMonitorPort, @@ -37,6 +37,7 @@ FanSpeed, Frequency, HashRate, + MinerInfo, Temperature, Voltage, ) @@ -97,7 +98,7 @@ class PyASICMinerController( FrequencyMonitorPort, MiningControlPort, InternalFanControlPort, - ModelDetectionPort, + DeviceInfoPort, MaxPowerDetectionPort, MaxHashrateDetectionPort, ): @@ -178,10 +179,10 @@ async def _get_miner(self) -> None: if self.logger: self.logger.error(f"Failed to retrieve miner instance from {self.ip}: {e}") - # --- ModelDetectionPort --- + # --- DeviceInfoDetectionPort --- - async def get_model(self) -> Optional[str]: - """Gets the model of the miner.""" + async def get_device_info(self) -> Optional[MinerInfo]: + """Gets the device identification information of the miner, if available.""" if self.logger: self.logger.debug(f"Fetching model from {self.ip}...") @@ -193,7 +194,19 @@ async def get_model(self) -> Optional[str]: self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return None - return self._miner.model or None + serial_number = await self._miner.get_serial_number() + mac_address = await self._miner.get_mac() + model = await self._miner.get_model() + firmware_version = await self._miner.get_fw_ver() + hostname = await self._miner.get_hostname() + + return MinerInfo( + model=str(model) if model is not None else None, + serial_number=str(serial_number) if serial_number is not None else None, + firmware_version=str(firmware_version) if firmware_version is not None else None, + mac_address=str(mac_address) if mac_address is not None else None, + hostname=str(hostname) if hostname is not None else None, + ) # --- MaxPowerDetectionPort --- diff --git a/edge_mining/application/interfaces.py b/edge_mining/application/interfaces.py index 522e5b7..fefe863 100644 --- a/edge_mining/application/interfaces.py +++ b/edge_mining/application/interfaces.py @@ -17,7 +17,7 @@ from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.ports import MinerFeaturePort -from edge_mining.domain.miner.value_objects import HashRate, MinerStateSnapshot +from edge_mining.domain.miner.value_objects import HashRate, MinerInfo, MinerStateSnapshot from edge_mining.domain.notification.common import NotificationAdapter from edge_mining.domain.notification.entities import Notifier from edge_mining.domain.notification.ports import NotificationPort @@ -142,6 +142,10 @@ async def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: async def get_miner_status(self, miner_id: EntityId) -> Optional[MinerStateSnapshot]: """Gets the current status of the specified miner as a state snapshot.""" + @abstractmethod + async def get_miner_info(self, miner_id: EntityId) -> Optional[MinerInfo]: + """Gets the information of the specified miner.""" + @abstractmethod async def sync_all_miners(self, include_inactive: bool = False) -> None: """Synchronizes the status of all miners from their controllers.""" diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 123d5cd..5b19478 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -14,16 +14,16 @@ ) from edge_mining.domain.miner.ports import ( ChipTemperatureMonitorPort, + DeviceInfoPort, HashrateMonitorPort, InternalFanSpeedMonitorPort, MinerRepository, MiningControlPort, - ModelDetectionPort, PowerControlPort, PowerMonitorPort, StatusMonitorPort, ) -from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerStateSnapshot +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerInfo, MinerStateSnapshot from edge_mining.domain.notification.ports import NotificationPort from edge_mining.shared.logging.port import LoggerPort @@ -48,14 +48,21 @@ def __init__( self._event_bus = event_bus self.logger = logger - async def _try_update_model(self, miner: Miner) -> None: - """Update miner model from MODEL_DETECTION feature port if available.""" - model_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) - if model_port and isinstance(model_port, ModelDetectionPort): - current_model = await model_port.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + async def get_miner_info(self, miner_id: EntityId) -> Optional[MinerInfo]: + """Gets the information of the specified miner.""" + if self.logger: + self.logger.info(f"Getting info for miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.DEVICE_INFO_DETECTION) + if not port or not isinstance(port, DeviceInfoPort): + raise MinerControllerConfigurationError(f"No device info port available for miner {miner_id}.") + + return await port.get_device_info() async def _notify(self, notifiers: List[NotificationPort], title: str, message: str): """Sends a notification using the configured notifiers.""" @@ -95,9 +102,6 @@ async def start_miner(self, miner_id: EntityId, notifiers: Optional[List[Notific if status_port and isinstance(status_port, StatusMonitorPort): current_status = await status_port.get_status() - # Update model - await self._try_update_model(miner) - success = False if mining_port and isinstance(mining_port, MiningControlPort): success = await mining_port.start_mining() @@ -157,9 +161,6 @@ async def stop_miner(self, miner_id: EntityId, notifiers: Optional[List[Notifica if status_port and isinstance(status_port, StatusMonitorPort): current_status = await status_port.get_status() - # Update model - await self._try_update_model(miner) - success = False if mining_port and isinstance(mining_port, MiningControlPort): success = await mining_port.stop_mining() @@ -223,8 +224,6 @@ async def get_miner_hashrate(self, miner_id: EntityId) -> Optional[HashRate]: if not port or not isinstance(port, HashrateMonitorPort): raise MinerControllerConfigurationError(f"No hashrate monitor available for miner {miner_id}.") - await self._try_update_model(miner) - return await port.get_hashrate() async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: @@ -253,8 +252,6 @@ async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() - await self._try_update_model(miner) - return MinerStateSnapshot( status=current_status, hash_rate=current_hashrate, @@ -315,8 +312,6 @@ async def sync_all_miners(self, include_inactive: bool = False) -> None: if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() - await self._try_update_model(miner) - synced_count += 1 if self.logger: diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index 280c679..b2d7784 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -772,17 +772,6 @@ async def _process_single_miner_in_unit( power_consumption=current_power, ) - # Update model if available and it has changed (static config update) - model_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MODEL_DETECTION) - if model_port: - from edge_mining.domain.miner.ports import ModelDetectionPort - - if isinstance(model_port, ModelDetectionPort): - current_model = await model_port.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) - # Creates a copy of the context with the miner included, so that the policy # can access miner-specific data, without modifying the original context. # This is important to keep the context consistent across all miners in diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 5fdf953..7e98574 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -39,9 +39,9 @@ class MinerFeatureType(Enum): EXTERNAL_FAN_CONTROL = "external_fan_control" # Info - MODEL_DETECTION = "model_detection" MAX_POWER_DETECTION = "max_power_detection" MAX_HASHRATE_DETECTION = "max_hashrate_detection" + DEVICE_INFO_DETECTION = "device_info_detection" class MinerControllerAdapter(AdapterType): diff --git a/edge_mining/domain/miner/ports.py b/edge_mining/domain/miner/ports.py index a610149..b403a7b 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -11,6 +11,7 @@ FanSpeed, Frequency, HashRate, + MinerInfo, Temperature, Voltage, ) @@ -231,17 +232,6 @@ async def set_external_fan_speed(self, speed_percent: float) -> bool: # --- Info Ports --- -class ModelDetectionPort(MinerFeaturePort): - """Port for detecting miner hardware model.""" - - feature_type = MinerFeatureType.MODEL_DETECTION - - @abstractmethod - async def get_model(self) -> Optional[str]: - """Gets the model of the miner, if available.""" - raise NotImplementedError - - class MaxPowerDetectionPort(MinerFeaturePort): """Port for detecting miner maximum power consumption.""" @@ -264,6 +254,17 @@ async def get_max_hashrate(self) -> Optional[HashRate]: raise NotImplementedError +class DeviceInfoPort(MinerFeaturePort): + """Port for detecting miner device information (model, serial number, firmware version, etc.).""" + + feature_type = MinerFeatureType.DEVICE_INFO_DETECTION + + @abstractmethod + async def get_device_info(self) -> Optional[MinerInfo]: + """Gets the device identification information of the miner, if available.""" + raise NotImplementedError + + # --- Repository Ports --- diff --git a/edge_mining/domain/miner/value_objects.py b/edge_mining/domain/miner/value_objects.py index 45e1b91..4c4ddea 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -47,6 +47,17 @@ class Frequency(ValueObject): unit: str = "MHz" +@dataclass(frozen=True) +class MinerInfo(ValueObject): + """Value Object for miner device information.""" + + model: Optional[str] = None + serial_number: Optional[str] = None + firmware_version: Optional[str] = None + mac_address: Optional[str] = None + hostname: Optional[str] = None + + @dataclass(frozen=True) class MinerFeature(ValueObject): """Value Object representing a single capability provided by a controller to a miner. From 7023e6c258a9f836b0753dab6aa7b10825b4e55c Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 17:04:44 +0200 Subject: [PATCH 20/33] feat: add data integrity validation and cleanup for unknown miner features in database --- edge_mining/adapters/domain/miner/tables.py | 12 +++- .../persistence/sqlalchemy/base.py | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/edge_mining/adapters/domain/miner/tables.py b/edge_mining/adapters/domain/miner/tables.py index 6fa68b7..44672e4 100644 --- a/edge_mining/adapters/domain/miner/tables.py +++ b/edge_mining/adapters/domain/miner/tables.py @@ -268,16 +268,24 @@ def _restore_miner_composites(mapper, connection, target: Any) -> None: def load_features_for_miner(session, miner_id: EntityId) -> list[MinerFeature]: - """Load MinerFeature VOs from the miner_features table for a given miner.""" + """Load MinerFeature VOs from the miner_features table for a given miner. + + Unknown feature types (e.g. removed/renamed) are silently skipped. + """ from sqlalchemy import select stmt = select(miner_features_table).where(miner_features_table.c.miner_id == str(miner_id)) rows = session.execute(stmt).fetchall() features = [] for row in rows: + try: + feature_type = MinerFeatureType(row.feature_type) + except ValueError: + # Skip features whose type no longer exists in the enum + continue features.append( MinerFeature( - feature_type=MinerFeatureType(row.feature_type), + feature_type=feature_type, controller_id=EntityId(uuid.UUID(row.controller_id)), priority=row.priority, enabled=bool(row.enabled), diff --git a/edge_mining/adapters/infrastructure/persistence/sqlalchemy/base.py b/edge_mining/adapters/infrastructure/persistence/sqlalchemy/base.py index b0b41b4..7417c32 100644 --- a/edge_mining/adapters/infrastructure/persistence/sqlalchemy/base.py +++ b/edge_mining/adapters/infrastructure/persistence/sqlalchemy/base.py @@ -162,6 +162,7 @@ def initialize_database(self) -> None: This method handles the complete database initialization workflow: 1. Imports all table definitions (via registry_loader imported at module level) 2. Runs Alembic migrations to create/update the database schema + 3. Validates data integrity Database schema creation is EXCLUSIVELY managed through Alembic migrations: - On first run: Alembic creates the database and applies the initial migration @@ -201,3 +202,61 @@ def initialize_database(self) -> None: self.logger.warning( "Automatic migrations disabled. Database must be initialized manually with: alembic upgrade head" ) + + # Validate data integrity after migrations + self._cleanup_unknown_miner_features() + + def _cleanup_unknown_miner_features(self) -> None: + """Remove miner_features rows whose feature_type is not in MinerFeatureType. + + This handles the case where feature types were renamed or removed between + application versions, preventing load errors at runtime. + """ + from sqlalchemy import select + + from edge_mining.adapters.domain.miner.tables import miner_features_table + from edge_mining.domain.miner.common import MinerFeatureType + + valid_types = {ft.value for ft in MinerFeatureType} + + try: + session = self.get_session() + try: + rows = session.execute( + select( + miner_features_table.c.id, + miner_features_table.c.feature_type, + miner_features_table.c.miner_id, + ) + ).fetchall() + + unknown_rows = [r for r in rows if r.feature_type not in valid_types] + + if not unknown_rows: + return + + unknown_ids = [r.id for r in unknown_rows] + unknown_descriptions = [ + f"feature_type='{r.feature_type}' (miner_id={r.miner_id})" for r in unknown_rows + ] + + if self.logger: + self.logger.warning( + f"Found {len(unknown_rows)} miner feature(s) with unknown type, removing: " + + ", ".join(unknown_descriptions) + ) + + session.execute(miner_features_table.delete().where(miner_features_table.c.id.in_(unknown_ids))) + session.commit() + + if self.logger: + self.logger.info(f"Removed {len(unknown_ids)} obsolete miner feature(s) from database.") + except Exception as e: + session.rollback() + if self.logger: + self.logger.error(f"Failed to clean up unknown miner features: {e}") + finally: + session.close() + except Exception as e: + if self.logger: + self.logger.error(f"Failed to clean up unknown miner features: {e}") From 413670a3cd587d9e8757cbf14c1f10d0b101b1fa Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 17:20:17 +0200 Subject: [PATCH 21/33] feat: update internal fan speed methods to return a list of FanSpeed objects --- .../domain/miner/controllers/dummy.py | 6 ++--- .../domain/miner/controllers/pyasic.py | 23 ++++++++++--------- .../services/miner_action_service.py | 2 +- edge_mining/domain/miner/ports.py | 4 ++-- edge_mining/domain/miner/value_objects.py | 6 ++--- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index 401ebf2..94b1dfd 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -1,7 +1,7 @@ """Dummy adapter (Implementation of Feature Ports) that simulates a miner for Edge Mining Application""" import random -from typing import Optional +from typing import List, Optional from edge_mining.domain.common import Watts from edge_mining.domain.miner.common import MinerStatus @@ -232,7 +232,7 @@ async def get_outlet_temperature(self) -> Optional[Temperature]: # --- InternalFanSpeedMonitorPort --- - async def get_internal_fan_speed(self) -> Optional[FanSpeed]: + async def get_internal_fan_speed(self) -> List[FanSpeed]: """Get simulated internal fan speed.""" if self._status == MinerStatus.ON: rpm = random.uniform(3000.0, 6000.0) @@ -243,7 +243,7 @@ async def get_internal_fan_speed(self) -> Optional[FanSpeed]: fan = FanSpeed(value=round(rpm, 0)) if self.logger: self.logger.debug(f"DummyController: Reporting internal fan speed {fan.value} {fan.unit}") - return fan + return [fan] # --- ExternalFanSpeedMonitorPort --- diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index ca236ce..7669364 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -3,7 +3,7 @@ that controls a miner via pyasic. """ -from typing import Dict, Optional, Tuple, cast +from typing import Dict, List, Optional, Tuple, cast import pyasic from pyasic import AnyMiner @@ -440,27 +440,28 @@ async def get_outlet_temperature(self) -> Optional[Temperature]: # --- InternalFanSpeedMonitorPort --- - async def get_internal_fan_speed(self) -> Optional[FanSpeed]: + async def get_internal_fan_speed(self) -> List[FanSpeed]: """Gets the current internal fan speed, if available.""" + miner_fans: List[FanSpeed] = [] + if self.logger: self.logger.debug(f"Fetching internal fan speed from {self.ip}...") await self._get_miner() if not self._miner: - return None + return [] miner = self._miner - data = await miner.get_data() - if data is None or not data.fans: - return None + fans = await miner.get_fans() + if fans is None: + return [] - # Average fan speed across all fans - speeds = [fan.speed for fan in data.fans if fan.speed is not None and fan.speed > 0] - if not speeds: - return None + for fan in fans: + if fan.speed is not None: + miner_fans.append(FanSpeed(value=float(fan.speed))) - return FanSpeed(value=round(sum(speeds) / len(speeds), 0)) + return miner_fans # --- VoltageMonitorPort --- diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 5b19478..0d8d88b 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -378,7 +378,7 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi internal_fan_port = await self.adapter_service.get_miner_feature_port( temp_miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING ) - internal_fan_speed = None + internal_fan_speed = [] if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): internal_fan_speed = await internal_fan_port.get_internal_fan_speed() diff --git a/edge_mining/domain/miner/ports.py b/edge_mining/domain/miner/ports.py index b403a7b..cbb6b2c 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -134,8 +134,8 @@ class InternalFanSpeedMonitorPort(MinerFeaturePort): feature_type = MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING @abstractmethod - async def get_internal_fan_speed(self) -> Optional[FanSpeed]: - """Gets the current internal fan speed, if available.""" + async def get_internal_fan_speed(self) -> List[FanSpeed]: + """Gets the current internal fans speed, if available.""" raise NotImplementedError diff --git a/edge_mining/domain/miner/value_objects.py b/edge_mining/domain/miner/value_objects.py index 4c4ddea..ff683bf 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -1,7 +1,7 @@ """Collection of Value Objects for the Mining Device Management domain of the Edge Mining application.""" -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from typing import List, Optional from edge_mining.domain.common import EntityId, ValueObject, Watts from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus @@ -87,7 +87,7 @@ class MinerStateSnapshot(ValueObject): board_temperature: Optional[Temperature] = None inlet_temperature: Optional[Temperature] = None outlet_temperature: Optional[Temperature] = None - internal_fan_speed: Optional[FanSpeed] = None + internal_fan_speed: List[FanSpeed] = field(default_factory=list) external_fan_speed: Optional[FanSpeed] = None voltage: Optional[Voltage] = None frequency: Optional[Frequency] = None From 1db53c538925b4ac110e8db7bced03cc1385232e Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 18:02:49 +0200 Subject: [PATCH 22/33] feat: add hashboard, chip, and fan count to MinerInfo value object and PyASICMinerController --- edge_mining/adapters/domain/miner/controllers/pyasic.py | 7 +++++++ edge_mining/domain/miner/value_objects.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 7669364..7841008 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -194,6 +194,10 @@ async def get_device_info(self) -> Optional[MinerInfo]: self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return None + hashboard_count = self._miner.expected_hashboards + chip_count = self._miner.expected_chips + fan_count = self._miner.expected_fans + serial_number = await self._miner.get_serial_number() mac_address = await self._miner.get_mac() model = await self._miner.get_model() @@ -206,6 +210,9 @@ async def get_device_info(self) -> Optional[MinerInfo]: firmware_version=str(firmware_version) if firmware_version is not None else None, mac_address=str(mac_address) if mac_address is not None else None, hostname=str(hostname) if hostname is not None else None, + hashboard_count=int(hashboard_count) if hashboard_count is not None else None, + chip_count=int(chip_count) if chip_count is not None else None, + fan_count=int(fan_count) if fan_count is not None else None, ) # --- MaxPowerDetectionPort --- diff --git a/edge_mining/domain/miner/value_objects.py b/edge_mining/domain/miner/value_objects.py index ff683bf..5d88089 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -56,6 +56,9 @@ class MinerInfo(ValueObject): firmware_version: Optional[str] = None mac_address: Optional[str] = None hostname: Optional[str] = None + hashboard_count: Optional[int] = None + chip_count: Optional[int] = None + fan_count: Optional[int] = None @dataclass(frozen=True) From 50f9cf5c1ee72466c3635263abefc187d41ae746 Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 18:04:19 +0200 Subject: [PATCH 23/33] feat: add schemas for temperature, fan speed, voltage, and frequency value objects in MinerStateSnapshot --- edge_mining/adapters/domain/miner/schemas.py | 104 ++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index ec99095..d343641 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -15,7 +15,15 @@ MinerStatus, ) from edge_mining.domain.miner.entities import MinerController -from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerStateSnapshot +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashRate, + MinerFeature, + MinerStateSnapshot, + Temperature, + Voltage, +) from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, @@ -53,6 +61,50 @@ def to_model(self) -> HashRate: return HashRate(value=self.value, unit=self.unit) +class TemperatureSchema(BaseModel): + """Schema for Temperature value object.""" + + value: float = Field(..., description="Temperature value") + unit: str = Field(default="°C", description="Temperature unit") + + def to_model(self) -> Temperature: + """Convert TemperatureSchema to Temperature domain value object.""" + return Temperature(value=self.value, unit=self.unit) + + +class FanSpeedSchema(BaseModel): + """Schema for FanSpeed value object.""" + + value: float = Field(..., ge=0, description="Fan speed value, must be zero or positive") + unit: str = Field(default="RPM", description="Fan speed unit") + + def to_model(self) -> FanSpeed: + """Convert FanSpeedSchema to FanSpeed domain value object.""" + return FanSpeed(value=self.value, unit=self.unit) + + +class VoltageSchema(BaseModel): + """Schema for Voltage value object.""" + + value: float = Field(..., description="Voltage value") + unit: str = Field(default="V", description="Voltage unit") + + def to_model(self) -> Voltage: + """Convert VoltageSchema to Voltage domain value object.""" + return Voltage(value=self.value, unit=self.unit) + + +class FrequencySchema(BaseModel): + """Schema for Frequency value object.""" + + value: float = Field(..., ge=0, description="Frequency value, must be zero or positive") + unit: str = Field(default="MHz", description="Frequency unit") + + def to_model(self) -> Frequency: + """Convert FrequencySchema to Frequency domain value object.""" + return Frequency(value=self.value, unit=self.unit) + + class MinerFeatureSchema(BaseModel): """Schema for MinerFeature value object.""" @@ -194,6 +246,14 @@ class MinerStateSnapshotSchema(BaseModel): status: MinerStatus = Field(default=MinerStatus.UNKNOWN, description="Current miner status") hash_rate: Optional[HashRateSchema] = Field(default=None, description="Current hash rate") power_consumption: Optional[float] = Field(default=None, description="Current power consumption in Watts") + chip_temperature: Optional[TemperatureSchema] = Field(default=None, description="Chip temperature") + board_temperature: Optional[TemperatureSchema] = Field(default=None, description="Board temperature") + inlet_temperature: Optional[TemperatureSchema] = Field(default=None, description="Inlet temperature") + outlet_temperature: Optional[TemperatureSchema] = Field(default=None, description="Outlet temperature") + internal_fan_speed: list[FanSpeedSchema] = Field(default_factory=list, description="Internal fan speeds") + external_fan_speed: Optional[FanSpeedSchema] = Field(default=None, description="External fan speed") + voltage: Optional[VoltageSchema] = Field(default=None, description="Voltage") + frequency: Optional[FrequencySchema] = Field(default=None, description="Frequency") @classmethod def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": @@ -206,6 +266,40 @@ def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": status=snapshot.status, hash_rate=hash_rate, power_consumption=snapshot.power_consumption, + chip_temperature=( + TemperatureSchema(value=snapshot.chip_temperature.value, unit=snapshot.chip_temperature.unit) + if snapshot.chip_temperature + else None + ), + board_temperature=( + TemperatureSchema(value=snapshot.board_temperature.value, unit=snapshot.board_temperature.unit) + if snapshot.board_temperature + else None + ), + inlet_temperature=( + TemperatureSchema(value=snapshot.inlet_temperature.value, unit=snapshot.inlet_temperature.unit) + if snapshot.inlet_temperature + else None + ), + outlet_temperature=( + TemperatureSchema(value=snapshot.outlet_temperature.value, unit=snapshot.outlet_temperature.unit) + if snapshot.outlet_temperature + else None + ), + internal_fan_speed=[FanSpeedSchema(value=fs.value, unit=fs.unit) for fs in snapshot.internal_fan_speed], + external_fan_speed=( + FanSpeedSchema(value=snapshot.external_fan_speed.value, unit=snapshot.external_fan_speed.unit) + if snapshot.external_fan_speed + else None + ), + voltage=( + VoltageSchema(value=snapshot.voltage.value, unit=snapshot.voltage.unit) if snapshot.voltage else None + ), + frequency=( + FrequencySchema(value=snapshot.frequency.value, unit=snapshot.frequency.unit) + if snapshot.frequency + else None + ), ) def to_model(self) -> MinerStateSnapshot: @@ -214,6 +308,14 @@ def to_model(self) -> MinerStateSnapshot: status=MinerStatus(self.status) if isinstance(self.status, str) else self.status, hash_rate=(HashRate(value=self.hash_rate.value, unit=self.hash_rate.unit) if self.hash_rate else None), power_consumption=Watts(self.power_consumption) if self.power_consumption is not None else None, + chip_temperature=self.chip_temperature.to_model() if self.chip_temperature else None, + board_temperature=self.board_temperature.to_model() if self.board_temperature else None, + inlet_temperature=self.inlet_temperature.to_model() if self.inlet_temperature else None, + outlet_temperature=self.outlet_temperature.to_model() if self.outlet_temperature else None, + internal_fan_speed=[fs.to_model() for fs in self.internal_fan_speed], + external_fan_speed=self.external_fan_speed.to_model() if self.external_fan_speed else None, + voltage=self.voltage.to_model() if self.voltage else None, + frequency=self.frequency.to_model() if self.frequency else None, ) class Config: From c2050d89334720332001abcc11d54d40423804ca Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 18:10:30 +0200 Subject: [PATCH 24/33] feat: update pyasic dependency to version 0.78.10 in pyproject.toml and requirements.txt --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ee828f..ea451d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ solar = [ "astral>=3.2", ] pyasic = [ - "pyasic==0.76.5" + "pyasic==0.78.10" ] all = [ "edge-mining[api,homeassistant,mqtt,telegram,solar,pyasic]", diff --git a/requirements.txt b/requirements.txt index 069ed29..1bd7923 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,4 @@ paho-mqtt==2.1.0 homeassistant_api==4.2.2.post1 python-telegram-bot>=20.0 astral==3.2 -pyasic==0.77.2 +pyasic==0.78.10 From fb5daf16fc1c141a91fecf4af7a49cb8089937dc Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 20:45:25 +0200 Subject: [PATCH 25/33] feat: implement hasboard monitoring and update related schemas, ports, and services --- .../domain/miner/controllers/dummy.py | 120 ++++++++-------- .../domain/miner/controllers/pyasic.py | 133 ++++++++---------- edge_mining/adapters/domain/miner/schemas.py | 92 ++++++++---- .../services/miner_action_service.py | 12 +- .../services/optimization_service.py | 16 +-- edge_mining/domain/miner/common.py | 5 +- edge_mining/domain/miner/ports.py | 46 +----- edge_mining/domain/miner/value_objects.py | 58 +++++++- 8 files changed, 259 insertions(+), 223 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index 94b1dfd..f7b69c0 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -6,12 +6,10 @@ from edge_mining.domain.common import Watts from edge_mining.domain.miner.common import MinerStatus from edge_mining.domain.miner.ports import ( - BoardTemperatureMonitorPort, - ChipTemperatureMonitorPort, DeviceInfoPort, ExternalFanControlPort, ExternalFanSpeedMonitorPort, - FrequencyMonitorPort, + HashboardMonitorPort, HashrateMonitorPort, InletTemperatureMonitorPort, InternalFanControlPort, @@ -21,9 +19,16 @@ PowerControlPort, PowerMonitorPort, StatusMonitorPort, - VoltageMonitorPort, ) -from edge_mining.domain.miner.value_objects import FanSpeed, Frequency, HashRate, MinerInfo, Temperature, Voltage +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashboardSnapshot, + HashRate, + MinerInfo, + Temperature, + Voltage, +) from edge_mining.shared.logging.port import LoggerPort @@ -31,14 +36,11 @@ class DummyMinerController( HashrateMonitorPort, PowerMonitorPort, StatusMonitorPort, - ChipTemperatureMonitorPort, - BoardTemperatureMonitorPort, + HashboardMonitorPort, InletTemperatureMonitorPort, OutletTemperatureMonitorPort, InternalFanSpeedMonitorPort, ExternalFanSpeedMonitorPort, - VoltageMonitorPort, - FrequencyMonitorPort, MiningControlPort, PowerControlPort, InternalFanControlPort, @@ -181,33 +183,57 @@ async def get_miner_hashrate(self) -> Optional[HashRate]: self.logger.debug(f"DummyController: Reporting hash rate 0 (status: {status.name})") return HashRate(value=0.0, unit="TH/s") - # --- ChipTemperatureMonitorPort --- + # --- HashboardMonitorPort --- + + async def get_hashboards(self) -> List[HashboardSnapshot]: + """Get simulated hashboard data.""" + num_boards = 3 + snapshots: List[HashboardSnapshot] = [] + for i in range(num_boards): + if self._status == MinerStatus.ON: + chip_temp = Temperature(value=round(random.uniform(55.0, 85.0), 1)) + board_temp = Temperature(value=round(random.uniform(45.0, 70.0), 1)) + voltage = Voltage(value=round(random.uniform(11.8, 12.6), 2)) + frequency = Frequency(value=round(random.uniform(400.0, 650.0), 1)) + hr_value = round(random.uniform(0, self._hashrate_max.value / num_boards), 2) + nominal_hr = round(self._hashrate_max.value / num_boards, 2) + hash_rate = HashRate(value=hr_value, unit=self._hashrate_max.unit) + nominal_hash_rate = HashRate(value=nominal_hr, unit=self._hashrate_max.unit) + error_val = round(nominal_hr - hr_value, 4) + hash_rate_error = HashRate(value=error_val, unit=self._hashrate_max.unit) + elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): + chip_temp = Temperature(value=round(random.uniform(30.0, 55.0), 1)) + board_temp = Temperature(value=round(random.uniform(25.0, 45.0), 1)) + voltage = Voltage(value=round(random.uniform(11.0, 12.0), 2)) + frequency = Frequency(value=0.0) + hash_rate = HashRate(value=0.0, unit=self._hashrate_max.unit) + nominal_hash_rate = None + hash_rate_error = None + else: + chip_temp = Temperature(value=round(random.uniform(20.0, 30.0), 1)) + board_temp = Temperature(value=round(random.uniform(18.0, 28.0), 1)) + voltage = Voltage(value=0.0) + frequency = Frequency(value=0.0) + hash_rate = HashRate(value=0.0, unit=self._hashrate_max.unit) + nominal_hash_rate = None + hash_rate_error = None + + snapshots.append( + HashboardSnapshot( + index=i, + chip_temperature=chip_temp, + board_temperature=board_temp, + voltage=voltage, + frequency=frequency, + hash_rate=hash_rate, + nominal_hash_rate=nominal_hash_rate, + hash_rate_error=hash_rate_error, + ) + ) - async def get_chip_temperature(self) -> Optional[Temperature]: - """Get simulated chip temperature.""" - if self._status == MinerStatus.ON: - temp = Temperature(value=round(random.uniform(55.0, 85.0), 1)) - elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): - temp = Temperature(value=round(random.uniform(30.0, 55.0), 1)) - else: - temp = Temperature(value=round(random.uniform(20.0, 30.0), 1)) if self.logger: - self.logger.debug(f"DummyController: Reporting chip temperature {temp.value}{temp.unit}") - return temp - - # --- BoardTemperatureMonitorPort --- - - async def get_board_temperature(self) -> Optional[Temperature]: - """Get simulated board temperature.""" - if self._status == MinerStatus.ON: - temp = Temperature(value=round(random.uniform(45.0, 70.0), 1)) - elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): - temp = Temperature(value=round(random.uniform(25.0, 45.0), 1)) - else: - temp = Temperature(value=round(random.uniform(18.0, 28.0), 1)) - if self.logger: - self.logger.debug(f"DummyController: Reporting board temperature {temp.value}{temp.unit}") - return temp + self.logger.debug(f"DummyController: Reporting {len(snapshots)} hashboards") + return snapshots # --- InletTemperatureMonitorPort --- @@ -258,32 +284,6 @@ async def get_external_fan_speed(self) -> Optional[FanSpeed]: self.logger.debug(f"DummyController: Reporting external fan speed {fan.value} {fan.unit}") return fan - # --- VoltageMonitorPort --- - - async def get_voltage(self) -> Optional[Voltage]: - """Get simulated voltage.""" - if self._status == MinerStatus.ON: - v = Voltage(value=round(random.uniform(11.8, 12.6), 2)) - elif self._status in (MinerStatus.STARTING, MinerStatus.STOPPING): - v = Voltage(value=round(random.uniform(11.0, 12.0), 2)) - else: - v = Voltage(value=0.0) - if self.logger: - self.logger.debug(f"DummyController: Reporting voltage {v.value}{v.unit}") - return v - - # --- FrequencyMonitorPort --- - - async def get_frequency(self) -> Optional[Frequency]: - """Get simulated chip frequency.""" - if self._status == MinerStatus.ON: - f = Frequency(value=round(random.uniform(400.0, 650.0), 1)) - else: - f = Frequency(value=0.0) - if self.logger: - self.logger.debug(f"DummyController: Reporting frequency {f.value} {f.unit}") - return f - # --- PowerControlPort --- async def power_on(self) -> bool: diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 7841008..58fdee0 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -17,10 +17,8 @@ from edge_mining.domain.miner.common import MinerControllerProtocol, MinerStatus from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError from edge_mining.domain.miner.ports import ( - BoardTemperatureMonitorPort, - ChipTemperatureMonitorPort, DeviceInfoPort, - FrequencyMonitorPort, + HashboardMonitorPort, HashrateMonitorPort, InletTemperatureMonitorPort, InternalFanControlPort, @@ -31,11 +29,10 @@ OutletTemperatureMonitorPort, PowerMonitorPort, StatusMonitorPort, - VoltageMonitorPort, ) from edge_mining.domain.miner.value_objects import ( FanSpeed, - Frequency, + HashboardSnapshot, HashRate, MinerInfo, Temperature, @@ -89,13 +86,10 @@ class PyASICMinerController( HashrateMonitorPort, PowerMonitorPort, StatusMonitorPort, - ChipTemperatureMonitorPort, - BoardTemperatureMonitorPort, + HashboardMonitorPort, InletTemperatureMonitorPort, OutletTemperatureMonitorPort, InternalFanSpeedMonitorPort, - VoltageMonitorPort, - FrequencyMonitorPort, MiningControlPort, InternalFanControlPort, DeviceInfoPort, @@ -372,48 +366,75 @@ async def get_status(self) -> MinerStatus: return miner_status - # --- ChipTemperatureMonitorPort --- + # --- HashboardMonitorPort --- - async def get_chip_temperature(self) -> Optional[Temperature]: - """Gets the current chip temperature, if available.""" + async def get_hashboards(self) -> List[HashboardSnapshot]: + """Gets the current state of all hashboards.""" if self.logger: - self.logger.debug(f"Fetching chip temperature from {self.ip}...") + self.logger.debug(f"Fetching hashboard data from {self.ip}...") await self._get_miner() if not self._miner: if self.logger: self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") - return None + return [] miner = self._miner - temperature = await miner.get_env_temp() - - return Temperature(value=float(temperature)) if temperature is not None else None + hashboards = await miner.get_hashboards() + if not hashboards: + return [] - # --- BoardTemperatureMonitorPort --- + snapshots: List[HashboardSnapshot] = [] + for idx, hb in enumerate(hashboards): + chip_temp = Temperature(value=float(hb.chip_temp)) if hb.chip_temp is not None else None + board_temp = Temperature(value=float(hb.temp)) if hb.temp is not None else None + + hb_hashrate = None + if hb.hashrate is not None: + hr_val, hr_unit = self._normalize_hashrate_unit( + value=float(hb.hashrate), + unit=str(hb.hashrate.unit) if hasattr(hb.hashrate, "unit") else "TH/s", + ) + hb_hashrate = HashRate(value=hr_val, unit=hr_unit) + + hb_voltage = None + if hb.voltage is not None: + hb_voltage = Voltage(value=float(hb.voltage.value)) + + hb_frequency = None + + hb_expected_hashrate = None + # if hb.expected_hashrate is not None: + # ehr_val, ehr_unit = self._normalize_hashrate_unit( + # value=float(hb.expected_hashrate), + # unit=str(hb.expected_hashrate.unit) if hasattr(hb.expected_hashrate, "unit") else "TH/s", + # ) + # hb_expected_hashrate = HashRate(value=ehr_val, unit=ehr_unit) + + hb_hashrate_error = None + # if hb.hashrate is not None and hb.expected_hashrate is not None: + # error_val = float(hb.expected_hashrate) - float(hb.hashrate) + # error_unit = hb_hashrate.unit if hb_hashrate else "TH/s" + # hb_hashrate_error = HashRate(value=round(error_val, 4), unit=error_unit) + + snapshots.append( + HashboardSnapshot( + index=idx, + chip_temperature=chip_temp, + board_temperature=board_temp, + voltage=hb_voltage, + frequency=hb_frequency, + hash_rate=hb_hashrate, + nominal_hash_rate=hb_expected_hashrate, + hash_rate_error=hb_hashrate_error, + ) + ) - async def get_board_temperature(self) -> Optional[Temperature]: - """Gets the current board temperature, if available.""" if self.logger: - self.logger.debug(f"Fetching board temperature from {self.ip}...") + self.logger.debug(f"Hashboard data fetched: {len(snapshots)} boards from {self.ip}") - await self._get_miner() - - if not self._miner: - return None - - miner = self._miner - data = await miner.get_data() - if data is None or not data.hashboards: - return None - - # Average board temperature across all hashboards - temps = [hb.temp for hb in data.hashboards if hb.temp is not None] - if not temps: - return None - - return Temperature(value=round(sum(temps) / len(temps), 1)) + return snapshots # --- InletTemperatureMonitorPort --- @@ -470,44 +491,6 @@ async def get_internal_fan_speed(self) -> List[FanSpeed]: return miner_fans - # --- VoltageMonitorPort --- - - async def get_voltage(self) -> Optional[Voltage]: - """Gets the current voltage, if available.""" - if self.logger: - self.logger.debug(f"Fetching voltage from {self.ip}...") - - await self._get_miner() - - if not self._miner: - return None - - miner = self._miner - data = await miner.get_data() - if data is None or data.voltage is None: - return None - - return Voltage(value=float(data.voltage)) - - # --- FrequencyMonitorPort --- - - async def get_frequency(self) -> Optional[Frequency]: - """Gets the current chip operating frequency, if available.""" - if self.logger: - self.logger.debug(f"Fetching frequency from {self.ip}...") - - await self._get_miner() - - if not self._miner: - return None - - miner = self._miner - data = await miner.get_data() - if data is None or data.frequency_avg is None: - return None - - return Frequency(value=float(data.frequency_avg)) - # --- MiningControlPort --- async def start_mining(self) -> bool: diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index d343641..9e7d499 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -19,6 +19,7 @@ FanSpeed, Frequency, HashRate, + HashboardSnapshot, MinerFeature, MinerStateSnapshot, Temperature, @@ -240,20 +241,75 @@ class Config: } +class HashboardSnapshotSchema(BaseModel): + """Schema for HashboardSnapshot value object.""" + + index: int = Field(..., description="Hashboard slot/position index") + chip_temperature: Optional[TemperatureSchema] = Field(default=None, description="Chip temperature") + board_temperature: Optional[TemperatureSchema] = Field(default=None, description="Board temperature") + voltage: Optional[VoltageSchema] = Field(default=None, description="Voltage") + frequency: Optional[FrequencySchema] = Field(default=None, description="Frequency") + hash_rate: Optional[HashRateSchema] = Field(default=None, description="Current hash rate") + nominal_hash_rate: Optional[HashRateSchema] = Field(default=None, description="Nominal hash rate") + hash_rate_error: Optional[HashRateSchema] = Field(default=None, description="Hash rate error") + + @classmethod + def from_model(cls, hb: HashboardSnapshot) -> "HashboardSnapshotSchema": + """Create HashboardSnapshotSchema from a HashboardSnapshot value object.""" + return cls( + index=hb.index, + chip_temperature=( + TemperatureSchema(value=hb.chip_temperature.value, unit=hb.chip_temperature.unit) + if hb.chip_temperature + else None + ), + board_temperature=( + TemperatureSchema(value=hb.board_temperature.value, unit=hb.board_temperature.unit) + if hb.board_temperature + else None + ), + voltage=VoltageSchema(value=hb.voltage.value, unit=hb.voltage.unit) if hb.voltage else None, + frequency=FrequencySchema(value=hb.frequency.value, unit=hb.frequency.unit) if hb.frequency else None, + hash_rate=(HashRateSchema(value=hb.hash_rate.value, unit=hb.hash_rate.unit) if hb.hash_rate else None), + nominal_hash_rate=( + HashRateSchema(value=hb.nominal_hash_rate.value, unit=hb.nominal_hash_rate.unit) + if hb.nominal_hash_rate + else None + ), + hash_rate_error=( + HashRateSchema(value=hb.hash_rate_error.value, unit=hb.hash_rate_error.unit) + if hb.hash_rate_error + else None + ), + ) + + def to_model(self) -> HashboardSnapshot: + """Convert HashboardSnapshotSchema to HashboardSnapshot value object.""" + return HashboardSnapshot( + index=self.index, + chip_temperature=self.chip_temperature.to_model() if self.chip_temperature else None, + board_temperature=self.board_temperature.to_model() if self.board_temperature else None, + voltage=self.voltage.to_model() if self.voltage else None, + frequency=self.frequency.to_model() if self.frequency else None, + hash_rate=self.hash_rate.to_model() if self.hash_rate else None, + nominal_hash_rate=self.nominal_hash_rate.to_model() if self.nominal_hash_rate else None, + hash_rate_error=self.hash_rate_error.to_model() if self.hash_rate_error else None, + ) + + class MinerStateSnapshotSchema(BaseModel): """Schema for MinerStateSnapshot value object (runtime operational state).""" status: MinerStatus = Field(default=MinerStatus.UNKNOWN, description="Current miner status") hash_rate: Optional[HashRateSchema] = Field(default=None, description="Current hash rate") power_consumption: Optional[float] = Field(default=None, description="Current power consumption in Watts") - chip_temperature: Optional[TemperatureSchema] = Field(default=None, description="Chip temperature") - board_temperature: Optional[TemperatureSchema] = Field(default=None, description="Board temperature") inlet_temperature: Optional[TemperatureSchema] = Field(default=None, description="Inlet temperature") outlet_temperature: Optional[TemperatureSchema] = Field(default=None, description="Outlet temperature") internal_fan_speed: list[FanSpeedSchema] = Field(default_factory=list, description="Internal fan speeds") external_fan_speed: Optional[FanSpeedSchema] = Field(default=None, description="External fan speed") - voltage: Optional[VoltageSchema] = Field(default=None, description="Voltage") - frequency: Optional[FrequencySchema] = Field(default=None, description="Frequency") + hashboards: list[HashboardSnapshotSchema] = Field(default_factory=list, description="Per-hashboard data") + blocks_found: Optional[int] = Field(default=None, description="Blocks found count") + system_uptime: Optional[int] = Field(default=None, description="System uptime in seconds") @classmethod def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": @@ -266,16 +322,6 @@ def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": status=snapshot.status, hash_rate=hash_rate, power_consumption=snapshot.power_consumption, - chip_temperature=( - TemperatureSchema(value=snapshot.chip_temperature.value, unit=snapshot.chip_temperature.unit) - if snapshot.chip_temperature - else None - ), - board_temperature=( - TemperatureSchema(value=snapshot.board_temperature.value, unit=snapshot.board_temperature.unit) - if snapshot.board_temperature - else None - ), inlet_temperature=( TemperatureSchema(value=snapshot.inlet_temperature.value, unit=snapshot.inlet_temperature.unit) if snapshot.inlet_temperature @@ -292,14 +338,9 @@ def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": if snapshot.external_fan_speed else None ), - voltage=( - VoltageSchema(value=snapshot.voltage.value, unit=snapshot.voltage.unit) if snapshot.voltage else None - ), - frequency=( - FrequencySchema(value=snapshot.frequency.value, unit=snapshot.frequency.unit) - if snapshot.frequency - else None - ), + hashboards=[HashboardSnapshotSchema.from_model(hb) for hb in snapshot.hashboards], + blocks_found=snapshot.blocks_found, + system_uptime=snapshot.system_uptime, ) def to_model(self) -> MinerStateSnapshot: @@ -308,14 +349,13 @@ def to_model(self) -> MinerStateSnapshot: status=MinerStatus(self.status) if isinstance(self.status, str) else self.status, hash_rate=(HashRate(value=self.hash_rate.value, unit=self.hash_rate.unit) if self.hash_rate else None), power_consumption=Watts(self.power_consumption) if self.power_consumption is not None else None, - chip_temperature=self.chip_temperature.to_model() if self.chip_temperature else None, - board_temperature=self.board_temperature.to_model() if self.board_temperature else None, inlet_temperature=self.inlet_temperature.to_model() if self.inlet_temperature else None, outlet_temperature=self.outlet_temperature.to_model() if self.outlet_temperature else None, internal_fan_speed=[fs.to_model() for fs in self.internal_fan_speed], external_fan_speed=self.external_fan_speed.to_model() if self.external_fan_speed else None, - voltage=self.voltage.to_model() if self.voltage else None, - frequency=self.frequency.to_model() if self.frequency else None, + hashboards=[hb.to_model() for hb in self.hashboards], + blocks_found=self.blocks_found, + system_uptime=self.system_uptime, ) class Config: diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 0d8d88b..9650ed9 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -13,8 +13,8 @@ MinerNotFoundError, ) from edge_mining.domain.miner.ports import ( - ChipTemperatureMonitorPort, DeviceInfoPort, + HashboardMonitorPort, HashrateMonitorPort, InternalFanSpeedMonitorPort, MinerRepository, @@ -369,11 +369,11 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi current_power = await power_port.get_power() temperature_port = await self.adapter_service.get_miner_feature_port( - temp_miner, MinerFeatureType.CHIP_TEMPERATURE_MONITORING + temp_miner, MinerFeatureType.HASHBOARD_MONITORING ) - current_chip_temperature = None - if temperature_port and isinstance(temperature_port, ChipTemperatureMonitorPort): - current_chip_temperature = await temperature_port.get_chip_temperature() + current_hashboards = [] + if temperature_port and isinstance(temperature_port, HashboardMonitorPort): + current_hashboards = await temperature_port.get_hashboards() internal_fan_port = await self.adapter_service.get_miner_feature_port( temp_miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING @@ -405,7 +405,7 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi status=current_status, hash_rate=current_hashrate, power_consumption=current_power, - chip_temperature=current_chip_temperature, + hashboards=current_hashboards, internal_fan_speed=internal_fan_speed, ) diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index b2d7784..005b096 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -30,7 +30,7 @@ from edge_mining.domain.miner.events import MinerStateChangedEvent from edge_mining.domain.miner.exceptions import MinerError from edge_mining.domain.miner.ports import ( - ChipTemperatureMonitorPort, + HashboardMonitorPort, HashrateMonitorPort, InternalFanSpeedMonitorPort, MinerFeaturePort, @@ -302,17 +302,17 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() - temperature_port = await self.adapter_service.get_miner_feature_port( - miner, MinerFeatureType.CHIP_TEMPERATURE_MONITORING + hashboard_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.HASHBOARD_MONITORING ) - current_chip_temperature = None - if temperature_port and isinstance(temperature_port, ChipTemperatureMonitorPort): - current_chip_temperature = await temperature_port.get_chip_temperature() + current_hashboards = [] + if hashboard_port and isinstance(hashboard_port, HashboardMonitorPort): + current_hashboards = await hashboard_port.get_hashboards() internal_fan_port = await self.adapter_service.get_miner_feature_port( miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING ) - internal_fan_speed = None + internal_fan_speed = [] if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): internal_fan_speed = await internal_fan_port.get_internal_fan_speed() @@ -321,7 +321,7 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option status=current_status, hash_rate=current_hashrate, power_consumption=current_power, - chip_temperature=current_chip_temperature, + hashboards=current_hashboards, internal_fan_speed=internal_fan_speed, ) diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 7e98574..73a5add 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -23,14 +23,11 @@ class MinerFeatureType(Enum): HASHRATE_MONITORING = "hashrate_monitoring" POWER_MONITORING = "power_monitoring" STATUS_MONITORING = "status_monitoring" - CHIP_TEMPERATURE_MONITORING = "chip_temperature_monitoring" - BOARD_TEMPERATURE_MONITORING = "board_temperature_monitoring" + HASHBOARD_MONITORING = "hashboard_monitoring" INLET_TEMPERATURE_MONITORING = "inlet_temperature_monitoring" OUTLET_TEMPERATURE_MONITORING = "outlet_temperature_monitoring" FAN_SPEED_INTERNAL_MONITORING = "fan_speed_internal_monitoring" FAN_SPEED_EXTERNAL_MONITORING = "fan_speed_external_monitoring" - VOLTAGE_MONITORING = "voltage_monitoring" - FREQUENCY_MONITORING = "frequency_monitoring" # Control (write) MINING_CONTROL = "mining_control" diff --git a/edge_mining/domain/miner/ports.py b/edge_mining/domain/miner/ports.py index cbb6b2c..e7cd762 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -9,11 +9,10 @@ from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.value_objects import ( FanSpeed, - Frequency, + HashboardSnapshot, HashRate, MinerInfo, Temperature, - Voltage, ) # --- Feature Ports --- @@ -84,25 +83,14 @@ async def get_status(self) -> MinerStatus: raise NotImplementedError -class ChipTemperatureMonitorPort(MinerFeaturePort): - """Port for monitoring ASIC chip temperature.""" +class HashboardMonitorPort(MinerFeaturePort): + """Port for monitoring per-hashboard data (temperatures, voltage, frequency, hashrate).""" - feature_type = MinerFeatureType.CHIP_TEMPERATURE_MONITORING + feature_type = MinerFeatureType.HASHBOARD_MONITORING @abstractmethod - async def get_chip_temperature(self) -> Optional[Temperature]: - """Gets the current chip temperature, if available.""" - raise NotImplementedError - - -class BoardTemperatureMonitorPort(MinerFeaturePort): - """Port for monitoring board temperature.""" - - feature_type = MinerFeatureType.BOARD_TEMPERATURE_MONITORING - - @abstractmethod - async def get_board_temperature(self) -> Optional[Temperature]: - """Gets the current board temperature, if available.""" + async def get_hashboards(self) -> List[HashboardSnapshot]: + """Gets the current state of all hashboards.""" raise NotImplementedError @@ -150,28 +138,6 @@ async def get_external_fan_speed(self) -> Optional[FanSpeed]: raise NotImplementedError -class VoltageMonitorPort(MinerFeaturePort): - """Port for monitoring miner voltage.""" - - feature_type = MinerFeatureType.VOLTAGE_MONITORING - - @abstractmethod - async def get_voltage(self) -> Optional[Voltage]: - """Gets the current voltage, if available.""" - raise NotImplementedError - - -class FrequencyMonitorPort(MinerFeaturePort): - """Port for monitoring chip operating frequency.""" - - feature_type = MinerFeatureType.FREQUENCY_MONITORING - - @abstractmethod - async def get_frequency(self) -> Optional[Frequency]: - """Gets the current chip operating frequency, if available.""" - raise NotImplementedError - - # --- Control Ports (write) --- diff --git a/edge_mining/domain/miner/value_objects.py b/edge_mining/domain/miner/value_objects.py index 5d88089..7763151 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -74,6 +74,23 @@ class MinerFeature(ValueObject): enabled: bool = True +@dataclass(frozen=True) +class HashboardSnapshot(ValueObject): + """Value Object representing a snapshot of a single hashboard's state. + + Aggregates per-board metrics: temperatures, electrical parameters, and hashrate data. + """ + + index: int + chip_temperature: Optional[Temperature] = None + board_temperature: Optional[Temperature] = None + voltage: Optional[Voltage] = None + frequency: Optional[Frequency] = None + hash_rate: Optional[HashRate] = None + nominal_hash_rate: Optional[HashRate] = None + hash_rate_error: Optional[HashRate] = None + + @dataclass(frozen=True) class MinerStateSnapshot(ValueObject): """Value Object representing a snapshot of a miner's operational state at a given moment. @@ -81,16 +98,49 @@ class MinerStateSnapshot(ValueObject): This is used by the Rule Engine, Policy Rules, and the DecisionalContext for decision-making. It has no repository — it is created on-the-fly from controller data. + + Per-board data (chip/board temperature, voltage, frequency) is in hashboards. + Convenience properties (max_chip_temperature, max_board_temperature) are provided + for rule engine access without iterating boards. """ status: MinerStatus = MinerStatus.UNKNOWN hash_rate: Optional[HashRate] = None power_consumption: Optional[Watts] = None - chip_temperature: Optional[Temperature] = None - board_temperature: Optional[Temperature] = None inlet_temperature: Optional[Temperature] = None outlet_temperature: Optional[Temperature] = None internal_fan_speed: List[FanSpeed] = field(default_factory=list) external_fan_speed: Optional[FanSpeed] = None - voltage: Optional[Voltage] = None - frequency: Optional[Frequency] = None + hashboards: List[HashboardSnapshot] = field(default_factory=list) + blocks_found: Optional[int] = None + system_uptime: Optional[int] = None # seconds + + @property + def max_chip_temperature(self) -> Optional[Temperature]: + """Returns the maximum chip temperature across all hashboards.""" + temps = [hb.chip_temperature for hb in self.hashboards if hb.chip_temperature is not None] + return max(temps, key=lambda t: t.value) if temps else None + + @property + def max_board_temperature(self) -> Optional[Temperature]: + """Returns the maximum board temperature across all hashboards.""" + temps = [hb.board_temperature for hb in self.hashboards if hb.board_temperature is not None] + return max(temps, key=lambda t: t.value) if temps else None + + @property + def avg_chip_temperature(self) -> Optional[Temperature]: + """Returns the average chip temperature across all hashboards.""" + temps = [hb.chip_temperature for hb in self.hashboards if hb.chip_temperature is not None] + if not temps: + return None + avg = round(sum(t.value for t in temps) / len(temps), 1) + return Temperature(value=avg, unit=temps[0].unit) + + @property + def avg_board_temperature(self) -> Optional[Temperature]: + """Returns the average board temperature across all hashboards.""" + temps = [hb.board_temperature for hb in self.hashboards if hb.board_temperature is not None] + if not temps: + return None + avg = round(sum(t.value for t in temps) / len(temps), 1) + return Temperature(value=avg, unit=temps[0].unit) From 742e16ab7c22ba3e3a3b5bdd383ac7daa0b51cb2 Mon Sep 17 00:00:00 2001 From: markoceri Date: Thu, 9 Apr 2026 23:12:44 +0200 Subject: [PATCH 26/33] feat: add sync_miner_features method to AdapterService for feature reconciliation --- edge_mining/application/interfaces.py | 7 +++ .../application/services/adapter_service.py | 58 ++++++++++++++++++- edge_mining/bootstrap.py | 1 + .../services/test_configuration_event_flow.py | 2 + 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/edge_mining/application/interfaces.py b/edge_mining/application/interfaces.py index fefe863..4d8a784 100644 --- a/edge_mining/application/interfaces.py +++ b/edge_mining/application/interfaces.py @@ -56,6 +56,13 @@ async def get_miner_controller_adapter(self, miner: Miner, controller_id: Entity async def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: """Get the adapter implementing the highest-priority active feature port for a miner.""" + @abstractmethod + async def sync_miner_features(self, miner: Miner) -> bool: + """Reconcile stored features with what controllers actually support. + + Returns True if any changes were made. + """ + @abstractmethod async def get_all_notifiers(self) -> List[NotificationPort]: """Get all notifier adapter instances""" diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index e1ba231..2d8d4b4 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -35,7 +35,8 @@ from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType from edge_mining.domain.miner.entities import MinerController -from edge_mining.domain.miner.ports import MinerControllerRepository, MinerFeaturePort +from edge_mining.domain.miner.ports import MinerControllerRepository, MinerFeaturePort, MinerRepository +from edge_mining.domain.miner.value_objects import MinerFeature from edge_mining.domain.notification.common import NotificationAdapter from edge_mining.domain.notification.entities import Notifier from edge_mining.domain.notification.ports import NotificationPort, NotifierRepository @@ -65,6 +66,7 @@ def __init__( self, energy_monitor_repo: EnergyMonitorRepository, miner_controller_repo: MinerControllerRepository, + miner_repo: MinerRepository, notifier_repo: NotifierRepository, forecast_provider_repo: ForecastProviderRepository, mining_performance_tracker_repo: MiningPerformanceTrackerRepository, @@ -75,6 +77,7 @@ def __init__( ): self.energy_monitor_repo = energy_monitor_repo self.miner_controller_repo = miner_controller_repo + self.miner_repo = miner_repo self.notifier_repo = notifier_repo self.forecast_provider_repo = forecast_provider_repo self.mining_performance_tracker_repo = mining_performance_tracker_repo @@ -625,13 +628,66 @@ async def get_miner_controller_adapter(self, miner: Miner, controller_id: Entity return None return await self._initialize_miner_controller_adapter(miner, miner_controller) + async def sync_miner_features(self, miner: Miner) -> bool: + """Reconcile stored features with what controllers actually support. + + For each controller associated with the miner, discovers which features + the adapter supports and adds missing ones / removes stale ones. + Returns True if any changes were made (and persisted). + """ + changed = False + controller_ids = miner.get_controller_ids() + + for controller_id in controller_ids: + adapter = await self.get_miner_controller_adapter(miner, controller_id) + if not adapter: + continue + + supported = set(adapter.__class__.get_supported_features()) + stored = {f.feature_type for f in miner.get_features_by_controller(controller_id)} + + # Add missing features + for feature_type in supported - stored: + feature = MinerFeature( + feature_type=feature_type, + controller_id=controller_id, + priority=50, + enabled=True, + ) + try: + miner.add_feature(feature) + changed = True + except ValueError: + pass + + # Remove stale features (stored but no longer supported) + for feature_type in stored - supported: + miner.remove_feature(feature_type, controller_id) + changed = True + + if changed: + self.miner_repo.update(miner) + if self.logger: + self.logger.info(f"Reconciled features for miner {miner.name}.") + + return changed + async def get_miner_feature_port(self, miner: Miner, feature_type: MinerFeatureType) -> Optional[MinerFeaturePort]: """Get the adapter implementing the highest-priority active feature for a miner. Resolves the active MinerFeature for the given feature_type, retrieves the associated controller adapter, and verifies it supports the feature. + If the feature is not found, triggers a one-time reconciliation to catch + features added or removed by code changes. """ active_feature = miner.get_active_feature(feature_type) + + # Lazy reconciliation: if feature not found, sync and retry once + if not active_feature: + reconciled = await self.sync_miner_features(miner) + if reconciled: + active_feature = miner.get_active_feature(feature_type) + if not active_feature: if self.logger: self.logger.debug(f"No active feature of type {feature_type.value} for miner {miner.name}.") diff --git a/edge_mining/bootstrap.py b/edge_mining/bootstrap.py index d139021..9d8532c 100644 --- a/edge_mining/bootstrap.py +++ b/edge_mining/bootstrap.py @@ -297,6 +297,7 @@ def configure_dependencies(logger: LoggerPort, settings: AppSettings) -> Service adapter_service = AdapterService( energy_monitor_repo=persistence_settings.energy_monitor_repo, miner_controller_repo=persistence_settings.miner_controller_repo, + miner_repo=persistence_settings.miner_repo, notifier_repo=persistence_settings.notifier_repo, forecast_provider_repo=persistence_settings.forecast_provider_repo, home_forecast_provider_repo=persistence_settings.home_forecast_provider_repo, diff --git a/tests/unit/application/services/test_configuration_event_flow.py b/tests/unit/application/services/test_configuration_event_flow.py index 710c809..c1c367c 100644 --- a/tests/unit/application/services/test_configuration_event_flow.py +++ b/tests/unit/application/services/test_configuration_event_flow.py @@ -186,6 +186,7 @@ async def test_end_to_end_cache_invalidation(mock_persistence, logger): adapter_service = AdapterService( energy_monitor_repo=mock_persistence.energy_monitor_repo, miner_controller_repo=mock_persistence.miner_controller_repo, + miner_repo=mock_persistence.miner_repo, notifier_repo=mock_persistence.notifier_repo, forecast_provider_repo=mock_persistence.forecast_provider_repo, home_forecast_provider_repo=mock_persistence.home_forecast_provider_repo, @@ -233,6 +234,7 @@ async def test_external_service_update_clears_all_instance_cache(mock_persistenc adapter_service = AdapterService( energy_monitor_repo=mock_persistence.energy_monitor_repo, miner_controller_repo=mock_persistence.miner_controller_repo, + miner_repo=mock_persistence.miner_repo, notifier_repo=mock_persistence.notifier_repo, forecast_provider_repo=mock_persistence.forecast_provider_repo, home_forecast_provider_repo=mock_persistence.home_forecast_provider_repo, From 643fa18cb41650fc69c23a54806adb6bcd0d1163 Mon Sep 17 00:00:00 2001 From: markoceri Date: Fri, 10 Apr 2026 11:39:29 +0200 Subject: [PATCH 27/33] feat: add operational monitoring port and related methods for blocks found and system uptime --- .../domain/miner/controllers/dummy.py | 10 + .../domain/miner/controllers/pyasic.py | 458 +++++++++++++++++- edge_mining/adapters/domain/miner/schemas.py | 29 ++ .../services/miner_action_service.py | 24 + .../services/optimization_service.py | 23 + edge_mining/domain/miner/common.py | 1 + edge_mining/domain/miner/ports.py | 16 + 7 files changed, 539 insertions(+), 22 deletions(-) diff --git a/edge_mining/adapters/domain/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index f7b69c0..fdb71ab 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -15,6 +15,7 @@ InternalFanControlPort, InternalFanSpeedMonitorPort, MiningControlPort, + OperationalMonitorPort, OutletTemperatureMonitorPort, PowerControlPort, PowerMonitorPort, @@ -46,6 +47,7 @@ class DummyMinerController( InternalFanControlPort, ExternalFanControlPort, DeviceInfoPort, + OperationalMonitorPort, ): """Simulates miner control without real hardware. @@ -144,6 +146,14 @@ async def get_miner_status(self) -> MinerStatus: self.logger.debug(f"DummyController: Reporting status {status.name}") return status + async def get_blocks_found(self) -> Optional[int]: + """Gets the total number of blocks found.""" + return 0 + + async def get_system_uptime(self) -> Optional[int]: + """Gets the system uptime in seconds.""" + return 3600 + # --- PowerMonitorPort --- async def get_miner_power(self) -> Optional[Watts]: diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index 58fdee0..ac0fd0f 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -26,12 +26,14 @@ MaxHashrateDetectionPort, MaxPowerDetectionPort, MiningControlPort, + OperationalMonitorPort, OutletTemperatureMonitorPort, PowerMonitorPort, StatusMonitorPort, ) from edge_mining.domain.miner.value_objects import ( FanSpeed, + Frequency, HashboardSnapshot, HashRate, MinerInfo, @@ -95,6 +97,7 @@ class PyASICMinerController( DeviceInfoPort, MaxPowerDetectionPort, MaxHashrateDetectionPort, + OperationalMonitorPort, ): """Controls a miner via pyasic. Implements multiple feature ports.""" @@ -121,7 +124,10 @@ def __init__( def _log_configuration(self): if self.logger: - self.logger.debug(f"Entities Configured: IP={self.ip}") + self.logger.debug( + f"PyASIC Controller configured: IP={self.ip}, protocol={self.protocol}, " + f"port={self.port}, username={self.username}, pwd={'***' if self.password else None}" + ) async def _get_miner(self) -> None: """Retrieve the pyasic miner instance.""" @@ -168,7 +174,14 @@ async def _get_miner(self) -> None: 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}") + self.logger.debug( + f"Miner identified: type={type(self._miner).__name__}, " + f"model={self._miner.raw_model}, firmware={self._miner.firmware}, " + f"rpc={type(self._miner.rpc).__name__ if self._miner.rpc else None}, " + f"web={type(self._miner.web).__name__ if self._miner.web else None}, " + f"ssh={type(self._miner.ssh).__name__ if self._miner.ssh else None}, " + f"expected_hashboards={self._miner.expected_hashboards}" + ) except Exception as e: if self.logger: self.logger.error(f"Failed to retrieve miner instance from {self.ip}: {e}") @@ -366,6 +379,33 @@ async def get_status(self) -> MinerStatus: return miner_status + # --- OperationalMonitorPort --- + + async def get_blocks_found(self) -> Optional[int]: + """Gets the total number of blocks found by the miner.""" + await self._get_miner() + if not self._miner or not self._miner.rpc: + return None + try: + summary = await self._miner.rpc.send_command("summary") + + blocks_found = summary.get("SUMMARY", [{}])[0].get("Found Blocks") + return blocks_found if blocks_found is not None else None + except Exception: + return None + + async def get_system_uptime(self) -> Optional[int]: + """Gets the system uptime in seconds.""" + await self._get_miner() + if not self._miner or not self._miner.rpc: + return None + try: + summary = await self._miner.rpc.send_command("summary") + elapsed = summary.get("SUMMARY", [{}])[0].get("Elapsed") + return int(elapsed) if elapsed is not None else None + except Exception: + return None + # --- HashboardMonitorPort --- async def get_hashboards(self) -> List[HashboardSnapshot]: @@ -382,9 +422,30 @@ async def get_hashboards(self) -> List[HashboardSnapshot]: miner = self._miner hashboards = await miner.get_hashboards() + + if self.logger: + self.logger.debug( + f"pyasic get_hashboards() returned {len(hashboards)} boards. " + f"Raw data: {[(hb.slot, hb.chip_temp, hb.temp, hb.voltage, hb.hashrate) for hb in hashboards]}" + ) + + # Fallback: if get_hashboards() returns empty (e.g. expected_hashboards is None), + # query the RPC directly and build HashBoard objects from raw data. + if not hashboards: + if self.logger: + self.logger.debug(f"get_hashboards() returned empty for {self.ip}, trying RPC fallback...") + hashboards = await self._fetch_hashboards_fallback(miner) + if not hashboards: + if self.logger: + self.logger.debug(f"No hashboard data available from {self.ip}") return [] + # Supplement missing voltage/frequency from devdetails RPC + # pyasic's _get_hashboards() only extracts Chips from devdetails, + # but BOSer firmware also reports Voltage and Frequency there. + devdetails_extra = await self._fetch_devdetails_extra(miner) + snapshots: List[HashboardSnapshot] = [] for idx, hb in enumerate(hashboards): chip_temp = Temperature(value=float(hb.chip_temp)) if hb.chip_temp is not None else None @@ -398,25 +459,16 @@ async def get_hashboards(self) -> List[HashboardSnapshot]: ) hb_hashrate = HashRate(value=hr_val, unit=hr_unit) - hb_voltage = None - if hb.voltage is not None: - hb_voltage = Voltage(value=float(hb.voltage.value)) + hb_voltage = Voltage(value=round(float(hb.voltage), 2)) if hb.voltage is not None else None + hb_frequency: Optional[Frequency] = None - hb_frequency = None - - hb_expected_hashrate = None - # if hb.expected_hashrate is not None: - # ehr_val, ehr_unit = self._normalize_hashrate_unit( - # value=float(hb.expected_hashrate), - # unit=str(hb.expected_hashrate.unit) if hasattr(hb.expected_hashrate, "unit") else "TH/s", - # ) - # hb_expected_hashrate = HashRate(value=ehr_val, unit=ehr_unit) - - hb_hashrate_error = None - # if hb.hashrate is not None and hb.expected_hashrate is not None: - # error_val = float(hb.expected_hashrate) - float(hb.hashrate) - # error_unit = hb_hashrate.unit if hb_hashrate else "TH/s" - # hb_hashrate_error = HashRate(value=round(error_val, 4), unit=error_unit) + # Fill voltage/frequency from devdetails if pyasic didn't provide them + if idx < len(devdetails_extra): + extra = devdetails_extra[idx] + if hb_voltage is None and extra.get("voltage") is not None: + hb_voltage = Voltage(value=round(float(extra["voltage"]), 2)) + if extra.get("frequency") is not None: + hb_frequency = Frequency(value=float(extra["frequency"])) snapshots.append( HashboardSnapshot( @@ -426,8 +478,8 @@ async def get_hashboards(self) -> List[HashboardSnapshot]: voltage=hb_voltage, frequency=hb_frequency, hash_rate=hb_hashrate, - nominal_hash_rate=hb_expected_hashrate, - hash_rate_error=hb_hashrate_error, + nominal_hash_rate=None, + hash_rate_error=None, ) ) @@ -560,6 +612,368 @@ async def set_internal_fan_speed(self, speed_percent: float) -> bool: # --- Private helpers --- + async def _fetch_devdetails_extra(self, miner: AnyMiner) -> List[dict]: + """Fetch Voltage and Frequency from devdetails RPC. + + pyasic's _get_hashboards() only extracts Chips from devdetails, + but some firmwares (e.g. BOSer) also report Voltage and Frequency. + Returns a list of dicts (one per board, positional order) with + 'voltage' and 'frequency' keys. + """ + if miner.rpc is None: + return [] + + try: + rpc_data = await miner.rpc.send_command("devdetails") + except Exception as e: + if self.logger: + self.logger.debug(f"devdetails RPC failed: {e}") + return [] + + details = rpc_data.get("DEVDETAILS", []) + if not details: + return [] + + # Sort by ID and extract voltage/frequency by positional index + sorted_details = sorted(details, key=lambda d: d.get("ID", 0)) + result = [] + for d in sorted_details: + result.append( + { + "voltage": d.get("Voltage"), + "frequency": d.get("Frequency"), + } + ) + + if self.logger: + self.logger.debug(f"devdetails extra: {result}") + + return result + + async def _fetch_hashboards_fallback(self, miner: AnyMiner) -> list: + """Fallback: fetch hashboard data directly when get_hashboards() returns empty. + + This handles cases where pyasic's expected_hashboards is None (e.g., unrecognized + miner model/firmware combination), causing get_hashboards() to return []. + Tries gRPC first (BOSer), then RPC (BOSMiner/legacy), building HashBoard objects + from the raw response. + """ + if self.logger: + self.logger.debug( + f"Fallback check: miner.web={miner.web}, " + f"type={type(miner.web).__name__ if miner.web else None}, " + f"has get_hashboards={hasattr(miner.web, 'get_hashboards') if miner.web else False}, " + f"miner type={type(miner).__name__}" + ) + + # --- Try gRPC (BOSer firmware) --- + grpc_result = await self._try_grpc_hashboards(miner) + if grpc_result is not None: + return grpc_result + + # --- Try Luci web overview (BOSMinerWebAPI) --- + luci_result = await self._try_luci_hashboards(miner) + if luci_result is not None: + return luci_result + + # --- Try RPC (BOSMiner / legacy firmware) --- + rpc_result = await self._try_rpc_hashboards(miner) + if rpc_result is not None: + return rpc_result + + if self.logger: + self.logger.debug("No RPC or web interface available for fallback") + return [] + + async def _try_grpc_hashboards(self, miner: AnyMiner) -> Optional[list]: + """Try fetching hashboard data via gRPC. Returns None if not available.""" + from pyasic.data import HashBoard + + web_api = miner.web + + # If the current web class doesn't support get_hashboards, + # try BOSerWebAPI directly (handles pyasic misidentifying BOSer as BOSMiner) + if web_api is None or not hasattr(web_api, "get_hashboards"): + try: + from pyasic.web.braiins_os.boser import BOSerWebAPI + + web_api = BOSerWebAPI(str(miner.ip)) + if self.logger: + self.logger.debug(f"Created direct BOSerWebAPI for {self.ip}") + except ImportError: + return None + + try: + grpc_data = await web_api.get_hashboards() + if self.logger: + self.logger.debug(f"gRPC fallback raw response: {grpc_data}") + except Exception as e: + if self.logger: + self.logger.debug(f"gRPC fallback failed: {e}") + return None + + if not grpc_data or not grpc_data.get("hashboards"): + return None + + grpc_boards = sorted(grpc_data["hashboards"], key=lambda x: int(x.get("id", 0))) + hashboards = [] + for idx, board in enumerate(grpc_boards): + hb = HashBoard(slot=idx, expected_chips=miner.expected_chips) + hb.missing = False + + if board.get("boardTemp") is not None: + hb.temp = int(board["boardTemp"]["degreeC"]) + if board.get("highestChipTemp") is not None: + hb.chip_temp = int(board["highestChipTemp"]["temperature"]["degreeC"]) + if board.get("chipsCount") is not None: + hb.chips = board["chipsCount"] + if board.get("serialNumber") is not None: + hb.serial_number = board["serialNumber"] + if board.get("stats") is not None: + try: + real_hr = board["stats"]["realHashrate"]["last5S"] + if real_hr and real_hr.get("gigahashPerSecond") is not None: + hb.hashrate = miner.algo.hashrate( + rate=float(real_hr["gigahashPerSecond"]), + unit=miner.algo.unit.GH, + ) + except (KeyError, TypeError): + pass + hashboards.append(hb) + + if self.logger: + self.logger.debug(f"gRPC fallback: found {len(hashboards)} hashboards from {self.ip}") + return hashboards + + async def _try_luci_hashboards(self, miner: AnyMiner) -> Optional[list]: + """Try fetching hashboard data via Luci web API (get_api_status). + + The get_api_status endpoint returns all RPC data in one call, including + temps, devs, devdetails, fans, and summary. This is the most complete + data source on BOSer firmware when gRPC is unavailable. + """ + from pyasic.data import HashBoard + + if miner.web is None or not hasattr(miner.web, "get_api_status"): + return None + + # Ensure web credentials are set (override defaults if controller has credentials) + if self.password: + miner.web.pwd = self.password + if self.username: + miner.web.username = self.username + + try: + api_status = await miner.web.get_api_status() + except Exception as e: + if self.logger: + self.logger.debug(f"Luci get_api_status failed: {e}") + return None + + if not api_status or not isinstance(api_status, dict): + return None + + # Parse temps from api_status (may be "Not ready" on old firmware) + temps_list: list = [] + try: + temps_data = api_status["temps"][0] + if temps_data.get("TEMPS"): + for board in temps_data["TEMPS"]: + temps_list.append( + { + "chip_temp": round(board["Chip"]) if board.get("Chip") is not None else None, + "board_temp": round(board["Board"]) if board.get("Board") is not None else None, + } + ) + else: + status_info = temps_data.get("STATUS", [{}])[0] + if self.logger: + self.logger.debug( + f"Luci temps not available: {status_info.get('Msg', 'unknown')} " + f"(code {status_info.get('Code', '?')})" + ) + except (KeyError, IndexError, TypeError): + pass + + # Parse devs from api_status + devs_list: list = [] + try: + for dev in api_status["devs"][0]["DEVS"]: + devs_list.append({"mhs": dev.get("MHS 1m") or dev.get("MHS 5m") or dev.get("MHS av")}) + except (KeyError, IndexError, TypeError): + pass + + # Parse devdetails from api_status for voltage and frequency + details_list: list = [] + try: + for detail in api_status["devdetails"][0]["DEVDETAILS"]: + details_list.append( + { + "voltage": detail.get("Voltage"), + "frequency": detail.get("Frequency"), + } + ) + except (KeyError, IndexError, TypeError): + pass + + board_count = max(len(temps_list), len(devs_list), len(details_list)) + if board_count == 0: + return None + + hashboards = [] + for idx in range(board_count): + hb = HashBoard(slot=idx, expected_chips=miner.expected_chips) + hb.missing = False + + if idx < len(temps_list): + t = temps_list[idx] + if t.get("chip_temp") is not None: + hb.chip_temp = t["chip_temp"] + if t.get("board_temp") is not None: + hb.temp = t["board_temp"] + + if idx < len(devs_list): + dev = devs_list[idx] + mhs = dev.get("mhs") + if mhs is not None: + hb.hashrate = miner.algo.hashrate(rate=mhs, unit=miner.algo.unit.MH) + + if idx < len(details_list): + d = details_list[idx] + if d.get("voltage") is not None: + hb.voltage = round(float(d["voltage"]), 2) + + hashboards.append(hb) + + if self.logger: + self.logger.debug(f"Luci api_status: found {len(hashboards)} hashboards from {self.ip}") + return hashboards + + async def _try_rpc_hashboards(self, miner: AnyMiner) -> Optional[list]: + """Try fetching hashboard data via RPC. Returns None if not available.""" + from pyasic.data import HashBoard + + if miner.rpc is None: + return None + + try: + rpc_data = await miner.rpc.multicommand("temps", "devdetails", "devs", "stats") + except Exception as e: + if self.logger: + self.logger.debug(f"RPC fallback multicommand failed: {e}") + return None + + if self.logger: + self.logger.debug(f"RPC fallback raw response keys: {list(rpc_data.keys())}") + + # Parse temps by positional index (BOSMiner firmware) + temps_list: list = [] + try: + for board in rpc_data["temps"][0]["TEMPS"]: + temps_list.append( + { + "chip_temp": round(board["Chip"]) if board.get("Chip") is not None else None, + "board_temp": round(board["Board"]) if board.get("Board") is not None else None, + } + ) + except (KeyError, IndexError, TypeError): + pass + + # Parse temps from stats if temps command didn't return data (BOSer firmware). + # The stats response contains temperature fields like temp_chip_N, temp_board_N, temp2_N, etc. + if not temps_list: + try: + stats_entries = rpc_data["stats"][0]["STATS"] + if self.logger: + self.logger.debug(f"RPC stats entries: {stats_entries}") + for stat in stats_entries: + # Look for per-chain temperature keys found in various CGMiner-based firmwares + # Common patterns: temp_chip_1..N, temp_board_1..N, temp2_1..N, temp_1..N + chain_idx = 0 + while True: + chain_num = chain_idx + 1 + chip_temp = None + board_temp = None + + # Try various known key patterns for chip temperature + for key in [f"temp_chip_{chain_num}", f"temp2_{chain_num}", f"temp{chain_num}"]: + val = stat.get(key) + if val is not None and float(val) > 0: + chip_temp = round(float(val)) + break + + # Try various known key patterns for board temperature + for key in [f"temp_board_{chain_num}", f"temp_pcb_{chain_num}", f"temp{chain_num}"]: + val = stat.get(key) + if val is not None and float(val) > 0: + # Avoid using the same key for both if chip_temp already used it + if board_temp is None: + board_temp = round(float(val)) + + if chip_temp is None and board_temp is None: + break + temps_list.append({"chip_temp": chip_temp, "board_temp": board_temp}) + chain_idx += 1 + except (KeyError, IndexError, TypeError): + pass + + # Parse devdetails by positional index + details_list: list = [] + try: + for detail in rpc_data["devdetails"][0]["DEVDETAILS"]: + details_list.append( + { + "chips": detail.get("Chips"), + "voltage": detail.get("Voltage"), + "frequency": detail.get("Frequency"), + } + ) + except (KeyError, IndexError, TypeError): + pass + + # Parse devs by positional index + devs_list: list = [] + try: + for dev in rpc_data["devs"][0]["DEVS"]: + devs_list.append({"mhs": dev.get("MHS 1m") or dev.get("MHS 5m") or dev.get("MHS av")}) + except (KeyError, IndexError, TypeError): + pass + + board_count = max(len(temps_list), len(details_list), len(devs_list)) + if board_count == 0: + return None + + hashboards = [] + for idx in range(board_count): + hb = HashBoard(slot=idx, expected_chips=miner.expected_chips) + hb.missing = False + + if idx < len(temps_list): + t = temps_list[idx] + if t.get("chip_temp") is not None: + hb.chip_temp = t["chip_temp"] + if t.get("board_temp") is not None: + hb.temp = t["board_temp"] + + if idx < len(details_list): + d = details_list[idx] + if d.get("chips") is not None: + hb.chips = d["chips"] + if d.get("voltage") is not None: + hb.voltage = round(float(d["voltage"]), 2) + + if idx < len(devs_list): + dev = devs_list[idx] + mhs = dev.get("mhs") + if mhs is not None: + hb.hashrate = miner.algo.hashrate(rate=mhs, unit=miner.algo.unit.MH) + + hashboards.append(hb) + + if self.logger: + self.logger.debug(f"RPC fallback: found {len(hashboards)} hashboards from {self.ip}") + return hashboards + async def _derive_miner_status(self) -> Optional[bool]: """Derives the miner status based on hashrate and power consumption. diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 9e7d499..7c2bcd5 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -310,6 +310,18 @@ class MinerStateSnapshotSchema(BaseModel): hashboards: list[HashboardSnapshotSchema] = Field(default_factory=list, description="Per-hashboard data") blocks_found: Optional[int] = Field(default=None, description="Blocks found count") system_uptime: Optional[int] = Field(default=None, description="System uptime in seconds") + max_chip_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Maximum chip temperature across all hashboards" + ) + max_board_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Maximum board temperature across all hashboards" + ) + avg_chip_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Average chip temperature across all hashboards" + ) + avg_board_temperature: Optional[TemperatureSchema] = Field( + default=None, description="Average board temperature across all hashboards" + ) @classmethod def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": @@ -318,6 +330,11 @@ def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": if snapshot.hash_rate: hash_rate = HashRateSchema(value=snapshot.hash_rate.value, unit=snapshot.hash_rate.unit) + max_chip_temp = snapshot.max_chip_temperature + max_board_temp = snapshot.max_board_temperature + avg_chip_temp = snapshot.avg_chip_temperature + avg_board_temp = snapshot.avg_board_temperature + return cls( status=snapshot.status, hash_rate=hash_rate, @@ -341,6 +358,18 @@ def from_model(cls, snapshot: MinerStateSnapshot) -> "MinerStateSnapshotSchema": hashboards=[HashboardSnapshotSchema.from_model(hb) for hb in snapshot.hashboards], blocks_found=snapshot.blocks_found, system_uptime=snapshot.system_uptime, + max_chip_temperature=( + TemperatureSchema(value=max_chip_temp.value, unit=max_chip_temp.unit) if max_chip_temp else None + ), + max_board_temperature=( + TemperatureSchema(value=max_board_temp.value, unit=max_board_temp.unit) if max_board_temp else None + ), + avg_chip_temperature=( + TemperatureSchema(value=avg_chip_temp.value, unit=avg_chip_temp.unit) if avg_chip_temp else None + ), + avg_board_temperature=( + TemperatureSchema(value=avg_board_temp.value, unit=avg_board_temp.unit) if avg_board_temp else None + ), ) def to_model(self) -> MinerStateSnapshot: diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 9650ed9..5eb9885 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -19,6 +19,7 @@ InternalFanSpeedMonitorPort, MinerRepository, MiningControlPort, + OperationalMonitorPort, PowerControlPort, PowerMonitorPort, StatusMonitorPort, @@ -252,10 +253,22 @@ async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() + operational_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + # If operational monitoring is available, it may provide blocks found and uptime + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + return MinerStateSnapshot( status=current_status, hash_rate=current_hashrate, power_consumption=current_power, + blocks_found=blocks_found, + system_uptime=system_uptime, ) async def sync_all_miners(self, include_inactive: bool = False) -> None: @@ -356,6 +369,15 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi if status_port and isinstance(status_port, StatusMonitorPort): current_status = await status_port.get_status() + operational_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + hashrate_port = await self.adapter_service.get_miner_feature_port( temp_miner, MinerFeatureType.HASHRATE_MONITORING ) @@ -407,6 +429,8 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi power_consumption=current_power, hashboards=current_hashboards, internal_fan_speed=internal_fan_speed, + blocks_found=blocks_found, + system_uptime=system_uptime, ) if self.logger: diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index 005b096..4f6aaa9 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -36,6 +36,7 @@ MinerFeaturePort, MinerRepository, MiningControlPort, + OperationalMonitorPort, PowerControlPort, PowerMonitorPort, StatusMonitorPort, @@ -290,6 +291,15 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option current_status = await status_port.get_status() + operational_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + hashrate_port = await self.adapter_service.get_miner_feature_port( miner, MinerFeatureType.HASHRATE_MONITORING ) @@ -323,6 +333,8 @@ async def get_decisional_context(self, optimization_unit_id: EntityId) -> Option power_consumption=current_power, hashboards=current_hashboards, internal_fan_speed=internal_fan_speed, + blocks_found=blocks_found, + system_uptime=system_uptime, ) break # We found a valid miner and controller, we can stop looking for more miners @@ -753,6 +765,15 @@ async def _process_single_miner_in_unit( # Query current state via feature ports current_status = await status_port.get_status() + operational_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.OPERATIONAL_MONITORING + ) + blocks_found = None + system_uptime = None + if operational_port and isinstance(operational_port, OperationalMonitorPort): + blocks_found = await operational_port.get_blocks_found() + system_uptime = await operational_port.get_system_uptime() + hashrate_port = await self.adapter_service.get_miner_feature_port( miner, MinerFeatureType.HASHRATE_MONITORING ) @@ -770,6 +791,8 @@ async def _process_single_miner_in_unit( status=current_status, hash_rate=current_hashrate, power_consumption=current_power, + blocks_found=blocks_found, + system_uptime=system_uptime, ) # Creates a copy of the context with the miner included, so that the policy diff --git a/edge_mining/domain/miner/common.py b/edge_mining/domain/miner/common.py index 73a5add..1b45a95 100644 --- a/edge_mining/domain/miner/common.py +++ b/edge_mining/domain/miner/common.py @@ -28,6 +28,7 @@ class MinerFeatureType(Enum): OUTLET_TEMPERATURE_MONITORING = "outlet_temperature_monitoring" FAN_SPEED_INTERNAL_MONITORING = "fan_speed_internal_monitoring" FAN_SPEED_EXTERNAL_MONITORING = "fan_speed_external_monitoring" + OPERATIONAL_MONITORING = "operational_monitoring" # Control (write) MINING_CONTROL = "mining_control" diff --git a/edge_mining/domain/miner/ports.py b/edge_mining/domain/miner/ports.py index e7cd762..435e25a 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -138,6 +138,22 @@ async def get_external_fan_speed(self) -> Optional[FanSpeed]: raise NotImplementedError +class OperationalMonitorPort(MinerFeaturePort): + """Port for monitoring overall miner operational state (e.g., blocks found, uptime).""" + + feature_type = MinerFeatureType.OPERATIONAL_MONITORING + + @abstractmethod + async def get_blocks_found(self) -> Optional[int]: + """Gets the total number of blocks found by the miner, if available.""" + raise NotImplementedError + + @abstractmethod + async def get_system_uptime(self) -> Optional[int]: + """Gets the system uptime in seconds, if available.""" + raise NotImplementedError + + # --- Control Ports (write) --- From 2f81edf3b0acdb9eb0d3d53b5f278cf1b54755b6 Mon Sep 17 00:00:00 2001 From: markoceri Date: Fri, 10 Apr 2026 12:09:25 +0200 Subject: [PATCH 28/33] feat: enhance miner info retrieval with hashboards and internal fan speed monitoring --- .../application/services/miner_action_service.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 5eb9885..bd1af25 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -253,13 +253,24 @@ async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: if power_port and isinstance(power_port, PowerMonitorPort): current_power = await power_port.get_power() + hashboard_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHBOARD_MONITORING) + current_hashboards = [] + if hashboard_port and isinstance(hashboard_port, HashboardMonitorPort): + current_hashboards = await hashboard_port.get_hashboards() + + internal_fan_port = await self.adapter_service.get_miner_feature_port( + miner, MinerFeatureType.FAN_SPEED_INTERNAL_MONITORING + ) + internal_fan_speed = [] + if internal_fan_port and isinstance(internal_fan_port, InternalFanSpeedMonitorPort): + internal_fan_speed = await internal_fan_port.get_internal_fan_speed() + operational_port = await self.adapter_service.get_miner_feature_port( miner, MinerFeatureType.OPERATIONAL_MONITORING ) blocks_found = None system_uptime = None if operational_port and isinstance(operational_port, OperationalMonitorPort): - # If operational monitoring is available, it may provide blocks found and uptime blocks_found = await operational_port.get_blocks_found() system_uptime = await operational_port.get_system_uptime() @@ -267,6 +278,8 @@ async def get_miner_status(self, miner_id: EntityId) -> MinerStateSnapshot: status=current_status, hash_rate=current_hashrate, power_consumption=current_power, + hashboards=current_hashboards, + internal_fan_speed=internal_fan_speed, blocks_found=blocks_found, system_uptime=system_uptime, ) From 833d576c68bef759d2e4cb6107ea8a25ed0f1da0 Mon Sep 17 00:00:00 2001 From: markoceri Date: Fri, 10 Apr 2026 12:54:50 +0200 Subject: [PATCH 29/33] feat: add enable, disable, and set priority methods for miner features in ConfigurationService --- .../adapters/domain/miner/fast_api/router.py | 64 ++++++++++++++++++- edge_mining/adapters/domain/miner/schemas.py | 8 ++- edge_mining/application/interfaces.py | 18 ++++++ .../services/configuration_service.py | 40 +++++++++++- 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/edge_mining/adapters/domain/miner/fast_api/router.py b/edge_mining/adapters/domain/miner/fast_api/router.py index da8f9c9..163163b 100644 --- a/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/edge_mining/adapters/domain/miner/fast_api/router.py @@ -7,6 +7,7 @@ from edge_mining.adapters.domain.miner.schemas import ( MINER_CONTROLLER_CONFIG_SCHEMA_MAP, + FeaturePrioritySchema, MinerControllerCreateSchema, MinerControllerSchema, MinerControllerUpdateSchema, @@ -30,7 +31,7 @@ ) from edge_mining.domain.common import EntityId, Watts from edge_mining.domain.miner.aggregate_roots import Miner -from edge_mining.domain.miner.common import MinerControllerAdapter +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType from edge_mining.domain.miner.exceptions import ( MinerControllerAlreadyExistsError, MinerControllerConfigurationError, @@ -348,6 +349,67 @@ async def get_miner_features( raise HTTPException(status_code=500, detail=str(e)) from e +@router.post("/miners/{miner_id}/features/{controller_id}/{feature_type}/enable", response_model=MinerSchema) +async def enable_miner_feature( + miner_id: EntityId, + controller_id: EntityId, + feature_type: str, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Enable a specific feature on a miner.""" + try: + ft = MinerFeatureType(feature_type) + miner = await config_service.enable_miner_feature(miner_id, controller_id, ft) + return MinerSchema.from_model(miner) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid feature type: {feature_type}") from e + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/miners/{miner_id}/features/{controller_id}/{feature_type}/disable", response_model=MinerSchema) +async def disable_miner_feature( + miner_id: EntityId, + controller_id: EntityId, + feature_type: str, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Disable a specific feature on a miner.""" + try: + ft = MinerFeatureType(feature_type) + miner = await config_service.disable_miner_feature(miner_id, controller_id, ft) + return MinerSchema.from_model(miner) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid feature type: {feature_type}") from e + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.put("/miners/{miner_id}/features/{controller_id}/{feature_type}/priority", response_model=MinerSchema) +async def set_miner_feature_priority( + miner_id: EntityId, + controller_id: EntityId, + feature_type: str, + body: FeaturePrioritySchema, + config_service: Annotated[ConfigurationServiceInterface, Depends(get_config_service)], +) -> MinerSchema: + """Set the priority of a specific feature on a miner.""" + try: + ft = MinerFeatureType(feature_type) + miner = await config_service.set_miner_feature_priority(miner_id, controller_id, ft, body.priority) + return MinerSchema.from_model(miner) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + @router.post("/miners/{miner_id}/set-controller", response_model=MinerSchema) async def set_miner_controller( miner_id: EntityId, diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 7c2bcd5..720d545 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -18,8 +18,8 @@ from edge_mining.domain.miner.value_objects import ( FanSpeed, Frequency, - HashRate, HashboardSnapshot, + HashRate, MinerFeature, MinerStateSnapshot, Temperature, @@ -106,6 +106,12 @@ def to_model(self) -> Frequency: return Frequency(value=self.value, unit=self.unit) +class FeaturePrioritySchema(BaseModel): + """Schema for setting feature priority.""" + + priority: int = Field(..., ge=1, le=100, description="Priority value (1-100, higher wins)") + + class MinerFeatureSchema(BaseModel): """Schema for MinerFeature value object.""" diff --git a/edge_mining/application/interfaces.py b/edge_mining/application/interfaces.py index 4d8a784..4846fdb 100644 --- a/edge_mining/application/interfaces.py +++ b/edge_mining/application/interfaces.py @@ -265,6 +265,24 @@ async def set_miner_controller(self, controller_id: EntityId, miner_id: EntityId async def unlink_controller_from_miner(self, controller_id: EntityId, miner_id: EntityId) -> None: """Remove all features provided by a controller from a miner.""" + @abstractmethod + async def enable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Enable a specific feature on a miner.""" + + @abstractmethod + async def disable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Disable a specific feature on a miner.""" + + @abstractmethod + async def set_miner_feature_priority( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType, priority: int + ) -> Miner: + """Set the priority of a specific feature on a miner.""" + @abstractmethod def check_miner_controller(self, controller: MinerController) -> bool: """Check if a miner controller is valid and can be used.""" diff --git a/edge_mining/application/services/configuration_service.py b/edge_mining/application/services/configuration_service.py index dc19658..31e0d83 100644 --- a/edge_mining/application/services/configuration_service.py +++ b/edge_mining/application/services/configuration_service.py @@ -28,7 +28,7 @@ from edge_mining.domain.home_load.exceptions import HomeForecastProviderNotFoundError from edge_mining.domain.home_load.ports import HomeForecastProviderRepository from edge_mining.domain.miner.aggregate_roots import Miner -from edge_mining.domain.miner.common import MinerControllerAdapter +from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.exceptions import ( MinerControllerConfigurationError, @@ -1615,6 +1615,44 @@ async def unlink_controller_from_miner(self, controller_id: EntityId, miner_id: miner.remove_features_by_controller(controller_id) self.miner_repo.update(miner) + async def enable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Enable a specific feature on a miner.""" + self.logger.info(f"Enabling feature {feature_type} from controller {controller_id} on miner {miner_id}") + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + miner.enable_feature(feature_type, controller_id) + self.miner_repo.update(miner) + return miner + + async def disable_miner_feature( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType + ) -> Miner: + """Disable a specific feature on a miner.""" + self.logger.info(f"Disabling feature {feature_type} from controller {controller_id} on miner {miner_id}") + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + miner.disable_feature(feature_type, controller_id) + self.miner_repo.update(miner) + return miner + + async def set_miner_feature_priority( + self, miner_id: EntityId, controller_id: EntityId, feature_type: MinerFeatureType, priority: int + ) -> Miner: + """Set the priority of a specific feature on a miner.""" + self.logger.info( + f"Setting priority {priority} for feature {feature_type} from controller {controller_id} on miner {miner_id}" + ) + miner = self.miner_repo.get_by_id(miner_id) + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + miner.set_priority(feature_type, controller_id, priority) + self.miner_repo.update(miner) + return miner + def check_miner_controller(self, controller: MinerController) -> bool: """Check if a miner controller is valid and can be used.""" self.logger.debug(f"Checking miner controller {controller.id} ({controller.name})") From ec9c8efeaf3d871ba3caa3497525a4319853fb13 Mon Sep 17 00:00:00 2001 From: markoceri Date: Fri, 10 Apr 2026 20:21:19 +0200 Subject: [PATCH 30/33] feat: update version to 0.1.0-rev2 and document new features and changes in CHANGELOG --- CHANGELOG.md | 114 +++++++++++++++++++++++++++++++++++++ edge_mining/__version__.py | 2 +- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64458fa..b5b9de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,103 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0-rev2] + +### Added +- **Miner Aggregate Root** (`edge_mining/domain/miner/aggregate_roots.py`): + - Promotes `Miner` to a full aggregate root with feature management capabilities + - Feature CRUD: `add_feature()`, `remove_feature()`, `remove_features_by_controller()` + - Feature queries: `get_active_feature()`, `get_features_by_controller()`, `get_features_by_type()`, `get_controller_ids()`, `has_feature()` + - Feature configuration: `enable_feature()`, `disable_feature()`, `set_feature_priority()` + +- **Miner Feature System** (`edge_mining/domain/miner/ports.py`): + - `MinerFeature` value object with identity based on `(feature_type, controller_id)` pair and configurable priority/enabled state + - `MinerFeatureType` enum with 17 values across 4 categories: monitoring (9), control (4), detection (3) + - `MinerFeaturePort` abstract base with MRO-based introspection via `get_supported_features()` class method + - **Monitoring Ports**: `HashrateMonitorPort`, `PowerMonitorPort`, `StatusMonitorPort`, `HashboardMonitorPort`, `InletTemperatureMonitorPort`, `OutletTemperatureMonitorPort`, `InternalFanSpeedMonitorPort`, `ExternalFanSpeedMonitorPort`, `OperationalMonitorPort` + - **Control Ports**: `MiningControlPort`, `PowerControlPort`, `InternalFanControlPort`, `ExternalFanControlPort` + - **Detection Ports**: `MaxPowerDetectionPort`, `MaxHashrateDetectionPort`, `DeviceInfoPort` + +- **New Value Objects** (`edge_mining/domain/miner/value_objects.py`): + - Measurement types: `Temperature`, `FanSpeed`, `Voltage`, `Frequency` frozen dataclasses with value and unit + - `MinerInfo`: Device information with model, serial number, firmware version, MAC address, hostname, hashboard/chip/fan count + - `HashboardSnapshot`: Per-board metrics (chip/board temperature, voltage, frequency, hash rate, nominal hash rate, hash rate error) + - Extended `MinerStateSnapshot` with: `inlet_temperature`, `outlet_temperature`, `internal_fan_speed` (list), `hashboards` (list), and convenience properties (`max_chip_temperature`, `avg_board_temperature`, etc.) + +- **New Pydantic Schemas** (`edge_mining/adapters/domain/miner/schemas.py`): + - `TemperatureSchema`, `FanSpeedSchema`, `VoltageSchema`, `FrequencySchema` with unit validation + - `HashboardSnapshotSchema`, `MinerInfoSchema`, `MinerFeatureSchema`, `FeaturePrioritySchema` + +- **`miner_features` Database Table** (`edge_mining/adapters/domain/miner/tables.py`): + - Columns: `id`, `miner_id` (FK), `controller_id` (FK), `feature_type`, `priority` (default 50), `enabled` (default True) + - Helper functions: `load_features_for_miner()`, `save_features_for_miner()` + +- **New API Endpoints** (`edge_mining/adapters/domain/miner/fast_api/router.py`): + - `GET /miners/{miner_id}/features` — List miner features + - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/enable` — Enable a feature + - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/disable` — Disable a feature + - `PUT /miners/{miner_id}/features/{controller_id}/{feature_type}/priority` — Set feature priority + - `POST /miners/{miner_id}/link-controller/{controller_id}` — Link controller and auto-create features + - `POST /miners/{miner_id}/unlink-controller` — Remove all features from a controller + +### Changed +- **Full Async Refactoring**: + - All `MinerActionServiceInterface` methods are now `async`: `start_miner()`, `stop_miner()`, `get_miner_status()`, `get_miner_consumption()`, `get_miner_hashrate()`, `get_miner_info()`, `sync_all_miners()` + - All `ConfigurationServiceInterface` miner management methods are now `async`: `add_miner()`, `update_miner()`, `remove_miner()`, `activate_miner()`, `deactivate_miner()`, `add_miner_controller()`, `update_miner_controller()`, `remove_miner_controller()` + - Miner controller adapters, energy providers, forecast providers, and external services refactored to support asynchronous operations + - Miner feature port methods updated to `async` + - `OptimizationService` methods `get_decisional_context()` and `test_rules()` are now `async` + +- **`AdapterService`** (`edge_mining/application/services/adapter_service.py`): + - New methods: `get_miner_controller_adapter()`, `get_miner_feature_port()` for dynamic port-based adapter resolution + - `sync_miner_features()` method for reconciling stored vs. actual controller features + - Async initialization of external services with instance caching + +- **`ConfigurationService`** (`edge_mining/application/services/configuration_service.py`): + - New methods: `set_miner_controller()`, `unlink_controller_from_miner()`, `unlink_miner_controller()`, `enable_miner_feature()`, `disable_miner_feature()`, `set_miner_feature_priority()` + +- **`MinerActionService`** (`edge_mining/application/services/miner_action_service.py`): + - Uses `AdapterService` to dynamically resolve feature ports instead of direct controller access + - New `get_miner_info()` method using `DeviceInfoPort` + +- **CLI Commands** (`edge_mining/adapters/domain/miner/cli/commands.py`): + - Refactored miner controller handling to support linking after creation + - New `unlink_controller_from_miner()` command + - Uses `run_async_func()` for async service calls + +- **Dependencies**: Updated `pyasic` to version `0.78.10` + +### Fixed +- Fixed data integrity validation and cleanup for unknown miner features in database + ## [0.1.0-rev1] ### Added +- **Event-Driven Architecture**: + - `InMemoryEventBus` (`edge_mining/adapters/infrastructure/event_bus/in_memory_event_bus.py`): Dual delivery mode event bus supporting blocking and fire-and-forget handlers via `asyncio.create_task()` + - `ConfigurationUpdatedEvent` (`edge_mining/application/events/configuration_events.py`): Application-level event for cache invalidation with `ConfigurationUpdatedEventType` and `ConfigurationAction` enums + - `MinerStateChangedEvent` (`edge_mining/domain/miner/events.py`): Emitted on miner start/stop with old and new status + - `EnergyStateSnapshotUpdatedEvent` (`edge_mining/domain/energy/events.py`): Emitted when energy state is read + - `RuleEngagedEvent` (`edge_mining/domain/optimization_unit/events.py`): Emitted when a policy rule produces a mining decision + - `DecisionalContextUpdatedEvent` (`edge_mining/domain/policy/events.py`): Emitted when decisional context is composed + +- **WebSocket Infrastructure**: + - `WebSocketManager` (`edge_mining/adapters/infrastructure/websocket/manager.py`): Real-time event broadcasting to connected clients with wildcard topic subscriptions (e.g. `energy.*`, `miner.state`) + - `WebSocketEventHandler` base class and 5 domain handlers: `MinerWebSocketHandler`, `EnergyWebSocketHandler`, `PolicyWebSocketHandler`, `OptimizationUnitWebSocketHandler`, `ConfigurationWebSocketHandler` + - Available topics: `config.updated`, `energy.state`, `miner.state`, `policy.context`, `rule.engaged` + - `WebSocketMessage` NamedTuple and `WebSocketEventRegistration` dataclass for type-safe event routing + +- **Testing**: + - Unit tests for all 5 domain events (`tests/unit/application/events/`) + - Unit tests for `DomainEvent` base class (`tests/unit/domain/test_events.py`) + - Unit tests for `WebSocketManager` (`tests/unit/adapters/infrastructure/websocket/test_websocket_manager.py`): lifecycle, subscriptions, wildcard matching, broadcast + - Unit tests for `InMemoryEventBus` (`tests/unit/adapters/infrastructure/test_in_memory_event_bus.py`) + - Integration tests for configuration event flow (`tests/unit/application/services/test_configuration_event_flow.py`) + +- **Documentation**: + - `docs/architecture/event_bus.md` — Event Bus architecture design + - `docs/WEBSOCKET.md` — WebSocket client guide and architecture + - **`MinerStateSnapshot` Value Object** (`edge_mining/domain/miner/value_objects.py`): - New frozen dataclass representing the runtime operational state of a miner - Fields: `status` (MinerStatus), `hash_rate` (Optional[HashRate]), `power_consumption` (Optional[Watts]) @@ -49,6 +143,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `GET /miners/{miner_id}/status`: Now returns `MinerStateSnapshotSchema` instead of `MinerSchema` - `GET /miner-controllers/{controller_id}/miner-details`: Now returns `MinerStateSnapshotSchema` +- **`AdapterService`** (`edge_mining/application/services/adapter_service.py`): + - Event subscription for `ConfigurationUpdatedEvent` to invalidate service caches + +- **`ConfigurationService`** (`edge_mining/application/services/configuration_service.py`): + - Publishes `ConfigurationUpdatedEvent` on all configuration changes + +- **`MinerActionService`** (`edge_mining/application/services/miner_action_service.py`): + - `start_miner()`/`stop_miner()` now publish `MinerStateChangedEvent` via event bus + +- **`bootstrap.py`**: Instantiates `InMemoryEventBus` and injects it into all services; adds `init_websocket_dependencies()` call at startup; runs `sync_all_miners()` on application start + - **CLI Commands** (`edge_mining/adapters/domain/miner/cli/commands.py`): - Removed status display from `list_miners()` and `print_miner_details()` @@ -72,8 +177,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed columns `status`, `hash_rate`, `power_consumption` from `miners` table definition - Removed `MinerStatusType()` reference (custom type no longer exists) +- **WebSocket event handlers**: Refactored to use topic strings and return `WebSocketMessage` payloads; `broadcast_message()` updated to use `WebSocketMessage` type + +- **`FORECAST_PROVIDER`** added to `ConfigurationUpdatedEventType` enum + ### Fixed - Fixed unterminated string literal in `MinerActionServiceInterface` docstring +- Fixed connection error handling for Home Assistant API with client reset and improved logging +- Fixed external service update logic to handle missing configuration classes +- Fixed database directory creation for SQLite connections when using SQLAlchemy persistence adapter +- Fixed critical error handling replaced with warnings for missing energy values in Home Assistant monitors +- Fixed missing import for sqlalchemy in migration script ## [0.1.0] diff --git a/edge_mining/__version__.py b/edge_mining/__version__.py index 75fc217..f1b1ccc 100644 --- a/edge_mining/__version__.py +++ b/edge_mining/__version__.py @@ -1,4 +1,4 @@ """Edge Mining version information.""" -__version__ = "0.1.0-rev1" +__version__ = "0.1.0-rev2" __version_info__ = tuple(str(x) for x in __version__.split(".")) From a589ab96d56db3dfc1006b3c5d6deb47cea6196b Mon Sep 17 00:00:00 2001 From: markoceri Date: Fri, 10 Apr 2026 22:13:35 +0200 Subject: [PATCH 31/33] feat: add endpoint to retrieve miner device information and corresponding schema --- CHANGELOG.md | 1 + .../domain/miner/controllers/pyasic.py | 219 +++++++++++++++++- .../adapters/domain/miner/fast_api/router.py | 22 ++ edge_mining/adapters/domain/miner/schemas.py | 28 +++ 4 files changed, 262 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b9de5..6bc9069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Helper functions: `load_features_for_miner()`, `save_features_for_miner()` - **New API Endpoints** (`edge_mining/adapters/domain/miner/fast_api/router.py`): + - `GET /miners/{miner_id}/info` — Get miner device information (model, serial number, firmware version, etc.) - `GET /miners/{miner_id}/features` — List miner features - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/enable` — Enable a feature - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/disable` — Disable a feature diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index ac0fd0f..ec66777 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -201,15 +201,46 @@ async def get_device_info(self) -> Optional[MinerInfo]: self.logger.error(f"Failed to retrieve miner instance from {self.ip}...") return None - hashboard_count = self._miner.expected_hashboards - chip_count = self._miner.expected_chips - fan_count = self._miner.expected_fans + miner = self._miner + + # --- pyasic native values (may be None for some firmwares) --- + hashboard_count = miner.expected_hashboards + chip_count = miner.expected_chips + fan_count = miner.expected_fans + + serial_number = await miner.get_serial_number() + mac_address = await miner.get_mac() + model = await miner.get_model() + firmware_version = await miner.get_fw_ver() + hostname = await miner.get_hostname() + + # --- RPC fallbacks for missing fields --- + if miner.rpc is not None: + rpc_info = await self._fetch_device_info_from_rpc(miner) + + if firmware_version is None and rpc_info.get("firmware_version"): + firmware_version = rpc_info["firmware_version"] + + if (model is None or str(model) in ("", "Unknown", "Unknown (BOS+)")) and rpc_info.get("model"): + model = rpc_info["model"] + + if hashboard_count is None and rpc_info.get("hashboard_count") is not None: + hashboard_count = rpc_info["hashboard_count"] - serial_number = await self._miner.get_serial_number() - mac_address = await self._miner.get_mac() - model = await self._miner.get_model() - firmware_version = await self._miner.get_fw_ver() - hostname = await self._miner.get_hostname() + if chip_count is None and rpc_info.get("chip_count") is not None: + chip_count = rpc_info["chip_count"] + + if fan_count is None and rpc_info.get("fan_count") is not None: + fan_count = rpc_info["fan_count"] + + if mac_address is None and rpc_info.get("mac_address"): + mac_address = rpc_info["mac_address"] + + if hostname is None and rpc_info.get("hostname"): + hostname = rpc_info["hostname"] + + if serial_number is None and rpc_info.get("serial_number"): + serial_number = rpc_info["serial_number"] return MinerInfo( model=str(model) if model is not None else None, @@ -222,6 +253,178 @@ async def get_device_info(self) -> Optional[MinerInfo]: fan_count=int(fan_count) if fan_count is not None else None, ) + async def _fetch_device_info_from_rpc(self, miner: AnyMiner) -> dict: + """Fetch device info fields from direct RPC commands and the Luci web API. + + Queries RPC (version, config, devdetails, fans, pools) and + Luci web endpoints (iface_status, cfg_data) to fill gaps + left by pyasic native methods. + """ + info: Dict[str, object] = {} + + # --- version → firmware_version --- + try: + ver_data = await miner.rpc.send_command("version") + version_entries = ver_data.get("VERSION", []) + if version_entries: + entry = version_entries[0] + # Try BOSer, then CGMiner, then BMMiner key + fw = entry.get("BOSer") or entry.get("CGMiner") or entry.get("BMMiner") + if fw: + info["firmware_version"] = str(fw) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC version failed for device info: {e}") + + # --- config → hashboard_count (ASC Count) --- + try: + cfg_data = await miner.rpc.send_command("config") + config_entries = cfg_data.get("CONFIG", []) + if config_entries: + asc_count = config_entries[0].get("ASC Count") + if asc_count is not None and int(asc_count) > 0: + info["hashboard_count"] = int(asc_count) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC config failed for device info: {e}") + + # --- devdetails → model, chip_count (intermittent on some firmwares) --- + try: + dd_data = await miner.rpc.send_command("devdetails") + details = dd_data.get("DEVDETAILS", []) + if details: + model_str = details[0].get("Model") + if model_str: + info["model"] = str(model_str) + # Chips per board × number of boards = total chip count + chips_per_board = details[0].get("Chips") + if chips_per_board is not None: + board_count = info.get("hashboard_count") or len(details) + info["chip_count"] = int(chips_per_board) * int(board_count) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC devdetails failed for device info: {e}") + + # --- fans → fan_count --- + try: + fans_data = await miner.rpc.send_command("fans") + fans_list = fans_data.get("FANS", []) + if fans_list: + info["fan_count"] = len(fans_list) + except Exception as e: + if self.logger: + self.logger.debug(f"RPC fans failed for device info: {e}") + + # --- pools → hostname (from pool worker name) --- + try: + pools_data = await miner.rpc.send_command("pools") + pools_list = pools_data.get("POOLS", []) + for pool in pools_list: + user = pool.get("User", "") + if "." in user: + # Worker name format: "username.worker" — worker is often the hostname + worker = user.rsplit(".", 1)[1] + if worker: + info["hostname"] = str(worker) + break + except Exception as e: + if self.logger: + self.logger.debug(f"RPC pools failed for device info: {e}") + + # --- Luci web API → mac_address --- + # --- GraphQL API → model, hostname, serial_number (hwid) --- + web_info = await self._fetch_device_info_from_web() + if web_info.get("mac_address"): + info["mac_address"] = web_info["mac_address"] + if web_info.get("model") and "model" not in info: + info["model"] = web_info["model"] + if web_info.get("hostname"): + info.setdefault("hostname", web_info["hostname"]) + if web_info.get("serial_number"): + info["serial_number"] = web_info["serial_number"] + + if self.logger: + self.logger.debug(f"RPC device info fallback: {info}") + + return info + + async def _fetch_device_info_from_web(self) -> dict: + """Fetch device info from the miner's web APIs (GraphQL + Luci). + + - GraphQL (/graphql): hwid (serial), hostname, modelName — no auth required. + - Luci (form auth): MAC address from iface_status/lan — requires credentials. + """ + import httpx + + info: Dict[str, str] = {} + base_url = f"http://{self.ip}" + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: + # --- GraphQL (no auth needed) → serial_number, hostname, model --- + try: + gql_query = {"query": "{ bos { hostname hwid } bosminer { info { modelName } } }"} + resp = await client.post(f"{base_url}/graphql", json=gql_query) + if resp.status_code == 200: + gql_data = resp.json().get("data", {}) + bos = gql_data.get("bos") or {} + bosminer = gql_data.get("bosminer") or {} + + hwid = bos.get("hwid") + if hwid: + info["serial_number"] = str(hwid) + + hostname = bos.get("hostname") + if hostname: + info["hostname"] = str(hostname) + + model_name = (bosminer.get("info") or {}).get("modelName") + if model_name: + info["model"] = str(model_name) + except Exception as e: + if self.logger: + self.logger.debug(f"GraphQL device info failed: {e}") + + # --- Luci (form auth) → MAC address --- + if self.password: + try: + luci_headers = { + "User-Agent": "BTC Tools v0.1", + "Content-Type": "application/x-www-form-urlencoded", + } + login_data = { + "luci_username": self.username or "root", + "luci_password": self.password, + } + await client.post( + f"{base_url}/cgi-bin/luci", + headers=luci_headers, + data=login_data, + ) + + if client.cookies: + resp = await client.get( + f"{base_url}/cgi-bin/luci/admin/network/iface_status/lan", + headers={"User-Agent": "BTC Tools v0.1"}, + ) + if resp.status_code == 200: + data = resp.json() + if isinstance(data, list) and data: + mac = data[0].get("macaddr") + if mac: + info["mac_address"] = str(mac).upper() + elif self.logger: + self.logger.debug("Luci form auth failed (no session cookie)") + except Exception as e: + if self.logger: + self.logger.debug(f"Luci MAC fetch failed: {e}") + + except Exception as e: + if self.logger: + self.logger.debug(f"Web device info fetch failed: {e}") + + return info + # --- MaxPowerDetectionPort --- async def get_max_power(self) -> Optional[Watts]: diff --git a/edge_mining/adapters/domain/miner/fast_api/router.py b/edge_mining/adapters/domain/miner/fast_api/router.py index 163163b..5fa2d1f 100644 --- a/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/edge_mining/adapters/domain/miner/fast_api/router.py @@ -13,6 +13,7 @@ MinerControllerUpdateSchema, MinerCreateSchema, MinerFeatureSchema, + MinerInfoSchema, MinerSchema, MinerStateSnapshotSchema, MinerUpdateSchema, @@ -294,6 +295,27 @@ async def get_miner_status( raise HTTPException(status_code=500, detail=str(e)) from e +@router.get("/miners/{miner_id}/info", response_model=Optional[MinerInfoSchema]) +async def get_miner_info( + miner_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> Optional[MinerInfoSchema]: + """Get device information for a specific miner.""" + try: + info = await action_service.get_miner_info(miner_id) + + if info is None: + return None + + return MinerInfoSchema.from_model(info) + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + @router.post("/miners/{miner_id}/activate", response_model=MinerSchema) async def activate_miner( miner_id: EntityId, diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 720d545..7a8c384 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -21,6 +21,7 @@ HashboardSnapshot, HashRate, MinerFeature, + MinerInfo, MinerStateSnapshot, Temperature, Voltage, @@ -403,6 +404,33 @@ class Config: } +class MinerInfoSchema(BaseModel): + """Schema for MinerInfo value object.""" + + model: Optional[str] = Field(default=None, description="Miner model") + serial_number: Optional[str] = Field(default=None, description="Serial number") + firmware_version: Optional[str] = Field(default=None, description="Firmware version") + mac_address: Optional[str] = Field(default=None, description="MAC address") + hostname: Optional[str] = Field(default=None, description="Hostname") + hashboard_count: Optional[int] = Field(default=None, description="Number of hashboards") + chip_count: Optional[int] = Field(default=None, description="Number of chips") + fan_count: Optional[int] = Field(default=None, description="Number of fans") + + @classmethod + def from_model(cls, info: MinerInfo) -> "MinerInfoSchema": + """Create MinerInfoSchema from a MinerInfo value object.""" + return cls( + model=info.model, + serial_number=info.serial_number, + firmware_version=info.firmware_version, + mac_address=info.mac_address, + hostname=info.hostname, + hashboard_count=info.hashboard_count, + chip_count=info.chip_count, + fan_count=info.fan_count, + ) + + class MinerCreateSchema(BaseModel): """Schema for creating a new miner.""" From 82e0aaf1dd75ee15c71ce871705c8fe56da7d7bb Mon Sep 17 00:00:00 2001 From: markoceri Date: Sun, 12 Apr 2026 22:49:23 +0200 Subject: [PATCH 32/33] feat: add miner limits retrieval with max power and hash rate detection --- CHANGELOG.md | 5 +++ .../adapters/domain/miner/fast_api/router.py | 19 ++++++++++ edge_mining/adapters/domain/miner/schemas.py | 38 +++++++++++++++++++ edge_mining/application/interfaces.py | 6 ++- .../services/miner_action_service.py | 34 ++++++++++++++++- edge_mining/domain/miner/value_objects.py | 8 ++++ 6 files changed, 108 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc9069..53582f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **New Value Objects** (`edge_mining/domain/miner/value_objects.py`): - Measurement types: `Temperature`, `FanSpeed`, `Voltage`, `Frequency` frozen dataclasses with value and unit - `MinerInfo`: Device information with model, serial number, firmware version, MAC address, hostname, hashboard/chip/fan count + - `MinerLimit`: Miner limits with optional `max_power` (Watts) and `max_hash_rate` (HashRate) - `HashboardSnapshot`: Per-board metrics (chip/board temperature, voltage, frequency, hash rate, nominal hash rate, hash rate error) - Extended `MinerStateSnapshot` with: `inlet_temperature`, `outlet_temperature`, `internal_fan_speed` (list), `hashboards` (list), and convenience properties (`max_chip_temperature`, `avg_board_temperature`, etc.) - **New Pydantic Schemas** (`edge_mining/adapters/domain/miner/schemas.py`): - `TemperatureSchema`, `FanSpeedSchema`, `VoltageSchema`, `FrequencySchema` with unit validation - `HashboardSnapshotSchema`, `MinerInfoSchema`, `MinerFeatureSchema`, `FeaturePrioritySchema` + - `MinerLimitSchema` with validation, `from_model()`/`to_model()` conversion for `MinerLimit` value object - **`miner_features` Database Table** (`edge_mining/adapters/domain/miner/tables.py`): - Columns: `id`, `miner_id` (FK), `controller_id` (FK), `feature_type`, `priority` (default 50), `enabled` (default True) @@ -38,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **New API Endpoints** (`edge_mining/adapters/domain/miner/fast_api/router.py`): - `GET /miners/{miner_id}/info` — Get miner device information (model, serial number, firmware version, etc.) + - `GET /miners/{miner_id}/limits` — Get miner limits (max power, max hash rate) via `MaxPowerDetectionPort` and `MaxHashrateDetectionPort` - `GET /miners/{miner_id}/features` — List miner features - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/enable` — Enable a feature - `POST /miners/{miner_id}/features/{controller_id}/{feature_type}/disable` — Disable a feature @@ -64,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`MinerActionService`** (`edge_mining/application/services/miner_action_service.py`): - Uses `AdapterService` to dynamically resolve feature ports instead of direct controller access - New `get_miner_info()` method using `DeviceInfoPort` + - New `get_miner_limits()` method using `MaxPowerDetectionPort` and `MaxHashrateDetectionPort` - **CLI Commands** (`edge_mining/adapters/domain/miner/cli/commands.py`): - Refactored miner controller handling to support linking after creation @@ -159,6 +163,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed status display from `list_miners()` and `print_miner_details()` - **Application Interfaces** (`edge_mining/application/interfaces.py`): + - New `get_miner_limits()` abstract method in `MinerActionServiceInterface` - `get_miner_status()`: Return type changed from `Optional[MinerStatus]` to `Optional[MinerStateSnapshot]` - `get_miner_details_from_controller()`: Return type changed from `Optional[Miner]` to `Optional[MinerStateSnapshot]` - `add_miner()`: Removed `status` parameter diff --git a/edge_mining/adapters/domain/miner/fast_api/router.py b/edge_mining/adapters/domain/miner/fast_api/router.py index 5fa2d1f..341d94c 100644 --- a/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/edge_mining/adapters/domain/miner/fast_api/router.py @@ -14,6 +14,7 @@ MinerCreateSchema, MinerFeatureSchema, MinerInfoSchema, + MinerLimitSchema, MinerSchema, MinerStateSnapshotSchema, MinerUpdateSchema, @@ -316,6 +317,24 @@ async def get_miner_info( raise HTTPException(status_code=500, detail=str(e)) from e +@router.get("/miners/{miner_id}/limits", response_model=Optional[MinerLimitSchema]) +async def get_miner_limits( + miner_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> Optional[MinerLimitSchema]: + """Get limits for a specific miner.""" + try: + limits = await action_service.get_miner_limits(miner_id) + + return MinerLimitSchema.from_model(limits) + except MinerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + @router.post("/miners/{miner_id}/activate", response_model=MinerSchema) async def activate_miner( miner_id: EntityId, diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index 7a8c384..d7536ba 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -22,6 +22,7 @@ HashRate, MinerFeature, MinerInfo, + MinerLimit, MinerStateSnapshot, Temperature, Voltage, @@ -431,6 +432,43 @@ def from_model(cls, info: MinerInfo) -> "MinerInfoSchema": ) +class MinerLimitSchema(BaseModel): + """Schema for MinerLimit value object.""" + + max_power: Optional[float] = Field(default=None, ge=0, description="Maximum power consumption in Watts") + max_hash_rate: Optional[HashRateSchema] = Field(default=None, description="Maximum hash rate") + + @field_validator("max_power") + @classmethod + def validate_max_power(cls, v: Optional[float]) -> Optional[float]: + """Validate that max_power is non-negative if provided.""" + if v is not None and v < 0: + raise ValueError("max_power cannot be negative") + return v + + def to_model(self) -> MinerLimit: + """Convert MinerLimitSchema to MinerLimit value object.""" + return MinerLimit( + max_power=Watts(self.max_power) if self.max_power is not None else None, + max_hash_rate=self.max_hash_rate.to_model() if self.max_hash_rate else None, + ) + + @classmethod + def from_model(cls, limit: Optional[MinerLimit]) -> "MinerLimitSchema": + """Create MinerLimitSchema from a MinerLimit value object.""" + if not limit: + return cls() + + max_hash_rate_schema: Optional[HashRateSchema] = None + if limit.max_hash_rate: + max_hash_rate_schema = HashRateSchema(value=limit.max_hash_rate.value, unit=limit.max_hash_rate.unit) + + return cls( + max_power=limit.max_power if limit.max_power else None, + max_hash_rate=max_hash_rate_schema, + ) + + class MinerCreateSchema(BaseModel): """Schema for creating a new miner.""" diff --git a/edge_mining/application/interfaces.py b/edge_mining/application/interfaces.py index 4846fdb..7786847 100644 --- a/edge_mining/application/interfaces.py +++ b/edge_mining/application/interfaces.py @@ -17,7 +17,7 @@ from edge_mining.domain.miner.common import MinerControllerAdapter, MinerFeatureType from edge_mining.domain.miner.entities import MinerController from edge_mining.domain.miner.ports import MinerFeaturePort -from edge_mining.domain.miner.value_objects import HashRate, MinerInfo, MinerStateSnapshot +from edge_mining.domain.miner.value_objects import HashRate, MinerInfo, MinerLimit, MinerStateSnapshot from edge_mining.domain.notification.common import NotificationAdapter from edge_mining.domain.notification.entities import Notifier from edge_mining.domain.notification.ports import NotificationPort @@ -153,6 +153,10 @@ async def get_miner_status(self, miner_id: EntityId) -> Optional[MinerStateSnaps async def get_miner_info(self, miner_id: EntityId) -> Optional[MinerInfo]: """Gets the information of the specified miner.""" + @abstractmethod + async def get_miner_limits(self, miner_id: EntityId) -> Optional[MinerLimit]: + """Gets the limits of the specified miner.""" + @abstractmethod async def sync_all_miners(self, include_inactive: bool = False) -> None: """Synchronizes the status of all miners from their controllers.""" diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index bd1af25..ced9434 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -17,6 +17,8 @@ HashboardMonitorPort, HashrateMonitorPort, InternalFanSpeedMonitorPort, + MaxHashrateDetectionPort, + MaxPowerDetectionPort, MinerRepository, MiningControlPort, OperationalMonitorPort, @@ -24,7 +26,7 @@ PowerMonitorPort, StatusMonitorPort, ) -from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerInfo, MinerStateSnapshot +from edge_mining.domain.miner.value_objects import HashRate, MinerFeature, MinerInfo, MinerLimit, MinerStateSnapshot from edge_mining.domain.notification.ports import NotificationPort from edge_mining.shared.logging.port import LoggerPort @@ -65,6 +67,36 @@ async def get_miner_info(self, miner_id: EntityId) -> Optional[MinerInfo]: return await port.get_device_info() + async def get_miner_limits(self, miner_id: EntityId) -> Optional[MinerLimit]: + """Gets the limits of the specified miner.""" + if self.logger: + self.logger.info(f"Getting limits for miner {miner_id}") + + miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) + + if not miner: + raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + + # --- Retrieve max power limit --- + max_power = None + power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MAX_POWER_DETECTION) + if power_port and isinstance(power_port, MaxPowerDetectionPort): + max_power = await power_port.get_max_power() + else: + if self.logger: + self.logger.warning(f"No max power detection port available for miner {miner_id}. Returning None.") + + # --- Retrieve max hash rate limit --- + max_hash_rate = None + hashrate_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.HASHRATE_MONITORING) + if hashrate_port and isinstance(hashrate_port, MaxHashrateDetectionPort): + max_hash_rate = await hashrate_port.get_max_hashrate() + else: + if self.logger: + self.logger.warning(f"No hashrate monitor port available for miner {miner_id}. Returning None.") + + return MinerLimit(max_power=max_power, max_hash_rate=max_hash_rate) if max_power or max_hash_rate else None + async def _notify(self, notifiers: List[NotificationPort], title: str, message: str): """Sends a notification using the configured notifiers.""" diff --git a/edge_mining/domain/miner/value_objects.py b/edge_mining/domain/miner/value_objects.py index 7763151..9adc827 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -61,6 +61,14 @@ class MinerInfo(ValueObject): fan_count: Optional[int] = None +@dataclass(frozen=True) +class MinerLimit(ValueObject): + """Value Object representing limits for a miner.""" + + max_power: Optional[Watts] = None + max_hash_rate: Optional[HashRate] = None + + @dataclass(frozen=True) class MinerFeature(ValueObject): """Value Object representing a single capability provided by a controller to a miner. From 0157e4a6bbeded6297bc749253c1907e8ca2f580 Mon Sep 17 00:00:00 2001 From: markoceri Date: Mon, 13 Apr 2026 00:10:02 +0200 Subject: [PATCH 33/33] feat: add firmware type to MinerInfo and related schemas for enhanced device information --- CHANGELOG.md | 2 +- edge_mining/adapters/domain/miner/controllers/dummy.py | 2 ++ edge_mining/adapters/domain/miner/controllers/pyasic.py | 2 ++ edge_mining/adapters/domain/miner/schemas.py | 4 ++++ edge_mining/domain/miner/value_objects.py | 1 + 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53582f4..6a77300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **New Value Objects** (`edge_mining/domain/miner/value_objects.py`): - Measurement types: `Temperature`, `FanSpeed`, `Voltage`, `Frequency` frozen dataclasses with value and unit - - `MinerInfo`: Device information with model, serial number, firmware version, MAC address, hostname, hashboard/chip/fan count + - `MinerInfo`: Device information with model, serial number, firmware type (Stock, BOS+, VNish, etc.), firmware version, MAC address, hostname, hashboard/chip/fan count - `MinerLimit`: Miner limits with optional `max_power` (Watts) and `max_hash_rate` (HashRate) - `HashboardSnapshot`: Per-board metrics (chip/board temperature, voltage, frequency, hash rate, nominal hash rate, hash rate error) - Extended `MinerStateSnapshot` with: `inlet_temperature`, `outlet_temperature`, `internal_fan_speed` (list), `hashboards` (list), and convenience properties (`max_chip_temperature`, `avg_board_temperature`, etc.) diff --git a/edge_mining/adapters/domain/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index fdb71ab..e60e737 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -80,6 +80,7 @@ async def get_device_info(self) -> Optional[MinerInfo]: # Simulate some dummy device info model = "DummyMiner X1" serial_number = "DMX1-01}" + firmware_type = "Stock" firmware_version = "1.0.0" mac_address = "00:11:22:33:10:99" hostname = "edgemining-dummyminer" @@ -87,6 +88,7 @@ async def get_device_info(self) -> Optional[MinerInfo]: info = MinerInfo( model=model, serial_number=serial_number, + firmware_type=firmware_type, firmware_version=firmware_version, mac_address=mac_address, hostname=hostname, diff --git a/edge_mining/adapters/domain/miner/controllers/pyasic.py b/edge_mining/adapters/domain/miner/controllers/pyasic.py index ec66777..5fe3223 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -211,6 +211,7 @@ async def get_device_info(self) -> Optional[MinerInfo]: serial_number = await miner.get_serial_number() mac_address = await miner.get_mac() model = await miner.get_model() + firmware_type = str(miner.firmware) if miner.firmware else None firmware_version = await miner.get_fw_ver() hostname = await miner.get_hostname() @@ -245,6 +246,7 @@ async def get_device_info(self) -> Optional[MinerInfo]: return MinerInfo( model=str(model) if model is not None else None, serial_number=str(serial_number) if serial_number is not None else None, + firmware_type=firmware_type, firmware_version=str(firmware_version) if firmware_version is not None else None, mac_address=str(mac_address) if mac_address is not None else None, hostname=str(hostname) if hostname is not None else None, diff --git a/edge_mining/adapters/domain/miner/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index d7536ba..fb228e1 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -410,6 +410,9 @@ class MinerInfoSchema(BaseModel): model: Optional[str] = Field(default=None, description="Miner model") serial_number: Optional[str] = Field(default=None, description="Serial number") + firmware_type: Optional[str] = Field( + default=None, description="Firmware type (e.g. Stock, BOS+, VNish, ePIC, LuxOS)" + ) firmware_version: Optional[str] = Field(default=None, description="Firmware version") mac_address: Optional[str] = Field(default=None, description="MAC address") hostname: Optional[str] = Field(default=None, description="Hostname") @@ -423,6 +426,7 @@ def from_model(cls, info: MinerInfo) -> "MinerInfoSchema": return cls( model=info.model, serial_number=info.serial_number, + firmware_type=info.firmware_type, firmware_version=info.firmware_version, mac_address=info.mac_address, hostname=info.hostname, diff --git a/edge_mining/domain/miner/value_objects.py b/edge_mining/domain/miner/value_objects.py index 9adc827..25d3659 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -53,6 +53,7 @@ class MinerInfo(ValueObject): model: Optional[str] = None serial_number: Optional[str] = None + firmware_type: Optional[str] = None firmware_version: Optional[str] = None mac_address: Optional[str] = None hostname: Optional[str] = None