From 1b2e6ba5f091d0104924e5c15dcf3c23b46f5553 Mon Sep 17 00:00:00 2001 From: Marco Mancino Date: Wed, 1 Jul 2026 01:00:02 +0200 Subject: [PATCH 1/2] feat(miner): fetch max hash rate, max power and model at creation time Enable the device-fetch buttons in the Add Miner form before the miner is saved, querying the associated controller directly instead of the (not yet existing) miner. - Add GET /miner-controllers/{id}/info and /miner-controllers/{id}/limits endpoints, backed by MinerActionService.get_controller_info / get_controller_limits, which read a controller via a temporary miner. - Extract shared _read_miner_info / _read_miner_limits helpers and a _temp_miner_for_controller builder to avoid duplication. - In the form, show the fetch buttons for Max Hash Rate, Max Power and Model when a controller is selected; in creation mode the selected controllers are tried until one returns a value. Closes #49 --- CHANGELOG.md | 26 ++++++ .../adapters/domain/miner/fast_api/router.py | 42 +++++++++ core/edge_mining/application/interfaces.py | 8 ++ .../services/miner_action_service.py | 92 ++++++++++++------- .../src/components/miners/MinerFormModal.vue | 86 ++++++++++++----- frontend/src/core/services/minerService.ts | 8 ++ 6 files changed, 210 insertions(+), 52 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..1e177b87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +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.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- 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 + +- `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 341d94c9..19762f1d 100644 --- a/core/edge_mining/adapters/domain/miner/fast_api/router.py +++ b/core/edge_mining/adapters/domain/miner/fast_api/router.py @@ -659,6 +659,48 @@ 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 + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + @router.put("/miner-controllers/{controller_id}", response_model=MinerControllerSchema) async def update_miner_controller( controller_id: EntityId, diff --git a/core/edge_mining/application/interfaces.py b/core/edge_mining/application/interfaces.py index c41c9cb1..6fe81853 100644 --- a/core/edge_mining/application/interfaces.py +++ b/core/edge_mining/application/interfaces.py @@ -230,6 +230,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.""" + class ConfigurationServiceInterface(ABC): """Base interface for configuration services in the Edge Mining application.""" diff --git a/core/edge_mining/application/services/miner_action_service.py b/core/edge_mining/application/services/miner_action_service.py index ced94349..94d9a301 100644 --- a/core/edge_mining/application/services/miner_action_service.py +++ b/core/edge_mining/application/services/miner_action_service.py @@ -51,32 +51,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) @@ -84,7 +85,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 @@ -93,10 +94,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.""" @@ -397,16 +436,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 fe18c1b0..07d8fbc4 100644 --- a/frontend/src/components/miners/MinerFormModal.vue +++ b/frontend/src/components/miners/MinerFormModal.vue @@ -294,13 +294,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") { @@ -334,26 +352,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]); @@ -426,7 +470,7 @@ function handleSave() { />