Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,18 @@ 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

- Reorganized configuration forms for readability: each entity field is now paired with its unit of measure on the same row, and fields are grouped by domain (Power / Energy) when at least two domains are present, preserving the chronological order within each group. Applied generically to all schema-driven configuration forms (#17).
- 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.
40 changes: 40 additions & 0 deletions core/edge_mining/adapters/domain/miner/fast_api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions core/edge_mining/application/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
92 changes: 61 additions & 31 deletions core/edge_mining/application/services/miner_action_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,40 +52,41 @@ 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)
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.")
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
Expand All @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down
86 changes: 65 additions & 21 deletions frontend/src/components/miners/MinerFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -328,13 +328,31 @@ const hasDeviceInfoFeature = computed(() => {

async function ensureDeviceInfo(): Promise<MinerInfo | null> {
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") {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -460,7 +504,7 @@ function handleSave() {
/>
<!-- Fetch hostname from device info -->
<button
v-if="isEdit && formData.id && hasDeviceInfoFeature"
v-if="(formData.id && hasDeviceInfoFeature) || (!formData.id && selectedControllerIds.length > 0)"
type="button"
class="btn btn-sm btn-square"
:class="fieldFetchSuccess['name'] ? 'btn-success' : 'btn-ghost'"
Expand Down Expand Up @@ -489,7 +533,7 @@ function handleSave() {
/>
<!-- Fetch model from device info -->
<button
v-if="isEdit && formData.id && hasDeviceInfoFeature"
v-if="(formData.id && hasDeviceInfoFeature) || (!formData.id && selectedControllerIds.length > 0)"
type="button"
class="btn btn-sm btn-square"
:class="fieldFetchSuccess['model'] ? 'btn-success' : 'btn-ghost'"
Expand Down Expand Up @@ -735,7 +779,7 @@ function handleSave() {
</select>
<!-- Fetch limits from miner -->
<button
v-if="isEdit && formData.id"
v-if="formData.id || selectedControllerIds.length > 0"
type="button"
class="btn btn-sm btn-square"
:class="fieldFetchSuccess['hash_rate_max'] ? 'btn-success' : 'btn-ghost'"
Expand Down Expand Up @@ -772,7 +816,7 @@ function handleSave() {
</label>
<!-- Fetch max power from miner -->
<button
v-if="isEdit && formData.id"
v-if="formData.id || selectedControllerIds.length > 0"
type="button"
class="btn btn-sm btn-square"
:class="fieldFetchSuccess['power_consumption_max'] ? 'btn-success' : 'btn-ghost'"
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/core/services/minerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ export class MinerService extends BaseService {
return this.get<MinerInfo | null>(`/miners/${minerId}/info`).getData();
}

getControllerLimits(controllerId: string): Promise<MinerLimit | null> {
return this.get<MinerLimit | null>(`/miner-controllers/${controllerId}/limits`).getData();
}

getControllerInfo(controllerId: string): Promise<MinerInfo | null> {
return this.get<MinerInfo | null>(`/miner-controllers/${controllerId}/info`).getData();
}

getControllerSupportedFeatures(controllerId: string): Promise<string[]> {
return this.get<string[]>(`/miner-controllers/${controllerId}/supported-features`).getData();
}
Expand Down
Loading