diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c99b70..15f07a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,17 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MinerActionService.test_miner_controller_connection()` builds a fresh, uncached adapter from the controller entity and verifies the device responds (status / hashrate / power / device info) - `AdapterService.build_miner_controller_adapter()` to instantiate an adapter from a controller entity without using or polluting the adapters cache - Frontend: "Test Connection" button in the miner controller form, shown only for the PyASIC adapter type, with inline success/error feedback (#46) -- Configure and order controller features directly in the miner creation form, - before the miner is saved (#48): - - New REST endpoint `GET /miner-controllers/{controller_id}/supported-features` - returning the feature types a controller supports, without requiring a - persisted miner (`MinerActionService.get_controller_supported_features`). - - The "Add Miner" form now shows a controller's features as soon as it is - selected (with the backend defaults: enabled, priority 50), so priority and - enabled/disabled state can be set during creation. - - On save, the chosen priority/enabled values are applied after the controllers - are linked. This also works for controllers newly added to an existing miner, - without requiring a second save. +- Configure and order controller features directly in the miner creation form, before the miner is saved (#48): + - New REST endpoint `GET /miner-controllers/{controller_id}/supported-features` returning the feature types a controller supports, without requiring a persisted miner (`MinerActionService.get_controller_supported_features`). + - The "Add Miner" form now shows a controller's features as soon as it is selected (with the backend defaults: enabled, priority 50), so priority and enabled/disabled state can be set during creation. + - On save, the chosen priority/enabled values are applied after the controllers are linked. This also works for controllers newly added to an existing miner, without requiring a second save. +- Fetch device data (max hash rate, max power and model/hostname) from the controller while creating a miner, without saving it first (#49): + - New REST endpoints `GET /miner-controllers/{controller_id}/limits` and `GET /miner-controllers/{controller_id}/info`, backed by `MinerActionService.get_controller_limits` / `get_controller_info`, which query a controller directly via a temporary miner. + - The "Add Miner" form now enables the "fetch from miner" buttons for Max Hash Rate, Max Power and Model when at least one controller is selected, trying the selected controllers until one provides the value. ### Changed @@ -43,3 +39,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Paired entity/unit fields now use a compact layout: the unit segmented control is rendered inline next to the entity input, so each row keeps a single label and helper text and stays aligned regardless of text length. Configuration modals were widened for more breathing room (#17). - Miner controller add/edit form now uses the shared schema-driven configuration form, so Home Assistant controllers (e.g. generic socket) benefit from entity/unit pairing and the unit segmented controls like the other forms (#17, #18). - Cleaned up sidebar header: removed the "Edge Mining" text label and the username placeholder, left-aligned the logo with the sidebar menu items, and refined the green glow to originate from the logo area (#33). +- `MinerActionService`: extracted shared `_read_miner_info` / `_read_miner_limits`helpers and a `_temp_miner_for_controller` builder, reused by the miner- and controller-level info/limits/details reads. diff --git a/core/edge_mining/adapters/domain/miner/fast_api/router.py b/core/edge_mining/adapters/domain/miner/fast_api/router.py index 27c35e8..a93b02d 100644 --- a/core/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/core/edge_mining/adapters/domain/miner/fast_api/router.py @@ -685,6 +685,46 @@ async def get_miner_details_from_controller( raise HTTPException(status_code=500, detail=str(e)) from e +@router.get("/miner-controllers/{controller_id}/info", response_model=Optional[MinerInfoSchema]) +async def get_controller_info( + controller_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> Optional[MinerInfoSchema]: + """Get device information directly from a controller, without requiring a persisted miner.""" + try: + info = await action_service.get_controller_info(controller_id) + + if info is None: + return None + + return MinerInfoSchema.from_model(info) + except MinerControllerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner controller 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("/miner-controllers/{controller_id}/limits", response_model=Optional[MinerLimitSchema]) +async def get_controller_limits( + controller_id: EntityId, + action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)], +) -> Optional[MinerLimitSchema]: + """Get max power / max hash rate directly from a controller, without requiring a persisted miner.""" + try: + limits = await action_service.get_controller_limits(controller_id) + + if limits is None: + return None + + return MinerLimitSchema.from_model(limits) + except MinerControllerNotFoundError as e: + raise HTTPException(status_code=404, detail="Miner controller not found") from e + except MinerControllerConfigurationError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + + @router.get("/miner-controllers/{controller_id}/supported-features", response_model=List[MinerFeatureType]) async def get_controller_supported_features( controller_id: EntityId, diff --git a/core/edge_mining/application/interfaces.py b/core/edge_mining/application/interfaces.py index 6365dbd..5817bfb 100644 --- a/core/edge_mining/application/interfaces.py +++ b/core/edge_mining/application/interfaces.py @@ -238,6 +238,14 @@ async def sync_all_miners(self, include_inactive: bool = False) -> None: async def get_miner_details_from_controller(self, controller_id: EntityId) -> Optional[MinerStateSnapshot]: """Get details of a miner from its controller as a state snapshot.""" + @abstractmethod + async def get_controller_info(self, controller_id: EntityId) -> Optional[MinerInfo]: + """Get device information directly from a controller, without a persisted miner.""" + + @abstractmethod + async def get_controller_limits(self, controller_id: EntityId) -> Optional[MinerLimit]: + """Get max power / max hash rate directly from a controller, without a persisted miner.""" + @abstractmethod async def get_controller_supported_features(self, controller_id: EntityId) -> List[MinerFeatureType]: """Get the feature types supported by a controller, without requiring a persisted miner.""" diff --git a/core/edge_mining/application/services/miner_action_service.py b/core/edge_mining/application/services/miner_action_service.py index 6a5d633..100b345 100644 --- a/core/edge_mining/application/services/miner_action_service.py +++ b/core/edge_mining/application/services/miner_action_service.py @@ -52,32 +52,33 @@ 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}") + @staticmethod + def _temp_miner_for_controller(controller_id: EntityId) -> Miner: + """Build a throwaway miner exposing every feature for a single controller. - miner: Optional[Miner] = self.miner_repo.get_by_id(miner_id) - - if not miner: - raise MinerNotFoundError(f"Miner with ID {miner_id} not found.") + Used to query a controller directly (info, limits, details) before a real + miner has been persisted, so the adapter service can resolve its ports. + """ + temp_features = [MinerFeature(feature_type=ft, controller_id=controller_id) for ft in MinerFeatureType] + return Miner( + name="Unknown", + model="Unknown", + hash_rate_max=None, + power_consumption_max=None, + active=True, + features=temp_features, + ) + async def _read_miner_info(self, miner: Miner) -> Optional[MinerInfo]: + """Read device information for a miner via its DeviceInfoPort.""" 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}.") + raise MinerControllerConfigurationError(f"No device info port available for miner {miner.name}.") 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.") - + async def _read_miner_limits(self, miner: Miner) -> Optional[MinerLimit]: + """Read max power / max hash rate for a miner via its detection ports.""" # --- Retrieve max power limit --- max_power = None power_port = await self.adapter_service.get_miner_feature_port(miner, MinerFeatureType.MAX_POWER_DETECTION) @@ -85,7 +86,7 @@ async def get_miner_limits(self, miner_id: EntityId) -> Optional[MinerLimit]: 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.") + self.logger.warning(f"No max power detection port available for miner {miner.name}. Returning None.") # --- Retrieve max hash rate limit --- max_hash_rate = None @@ -94,10 +95,48 @@ async def get_miner_limits(self, miner_id: EntityId) -> Optional[MinerLimit]: 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.") + self.logger.warning(f"No hashrate monitor port available for miner {miner.name}. 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 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.") + + return await self._read_miner_info(miner) + + 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.") + + return await self._read_miner_limits(miner) + + async def get_controller_info(self, controller_id: EntityId) -> Optional[MinerInfo]: + """Gets device information directly from a controller, without a persisted miner.""" + if self.logger: + self.logger.info(f"Getting info from controller {controller_id}") + + return await self._read_miner_info(self._temp_miner_for_controller(controller_id)) + + async def get_controller_limits(self, controller_id: EntityId) -> Optional[MinerLimit]: + """Gets max power / max hash rate directly from a controller, without a persisted miner.""" + if self.logger: + self.logger.info(f"Getting limits from controller {controller_id}") + + return await self._read_miner_limits(self._temp_miner_for_controller(controller_id)) + async def _notify(self, notifiers: List[NotificationPort], title: str, message: str): """Sends a notification using the configured notifiers.""" @@ -398,16 +437,7 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi # 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, - active=True, - features=temp_features, - ) + temp_miner = self._temp_miner_for_controller(controller_id) # Query via feature ports status_port = await self.adapter_service.get_miner_feature_port(temp_miner, MinerFeatureType.STATUS_MONITORING) diff --git a/frontend/src/components/miners/MinerFormModal.vue b/frontend/src/components/miners/MinerFormModal.vue index d33bdb2..4d948fe 100644 --- a/frontend/src/components/miners/MinerFormModal.vue +++ b/frontend/src/components/miners/MinerFormModal.vue @@ -328,13 +328,31 @@ const hasDeviceInfoFeature = computed(() => { async function ensureDeviceInfo(): Promise { if (deviceInfo.value) return deviceInfo.value; - if (!formData.value.id) { - fetchError.value = "Miner must be saved before fetching info"; + // Saved miner: resolve via the miner (respects feature priority) + if (formData.value.id) { + const info = await minerService.getMinerInfo(formData.value.id); + if (info) deviceInfo.value = info; + return info; + } + // Creation: query the selected controllers directly, use the first that responds + if (selectedControllerIds.value.length === 0) { + fetchError.value = "Select a controller before fetching info"; return null; } - const info = await minerService.getMinerInfo(formData.value.id); - if (info) deviceInfo.value = info; - return info; + let lastError: any = null; + for (const controllerId of selectedControllerIds.value) { + try { + const info = await minerService.getControllerInfo(controllerId); + if (info) { + deviceInfo.value = info; + return info; + } + } catch (error: any) { + lastError = error; + } + } + if (lastError) throw lastError; + return null; } async function fetchInfoField(field: "name" | "model") { @@ -368,26 +386,52 @@ async function fetchInfoField(field: "name" | "model") { } } +// Apply a single limit field from a MinerLimit; returns whether it was updated. +function applyLimitField(field: "hash_rate_max" | "power_consumption_max", limits: MinerLimit | null): boolean { + if (!limits) return false; + if (field === "hash_rate_max" && limits.max_hash_rate && limits.max_hash_rate.value > 0) { + formData.value.hash_rate_max = { + value: limits.max_hash_rate.value, + unit: limits.max_hash_rate.unit || "TH/s", + }; + return true; + } + if (field === "power_consumption_max" && limits.max_power && limits.max_power > 0) { + formData.value.power_consumption_max = limits.max_power; + return true; + } + return false; +} + async function fetchLimit(field: "hash_rate_max" | "power_consumption_max") { - if (!formData.value.id) { - fetchError.value = "Miner must be saved before fetching limits"; + if (!formData.value.id && selectedControllerIds.value.length === 0) { + fetchError.value = "Select a controller before fetching limits"; return; } fieldFetching.value = { ...fieldFetching.value, [field]: true }; fieldFetchSuccess.value = { ...fieldFetchSuccess.value, [field]: false }; fetchError.value = null; try { - const limits: MinerLimit = await minerService.getMinerLimits(formData.value.id); let updated = false; - if (field === "hash_rate_max" && limits.max_hash_rate && limits.max_hash_rate.value > 0) { - formData.value.hash_rate_max = { - value: limits.max_hash_rate.value, - unit: limits.max_hash_rate.unit || "TH/s", - }; - updated = true; - } else if (field === "power_consumption_max" && limits.max_power && limits.max_power > 0) { - formData.value.power_consumption_max = limits.max_power; - updated = true; + if (formData.value.id) { + // Saved miner: resolve via the miner (respects feature priority) + const limits = await minerService.getMinerLimits(formData.value.id); + updated = applyLimitField(field, limits); + } else { + // Creation: try each selected controller until one provides the value + let lastError: any = null; + for (const controllerId of selectedControllerIds.value) { + try { + const limits = await minerService.getControllerLimits(controllerId); + if (applyLimitField(field, limits)) { + updated = true; + break; + } + } catch (error: any) { + lastError = error; + } + } + if (!updated && lastError) throw lastError; } if (updated) { highlightedFields.value = new Set([field]); @@ -460,7 +504,7 @@ function handleSave() { />