diff --git a/CHANGELOG.md b/CHANGELOG.md index 64458fa..6a77300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,108 @@ 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 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.) + +- **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) + - 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}/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 + - `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` + - 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 + - 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,10 +148,22 @@ 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()` - **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 @@ -72,8 +183,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/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py b/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py new file mode 100644 index 0000000..df93a90 --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_add_miner_features_table.py @@ -0,0 +1,64 @@ +"""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/__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(".")) 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/miner/controllers/dummy.py b/edge_mining/adapters/domain/miner/controllers/dummy.py index d2fbf7a..e60e737 100644 --- a/edge_mining/adapters/domain/miner/controllers/dummy.py +++ b/edge_mining/adapters/domain/miner/controllers/dummy.py @@ -1,17 +1,58 @@ -"""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 typing import List, 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 ( + DeviceInfoPort, + ExternalFanControlPort, + ExternalFanSpeedMonitorPort, + HashboardMonitorPort, + HashrateMonitorPort, + InletTemperatureMonitorPort, + InternalFanControlPort, + InternalFanSpeedMonitorPort, + MiningControlPort, + OperationalMonitorPort, + OutletTemperatureMonitorPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashboardSnapshot, + HashRate, + MinerInfo, + Temperature, + Voltage, +) from edge_mining.shared.logging.port import LoggerPort -class DummyMinerController(MinerControlPort): - """Simulates miner control without real hardware.""" +class DummyMinerController( + HashrateMonitorPort, + PowerMonitorPort, + StatusMonitorPort, + HashboardMonitorPort, + InletTemperatureMonitorPort, + OutletTemperatureMonitorPort, + InternalFanSpeedMonitorPort, + ExternalFanSpeedMonitorPort, + MiningControlPort, + PowerControlPort, + InternalFanControlPort, + ExternalFanControlPort, + DeviceInfoPort, + OperationalMonitorPort, +): + """Simulates miner control without real hardware. + + Implements all 16 feature ports for testing and development. + """ def __init__( self, @@ -26,28 +67,49 @@ 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 + + # --- DeviceInfoPort --- + + async def get_device_info(self) -> Optional[MinerInfo]: + """Gets the device information of the miner.""" + if self.logger: + self.logger.debug("DummyController: Fetching device info...") + + # 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" - async def get_model(self) -> Optional[str]: - """Gets the model of the miner.""" + info = MinerInfo( + model=model, + serial_number=serial_number, + firmware_type=firmware_type, + firmware_version=firmware_version, + mac_address=mac_address, + hostname=hostname, + ) if self.logger: - self.logger.debug("Retrieving miner model for Dummy Miner Controller is not supported...") + self.logger.debug(f"DummyController: Device info fetched: {info}") + + return info - return None + # --- MiningControlPort --- async def start_miner(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 async def stop_miner(self) -> bool: """Stop the miner.""" @@ -57,15 +119,14 @@ async 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 + + # --- StatusMonitorPort --- async def get_miner_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 +135,7 @@ async 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,6 +148,16 @@ 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]: """Get the power of the miner.""" status = self._status @@ -95,7 +166,7 @@ async 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 +177,12 @@ async def get_miner_power(self) -> Optional[Watts]: self._power = power return power + # --- HashrateMonitorPort --- + async def get_miner_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 +195,138 @@ 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") - # 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 + # --- 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, + ) + ) + + if self.logger: + self.logger.debug(f"DummyController: Reporting {len(snapshots)} hashboards") + return snapshots + + # --- InletTemperatureMonitorPort --- + + 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: + self.logger.debug(f"DummyController: Reporting inlet temperature {temp.value}{temp.unit}") + return temp + + # --- OutletTemperatureMonitorPort --- + + 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)) + 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 --- + + 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) + 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 --- + + 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 + 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 + + # --- PowerControlPort --- + + 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})") + if self._status in (MinerStatus.OFF, MinerStatus.UNKNOWN): + self._status = MinerStatus.STARTING + return True + + 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})") + self._status = MinerStatus.OFF + return True + + # --- InternalFanControlPort --- + + 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}%") + self._internal_fan_speed = max(0.0, min(100.0, speed_percent)) + return True + + # --- ExternalFanControlPort --- + + 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}%") + 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 dbbadec..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 @@ -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 @@ -9,11 +9,14 @@ ServiceHomeAssistantAPI, ) from edge_mining.domain.common import Watts +from edge_mining.domain.miner.aggregate_roots import Miner from edge_mining.domain.miner.common import MinerStatus -from edge_mining.domain.miner.entities 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}" ) - async 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 - - async def get_miner_hashrate(self) -> Optional[HashRate]: - """ - Gets the current hash rate, if available. - This implementation does not provides hash rate information. - """ - return None - - async def get_miner_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...") @@ -130,7 +127,9 @@ async def get_miner_power(self) -> Optional[Watts]: return power_watts - async def get_miner_status(self) -> MinerStatus: + # --- StatusMonitorPort --- + + 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...") @@ -151,10 +150,12 @@ async def get_miner_status(self) -> MinerStatus: return miner_status - async def stop_miner(self) -> bool: - """Attempts to stop the specified miner. Returns True on success request.""" + # --- PowerControlPort --- + + 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 stop command to miner via Home Assistant...") + self.logger.debug("Sending power off command to miner via Home Assistant...") success = await self.home_assistant.set_entity_state( self.entity_switch, @@ -162,14 +163,14 @@ async 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 - async def start_miner(self) -> bool: - """Attempts to start the miner. Returns True on success request.""" + 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 start command to miner via Home Assistant...") + self.logger.debug("Sending power on command to miner via Home Assistant...") success = await self.home_assistant.set_entity_state( self.entity_switch, @@ -177,6 +178,6 @@ async 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 f6ce30c..5fe3223 100644 --- a/edge_mining/adapters/domain/miner/controllers/pyasic.py +++ b/edge_mining/adapters/domain/miner/controllers/pyasic.py @@ -1,9 +1,9 @@ """ -pyasic adapter (Implementation of Port) +pyasic adapter (Implementation of Feature Ports) 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 @@ -12,13 +12,34 @@ 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 -from edge_mining.domain.miner.entities import Miner from edge_mining.domain.miner.exceptions import MinerControllerConfigurationError -from edge_mining.domain.miner.ports import MinerControlPort -from edge_mining.domain.miner.value_objects import HashRate +from edge_mining.domain.miner.ports import ( + DeviceInfoPort, + HashboardMonitorPort, + HashrateMonitorPort, + InletTemperatureMonitorPort, + InternalFanControlPort, + InternalFanSpeedMonitorPort, + MaxHashrateDetectionPort, + MaxPowerDetectionPort, + MiningControlPort, + OperationalMonitorPort, + OutletTemperatureMonitorPort, + PowerMonitorPort, + StatusMonitorPort, +) +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashboardSnapshot, + HashRate, + MinerInfo, + 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 +65,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 +84,22 @@ def create( ) -class PyASICMinerController(MinerControlPort): - """Controls a miner via pyasic.""" +class PyASICMinerController( + HashrateMonitorPort, + PowerMonitorPort, + StatusMonitorPort, + HashboardMonitorPort, + InletTemperatureMonitorPort, + OutletTemperatureMonitorPort, + InternalFanSpeedMonitorPort, + MiningControlPort, + InternalFanControlPort, + DeviceInfoPort, + MaxPowerDetectionPort, + MaxHashrateDetectionPort, + OperationalMonitorPort, +): + """Controls a miner via pyasic. Implements multiple feature ports.""" def __init__( self, @@ -89,13 +124,16 @@ 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}" + ) - 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) @@ -136,37 +174,296 @@ 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}") - async def get_model(self) -> Optional[str]: - """Gets the model of the miner.""" + # --- DeviceInfoDetectionPort --- + + 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}...") - # Get pyasic miner instance - self._get_miner() + 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 self._miner.model or None + 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_type = str(miner.firmware) if miner.firmware else None + 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"] + + 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"] - async def get_miner_hashrate(self) -> Optional[HashRate]: + 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, + 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, + ) + + 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. """ - Gets the current hash rate, if available. + 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]: + """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 hashrate from from {self.ip}...") + self.logger.debug(f"Fetching max hash rate from {self.ip}...") - # Get pyasic miner instance - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -174,7 +471,39 @@ async def get_miner_hashrate(self) -> Optional[HashRate]: return None miner = self._miner - hashrate: Optional[AlgoHashRate] = run_async_func(miner.get_hashrate()) + 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]: + """Gets the current hash rate, if available.""" + + if self.logger: + self.logger.debug(f"Fetching hashrate 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: Optional[AlgoHashRate] = await miner.get_hashrate() if hashrate is None: if self.logger: self.logger.debug(f"Failed to fetch hashrate from {self.ip}...") @@ -190,13 +519,14 @@ async def get_miner_hashrate(self) -> Optional[HashRate]: return real_hashrate - async def get_miner_power(self) -> Optional[Watts]: + # --- PowerMonitorPort --- + + 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 from {self.ip}...") + self.logger.debug(f"Fetching power consumption from {self.ip}...") - # Get pyasic miner instance - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -204,7 +534,7 @@ async def get_miner_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}...") @@ -216,13 +546,14 @@ async def get_miner_power(self) -> Optional[Watts]: return power_watts - async def get_miner_status(self) -> MinerStatus: + # --- StatusMonitorPort --- + + 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}...") - # Get pyasic miner instance - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -230,7 +561,7 @@ async def get_miner_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] = { @@ -253,13 +584,198 @@ async def get_miner_status(self) -> MinerStatus: return miner_status - async def stop_miner(self) -> bool: - """Attempts to stop the specified miner. Returns True on success request.""" + # --- 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]: + """Gets the current state of all hashboards.""" + if self.logger: + 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 [] + + 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 + 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 = Voltage(value=round(float(hb.voltage), 2)) if hb.voltage is not None else None + hb_frequency: Optional[Frequency] = None + + # 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( + index=idx, + chip_temperature=chip_temp, + board_temperature=board_temp, + voltage=hb_voltage, + frequency=hb_frequency, + hash_rate=hb_hashrate, + nominal_hash_rate=None, + hash_rate_error=None, + ) + ) + + if self.logger: + self.logger.debug(f"Hashboard data fetched: {len(snapshots)} boards from {self.ip}") + + return snapshots + + # --- InletTemperatureMonitorPort --- + + 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}...") + + await self._get_miner() + + if not self._miner: + return None + + miner = self._miner + data = await miner.get_data() + if data is None or data.env_temp is None: + return None + + return Temperature(value=float(data.env_temp)) + + # --- OutletTemperatureMonitorPort --- + + 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}...") + + # pyasic does not typically provide separate outlet temperature + # Some miners expose this through env_temp or specific board data + return None + + # --- InternalFanSpeedMonitorPort --- + + 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 [] + + miner = self._miner + fans = await miner.get_fans() + if fans is None: + return [] + + for fan in fans: + if fan.speed is not None: + miner_fans.append(FanSpeed(value=float(fan.speed))) + + return miner_fans + + # --- MiningControlPort --- + + 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}...") + + await 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 = await miner.resume_mining() + + if self.logger: + self.logger.debug(f"Start command sent. Success: {success}") + + return success or False + + 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}...") - # Get pyasic miner instance - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -267,20 +783,21 @@ async def stop_miner(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}") return success or False - async def start_miner(self) -> bool: - """Attempts to start the miner. Returns True on success request.""" + # --- InternalFanControlPort --- + + 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"Sending start command to miner at {self.ip}...") + self.logger.debug(f"Setting internal fan speed to {speed_percent}% on {self.ip}...") - # Get pyasic miner instance - self._get_miner() + await self._get_miner() if not self._miner: if self.logger: @@ -288,12 +805,379 @@ async def start_miner(self) -> bool: return False miner = self._miner - success = run_async_func(miner.resume_mining()) + try: + 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 + except Exception as e: + if self.logger: + self.logger.error(f"Failed to set fan speed on {self.ip}: {e}") + return False + + # --- 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"Start command sent. Success: {success}") + self.logger.debug(f"devdetails extra: {result}") - return success or False + 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. @@ -306,8 +1190,8 @@ async 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] = await self.get_miner_hashrate() - wattage: Optional[Watts] = await self.get_miner_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/adapters/domain/miner/fast_api/router.py b/edge_mining/adapters/domain/miner/fast_api/router.py index 34890bf..341d94c 100644 --- a/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/edge_mining/adapters/domain/miner/fast_api/router.py @@ -7,10 +7,14 @@ from edge_mining.adapters.domain.miner.schemas import ( MINER_CONTROLLER_CONFIG_SCHEMA_MAP, + FeaturePrioritySchema, MinerControllerCreateSchema, MinerControllerSchema, MinerControllerUpdateSchema, MinerCreateSchema, + MinerFeatureSchema, + MinerInfoSchema, + MinerLimitSchema, MinerSchema, MinerStateSnapshotSchema, MinerUpdateSchema, @@ -28,8 +32,8 @@ MinerActionServiceInterface, ) 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.common import MinerControllerAdapter, MinerFeatureType from edge_mining.domain.miner.exceptions import ( MinerControllerAlreadyExistsError, MinerControllerConfigurationError, @@ -99,7 +103,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 +136,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, ) @@ -294,6 +296,45 @@ 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.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, @@ -330,21 +371,102 @@ 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}/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, 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 +474,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/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/schemas.py b/edge_mining/adapters/domain/miner/schemas.py index d4b65cb..fb228e1 100644 --- a/edge_mining/adapters/domain/miner/schemas.py +++ b/edge_mining/adapters/domain/miner/schemas.py @@ -7,9 +7,26 @@ 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.aggregate_roots import Miner +from edge_mining.domain.miner.common import ( + MinerControllerAdapter, + MinerControllerProtocol, + MinerFeatureType, + MinerStatus, +) +from edge_mining.domain.miner.entities import MinerController +from edge_mining.domain.miner.value_objects import ( + FanSpeed, + Frequency, + HashboardSnapshot, + HashRate, + MinerFeature, + MinerInfo, + MinerLimit, + MinerStateSnapshot, + Temperature, + Voltage, +) from edge_mining.shared.adapter_configs.miner import ( MinerControllerDummyConfig, MinerControllerGenericSocketHomeAssistantAPIConfig, @@ -47,6 +64,103 @@ 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 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.""" + + 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 +170,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 +192,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 +214,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 +223,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 +234,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: @@ -149,12 +249,87 @@ 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") + 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") + 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": @@ -163,10 +338,46 @@ 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, power_consumption=snapshot.power_consumption, + 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 + ), + 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: @@ -175,6 +386,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, + 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, + hashboards=[hb.to_model() for hb in self.hashboards], + blocks_found=self.blocks_found, + system_uptime=self.system_uptime, ) class Config: @@ -187,6 +405,74 @@ 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_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") + 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_type=info.firmware_type, + 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 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.""" @@ -194,18 +480,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 +501,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 +521,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 diff --git a/edge_mining/adapters/domain/miner/tables.py b/edge_mining/adapters/domain/miner/tables.py index 425d187..44672e4 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,58 @@ 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. + + 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=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))) 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.""" 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}") 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 = "" diff --git a/edge_mining/application/interfaces.py b/edge_mining/application/interfaces.py index d8c35ac..7786847 100644 --- a/edge_mining/application/interfaces.py +++ b/edge_mining/application/interfaces.py @@ -13,10 +13,11 @@ 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.value_objects import HashRate, MinerStateSnapshot +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 MinerFeaturePort +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 @@ -48,8 +49,19 @@ async def get_energy_monitor(self, energy_source: EnergySource) -> Optional[Ener """Get an energy monitor adapter instance.""" @abstractmethod - async def get_miner_controller(self, miner: Miner) -> Optional[MinerControlPort]: - """Get a miner controller adapter instance""" + async def get_miner_controller_adapter(self, miner: Miner, controller_id: EntityId) -> Optional[MinerFeaturePort]: + """Get a miner controller adapter instance for a specific controller.""" + + @abstractmethod + 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]: @@ -137,6 +149,14 @@ 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 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.""" @@ -157,7 +177,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 +201,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 +263,29 @@ 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 + 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: diff --git a/edge_mining/application/services/adapter_service.py b/edge_mining/application/services/adapter_service.py index 2e7b459..2d8d4b4 100644 --- a/edge_mining/application/services/adapter_service.py +++ b/edge_mining/application/services/adapter_service.py @@ -32,9 +32,11 @@ 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.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, 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 @@ -64,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, @@ -74,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 @@ -85,7 +89,7 @@ def __init__( Optional[ Union[ EnergyMonitorPort, - MinerControlPort, + MinerFeaturePort, NotificationPort, ForecastProviderPort, HomeForecastProviderPort, @@ -233,7 +237,7 @@ async def _initialize_energy_monitor_adapter( async 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: @@ -246,8 +250,6 @@ async 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} " @@ -255,16 +257,14 @@ async 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 @@ -279,7 +279,7 @@ async 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: @@ -619,19 +619,96 @@ async def get_energy_monitor(self, energy_source: EnergySource) -> Optional[Ener return None return await self._initialize_energy_monitor_adapter(energy_source, energy_monitor) - async 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) + async 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 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}.") + return None + + adapter = await 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 + async 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..31e0d83 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 @@ -27,15 +27,16 @@ from edge_mining.domain.home_load.entities import HomeForecastProvider 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.common import MinerControllerAdapter, MinerFeatureType +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,11 +1573,85 @@ 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 = 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}.") + + 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) + + 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.""" diff --git a/edge_mining/application/services/miner_action_service.py b/edge_mining/application/services/miner_action_service.py index 76f4140..ced9434 100644 --- a/edge_mining/application/services/miner_action_service.py +++ b/edge_mining/application/services/miner_action_service.py @@ -4,17 +4,29 @@ 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 ( + DeviceInfoPort, + HashboardMonitorPort, + HashrateMonitorPort, + InternalFanSpeedMonitorPort, + MaxHashrateDetectionPort, + MaxPowerDetectionPort, + MinerRepository, + MiningControlPort, + OperationalMonitorPort, + PowerControlPort, + PowerMonitorPort, + StatusMonitorPort, +) +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 @@ -39,6 +51,52 @@ def __init__( self._event_bus = event_bus self.logger = logger + 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 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.""" @@ -64,22 +122,24 @@ 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 = await self.adapter_service.get_miner_controller(miner) - - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") + # Try MINING_CONTROL first, then POWER_CONTROL as fallback + 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) - # Get the current state - current_status = await miner_controller.get_miner_status() + if not mining_port and not power_ctrl_port: + raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") - # Update model if available and it has changed (static config update) - current_model = await miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + # Get current status + 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() - success = await miner_controller.start_miner() + success = False + if mining_port and isinstance(mining_port, MiningControlPort): + success = await mining_port.start_mining() + elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): + success = await power_ctrl_port.power_on() if success: if self.logger: @@ -121,22 +181,24 @@ 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 = await self.adapter_service.get_miner_controller(miner) - - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") + # Try MINING_CONTROL first, then POWER_CONTROL as fallback + 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) - # Get the current state - current_status = await miner_controller.get_miner_status() + if not mining_port and not power_ctrl_port: + raise MinerControllerConfigurationError(f"No mining or power control available for miner {miner_id}.") - # Update model if available and it has changed (static config update) - current_model = await miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + # Get current status + 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() - success = await miner_controller.stop_miner() + success = False + if mining_port and isinstance(mining_port, MiningControlPort): + success = await mining_port.stop_mining() + elif power_ctrl_port and isinstance(power_ctrl_port, PowerControlPort): + success = await power_ctrl_port.power_off() if success: if self.logger: @@ -175,15 +237,11 @@ async 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 = await self.adapter_service.get_miner_controller(miner) + 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}.") - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") - - current_power = await miner_controller.get_miner_power() - - return current_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.""" @@ -195,21 +253,11 @@ async 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 = await self.adapter_service.get_miner_controller(miner) - - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") + 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}.") - current_hashrate = await miner_controller.get_miner_hashrate() - - # Update model if available and it has changed (static config update) - current_model = await miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) - - return current_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.""" @@ -221,27 +269,51 @@ 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 = await self.adapter_service.get_miner_controller(miner) - - if not miner_controller: - raise MinerControllerConfigurationError(f"Miner controller for miner {miner_id} is not configured.") - - # Query current state from controller - current_status = await miner_controller.get_miner_status() - current_hashrate = await miner_controller.get_miner_hashrate() - current_power = await miner_controller.get_miner_power() + # Query individual feature ports + 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 = 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 = 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() + + 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() - # Update model if available and it has changed (static config update) - current_model = await miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + 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() return 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, ) async def sync_all_miners(self, include_inactive: bool = False) -> None: @@ -275,27 +347,28 @@ 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 = await self.adapter_service.get_miner_controller(miner) - - if not miner_controller: + 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"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 = await miner_controller.get_miner_status() - current_hashrate = await miner_controller.get_miner_hashrate() - current_power = await miner_controller.get_miner_power() + current_status = await status_port.get_status() + + 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() - # Update model if available and it has changed (static config update) - current_model = await miner_controller.get_model() - if current_model and miner.model != current_model: - miner.model = current_model - self.miner_repo.update(miner) + 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() synced_count += 1 @@ -322,26 +395,59 @@ 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 = await self.adapter_service.get_miner_controller(temp_miner) + # Query via feature ports + 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() - if not miner_controller: - raise MinerControllerNotFoundError(f"Controller with ID {controller_id} not found.") + 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 + ) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + 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() - # Retrieve details from the controller - current_status = await miner_controller.get_miner_status() - current_hashrate = await miner_controller.get_miner_hashrate() - current_power = await miner_controller.get_miner_power() + temperature_port = await self.adapter_service.get_miner_feature_port( + temp_miner, MinerFeatureType.HASHBOARD_MONITORING + ) + 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 + ) + internal_fan_speed = [] + 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( ( @@ -366,6 +472,10 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi 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, ) if self.logger: diff --git a/edge_mining/application/services/optimization_service.py b/edge_mining/application/services/optimization_service.py index f18122c..4f6aaa9 100644 --- a/edge_mining/application/services/optimization_service.py +++ b/edge_mining/application/services/optimization_service.py @@ -25,10 +25,22 @@ 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 ( + HashboardMonitorPort, + HashrateMonitorPort, + InternalFanSpeedMonitorPort, + MinerFeaturePort, + MinerRepository, + MiningControlPort, + OperationalMonitorPort, + 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 @@ -261,41 +273,70 @@ 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 = await self.adapter_service.get_miner_controller(miner) - if not miner_controller: + # --- Query current state via feature ports --- + 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"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 + + 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 + ) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + 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() + + 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() - # Query current state from controller - current_status = await miner_controller.get_miner_status() - current_hashrate = await miner_controller.get_miner_hashrate() - current_power = await miner_controller.get_miner_power() + 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() # Build the miner state snapshot miner_state = 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, ) - # Update model if available and it has changed (static config update) - current_model = await 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 --- @@ -691,32 +732,24 @@ async def _process_single_miner_in_unit( ) return - # --- Miner Controller --- - miner_controller: Optional[MinerControlPort] = None - if miner.controller_id: - try: - miner_controller = await 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 = 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.") + await self._notify_unit( + notifiers, + f"Optimizer Error ({optimization_unit.name} / {miner_id})", + "Status monitor unavailable.", + ) + return + + mining_port = await 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." ) @@ -729,24 +762,39 @@ async def _process_single_miner_in_unit( # Get current status and make decision try: - # Query current state from controller - current_status = await miner_controller.get_miner_status() - current_hashrate = await miner_controller.get_miner_hashrate() - current_power = await miner_controller.get_miner_power() + # 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 + ) + current_hashrate = None + if hashrate_port and isinstance(hashrate_port, HashrateMonitorPort): + current_hashrate = await hashrate_port.get_hashrate() + + 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() # Build the miner state snapshot miner_state = MinerStateSnapshot( status=current_status, hash_rate=current_hashrate, power_consumption=current_power, + blocks_found=blocks_found, + system_uptime=system_uptime, ) - # Update model if available and it has changed (static config update) - current_model = await miner_controller.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 @@ -805,7 +853,8 @@ async def _process_single_miner_in_unit( ) await self._execute_miner_decision( - miner_controller, + mining_port, + status_port, miner_id, decision, current_status, @@ -838,7 +887,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, @@ -851,8 +901,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 = await controller.start_miner() + self.logger.info(f"Executing START for miner {miner_id} via {type(mining_port).__name__}") + if isinstance(mining_port, MiningControlPort): + success = await mining_port.start_mining() + elif isinstance(mining_port, PowerControlPort): + success = await mining_port.power_on() action_taken = True if success: await self._notify_unit( @@ -869,8 +922,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 = await controller.stop_miner() + self.logger.info(f"Executing STOP for miner {miner_id} via {type(mining_port).__name__}") + if isinstance(mining_port, MiningControlPort): + success = await mining_port.stop_mining() + elif isinstance(mining_port, PowerControlPort): + success = await mining_port.power_off() action_taken = True if success: await self._notify_unit( @@ -885,12 +941,31 @@ async def _execute_miner_decision( f"Attempt to stop miner {miner_id} failed." + message_suffix, ) - if action_taken and not success: + if action_taken: + if not success: + if self.logger: + self.logger.error( + 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 = await status_port.get_status() + + # Publish miner state changed event + if self._event_bus: + await self._event_bus.publish( + MinerStateChangedEvent( + miner_id=miner_id, + miner_name=miner.name if miner else "", + old_status=current_status, + new_status=new_status, + ) + ) + else: if self.logger: - self.logger.error( - f"Command {decision.name} for miner {miner_id} failed using controller {type(controller).__name__}." + self.logger.debug( + f"No action taken for miner {miner_id} (Decision: {decision.name}, " + f"Current Status: {current_status.name})." ) - elif action_taken and success: - # State is no longer persisted on the Miner entity. - # The next optimization iteration will query the controller for current status. - pass diff --git a/edge_mining/bootstrap.py b/edge_mining/bootstrap.py index f0853a4..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, @@ -328,6 +329,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( 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..1b45a95 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" + 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" + OPERATIONAL_MONITORING = "operational_monitoring" + + # Control (write) + MINING_CONTROL = "mining_control" + POWER_CONTROL = "power_control" + INTERNAL_FAN_CONTROL = "internal_fan_control" + EXTERNAL_FAN_CONTROL = "external_fan_control" + + # Info + MAX_POWER_DETECTION = "max_power_detection" + MAX_HASHRATE_DETECTION = "max_hashrate_detection" + DEVICE_INFO_DETECTION = "device_info_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 994b13a..435e25a 100644 --- a/edge_mining/domain/miner/ports.py +++ b/edge_mining/domain/miner/ports.py @@ -1,47 +1,254 @@ """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, + HashboardSnapshot, + HashRate, + MinerInfo, + Temperature, +) +# --- 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 - async def get_model(self) -> Optional[str]: - """Gets the model of the miner.""" + async 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 - async def start_miner(self) -> bool: - """Attempts to start the miner. Returns True on success request.""" + async 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 - async def stop_miner(self) -> bool: - """Attempts to stop the specified miner. Returns True on success request.""" + async def get_status(self) -> MinerStatus: + """Gets the current operational status of the miner.""" raise NotImplementedError + +class HashboardMonitorPort(MinerFeaturePort): + """Port for monitoring per-hashboard data (temperatures, voltage, frequency, hashrate).""" + + feature_type = MinerFeatureType.HASHBOARD_MONITORING + @abstractmethod - async def get_miner_status(self) -> MinerStatus: - """Gets the current operational status of the miner.""" + async def get_hashboards(self) -> List[HashboardSnapshot]: + """Gets the current state of all hashboards.""" raise NotImplementedError + +class InletTemperatureMonitorPort(MinerFeaturePort): + """Port for monitoring inlet air temperature.""" + + feature_type = MinerFeatureType.INLET_TEMPERATURE_MONITORING + @abstractmethod - async def get_miner_power(self) -> Optional[Watts]: - """Gets the current power consumption, if available.""" + async 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 - async def get_miner_hashrate(self) -> Optional[HashRate]: - """Gets the current hash rate, if available.""" + async 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 + async def get_internal_fan_speed(self) -> List[FanSpeed]: + """Gets the current internal fans speed, if available.""" + raise NotImplementedError + + +class ExternalFanSpeedMonitorPort(MinerFeaturePort): + """Port for monitoring external fan speed.""" + + feature_type = MinerFeatureType.FAN_SPEED_EXTERNAL_MONITORING + + @abstractmethod + async def get_external_fan_speed(self) -> Optional[FanSpeed]: + """Gets the current external fan speed, if available.""" + 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) --- + + +class MiningControlPort(MinerFeaturePort): + """Port for software-level mining start/stop control.""" + + feature_type = MinerFeatureType.MINING_CONTROL + + @abstractmethod + async def start_mining(self) -> bool: + """Attempts to start mining. Returns True on success.""" raise NotImplementedError + @abstractmethod + async 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 + async def power_on(self) -> bool: + """Attempts to power on the miner. Returns True on success.""" + raise NotImplementedError + + @abstractmethod + async 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 + 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 + + +class ExternalFanControlPort(MinerFeaturePort): + """Port for controlling external fan speed (e.g., ESPHome devices).""" + + feature_type = MinerFeatureType.EXTERNAL_FAN_CONTROL + + @abstractmethod + 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 + + +# --- Info Ports --- + + +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 + + +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 --- + class MinerRepository(ABC): """Port for the Miner Repository.""" @@ -73,7 +280,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..25d3659 100644 --- a/edge_mining/domain/miner/value_objects.py +++ b/edge_mining/domain/miner/value_objects.py @@ -1,10 +1,10 @@ """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 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,91 @@ 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 MinerInfo(ValueObject): + """Value Object for miner device information.""" + + 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 + hashboard_count: Optional[int] = None + chip_count: Optional[int] = None + 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. + + 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 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. @@ -22,8 +107,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 + 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 + 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) 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 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 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 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,