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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Additive history backfill on manual collection**: a manual per-device collection now re-fetches the whole requested look-back window from the provider and merges it into the store (de-duplicated by the `(device_id, timestamp)` primary key), filling internal gaps without dropping existing data. Previously it only ingested incrementally from the last stored point, ignoring `lookback_hours`. `EnergyLoadHistoryProviderPort.get_power_points` gains a `force_refresh` flag; the scheduled collection stays incremental (`ports.py`, `home_assistant_api_history.py`, `home_load_history_service.py`).
- **Forecast retrain after manual collection**: the device history modal prompts the user to retrain the device's forecast model with the freshly collected data, triggering per-device training and refreshing the forecast on success (frontend).
- **Training outcome reporting** (`LoadTrainingResult` value object in `domain/home_load/value_objects.py`): `train_device` now reports whether a model was actually `trained` (with best adapter, MAE and sample count), `skipped` (with reason, e.g. insufficient history) or `failed`. The `training/trigger` endpoint surfaces the outcome and the UI shows a status toast, instead of always reporting a generic "completed".

- **Miner controller connection test** (`edge_mining/adapters/domain/miner/`):
- New `POST /miner-controllers/test-connection` endpoint that tests a (non-persisted) controller configuration and returns a reachability result (`MinerControllerTestConnectionSchema`)
- `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)

### 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).
Expand Down
26 changes: 26 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 @@ -10,6 +10,7 @@
FeaturePrioritySchema,
MinerControllerCreateSchema,
MinerControllerSchema,
MinerControllerTestConnectionSchema,
MinerControllerUpdateSchema,
MinerCreateSchema,
MinerFeatureSchema,
Expand Down Expand Up @@ -553,6 +554,31 @@ async def add_miner_controller(
raise HTTPException(status_code=500, detail=str(e)) from e


@router.post("/miner-controllers/test-connection", response_model=MinerControllerTestConnectionSchema)
async def test_miner_controller_connection(
controller_schema: MinerControllerCreateSchema,
action_service: Annotated[MinerActionServiceInterface, Depends(get_miner_action_service)],
) -> MinerControllerTestConnectionSchema:
"""Test the connection of a miner controller before adding it."""
try:
controller_to_test = controller_schema.to_model()

if controller_to_test.config is None:
raise MinerControllerConfigurationError("Miner controller configuration should be set")

snapshot = await action_service.test_miner_controller_connection(controller_to_test)

return MinerControllerTestConnectionSchema(
success=True,
message="Connection successful.",
details=MinerStateSnapshotSchema.from_model(snapshot),
)
except MinerControllerConfigurationError as e:
return MinerControllerTestConnectionSchema(success=False, message=str(e), details=None)
except Exception as e:
return MinerControllerTestConnectionSchema(success=False, message=f"Connection failed: {e}", details=None)


@router.get("/miner-controllers/types", response_model=List[MinerControllerAdapter])
async def get_miner_controller_types() -> List[MinerControllerAdapter]:
"""Get a list of available miner controller types."""
Expand Down
10 changes: 10 additions & 0 deletions core/edge_mining/adapters/domain/miner/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,16 @@ class Config:
}


class MinerControllerTestConnectionSchema(BaseModel):
"""Result of a miner controller connection test."""

success: bool = Field(..., description="Whether the controller is reachable and properly configured")
message: str = Field(default="", description="Human readable result message")
details: Optional[MinerStateSnapshotSchema] = Field(
default=None, description="State snapshot retrieved from the controller on success"
)


class MinerControllerDummyConfigSchema(BaseModel):
"""Schema for Dummy MinerControllerConfig."""

Expand Down
10 changes: 10 additions & 0 deletions core/edge_mining/application/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ async def get_energy_monitor(self, energy_source: EnergySource) -> Optional[Ener
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 build_miner_controller_adapter(
self, miner: Miner, miner_controller: MinerController
) -> Optional[MinerFeaturePort]:
"""Build a fresh (uncached) miner controller adapter from a controller entity."""

@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."""
Expand Down Expand Up @@ -232,6 +238,10 @@ 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 test_miner_controller_connection(self, controller: MinerController) -> MinerStateSnapshot:
"""Test the connection of a (possibly unsaved) miner controller and return a state snapshot."""


class ConfigurationServiceInterface(ABC):
"""Base interface for configuration services in the Edge Mining application."""
Expand Down
24 changes: 20 additions & 4 deletions core/edge_mining/application/services/adapter_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,16 @@ async def _initialize_energy_monitor_adapter(
return None

async def _initialize_miner_controller_adapter(
self, miner: Miner, miner_controller: MinerController
self, miner: Miner, miner_controller: MinerController, use_cache: bool = True
) -> Optional[MinerFeaturePort]:
"""Initialize a miner controller adapter."""
"""Initialize a miner controller adapter.

When ``use_cache`` is False the instance is built fresh and is not stored
in (nor read from) the adapters cache. This is used to test the connection
of a controller that has not been persisted yet.
"""
# If the adapter has already been created, we use it.
if miner_controller.id in self._instance_cache:
if use_cache and miner_controller.id in self._instance_cache:
if self.logger:
self.logger.debug(
f"Returning cached adapter instance "
Expand Down Expand Up @@ -362,7 +367,8 @@ async def _initialize_miner_controller_adapter(
else:
raise ValueError(f"Unsupported miner controller adapter type: {miner_controller.adapter_type}")

self._instance_cache[miner_controller.id] = instance
if use_cache:
self._instance_cache[miner_controller.id] = instance
return instance
except Exception as e:
if self.logger:
Expand Down Expand Up @@ -698,6 +704,16 @@ async def get_miner_controller_adapter(self, miner: Miner, controller_id: Entity
return None
return await self._initialize_miner_controller_adapter(miner, miner_controller)

async def build_miner_controller_adapter(
self, miner: Miner, miner_controller: MinerController
) -> Optional[MinerFeaturePort]:
"""Build a miner controller adapter from a (possibly unsaved) controller entity.

The instance is created fresh and not cached, so it can be used to test the
connection of a controller before it is persisted.
"""
return await self._initialize_miner_controller_adapter(miner, miner_controller, use_cache=False)

async def sync_miner_features(self, miner: Miner) -> bool:
"""Reconcile stored features with what controllers actually support.

Expand Down
77 changes: 77 additions & 0 deletions core/edge_mining/application/services/miner_action_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from edge_mining.domain.common import EntityId, Watts
from edge_mining.domain.miner.aggregate_roots import Miner
from edge_mining.domain.miner.common import MinerFeatureType, MinerStatus
from edge_mining.domain.miner.entities import MinerController
from edge_mining.domain.miner.events import MinerStateChangedEvent
from edge_mining.domain.miner.exceptions import (
MinerControllerConfigurationError,
Expand Down Expand Up @@ -482,3 +483,79 @@ async def get_miner_details_from_controller(self, controller_id: EntityId) -> Mi
self.logger.debug(f"Retrieved miner details for controller {controller_id}")

return snapshot

async def test_miner_controller_connection(self, controller: MinerController) -> MinerStateSnapshot:
"""Test the connection of a (possibly unsaved) miner controller.

Builds a fresh adapter from the given controller entity (without persisting
it) and queries it to verify it is reachable and properly configured.
Returns a state snapshot on success, raises MinerControllerConfigurationError
if the controller can not be reached or returns no usable data.
"""
if self.logger:
self.logger.info(f"Testing connection for miner controller '{controller.name}' ({controller.adapter_type})")

# Build a temporary miner so adapters that need miner attributes can be
# instantiated. Defaults are used since no miner exists yet.
temp_miner = Miner(
name="Connection test",
model="Unknown",
hash_rate_max=HashRate(1, "TH/s"),
power_consumption_max=Watts(1.0),
active=True,
features=[],
)

adapter = await self.adapter_service.build_miner_controller_adapter(temp_miner, controller)

if adapter is None:
raise MinerControllerConfigurationError(
"Unable to initialize the controller. Check the configuration and try again."
)

current_status = MinerStatus.UNKNOWN
if isinstance(adapter, StatusMonitorPort):
current_status = await adapter.get_status()

current_hashrate = None
if isinstance(adapter, HashrateMonitorPort):
current_hashrate = await adapter.get_hashrate()

current_power = None
if isinstance(adapter, PowerMonitorPort):
current_power = await adapter.get_power()

device_info = None
if isinstance(adapter, DeviceInfoPort):
device_info = await adapter.get_device_info()

# The controller is considered reachable if it returns a determinate status
# or any usable data (hashrate, power or device info).
is_reachable = any(
(
current_status != MinerStatus.UNKNOWN,
current_hashrate is not None,
current_power is not None,
device_info is not None,
)
)

if not is_reachable:
if self.logger:
self.logger.warning(
f"Connection test failed for controller '{controller.name}'. No response from the device."
)
raise MinerControllerConfigurationError(
"No response from the device. Check the address, port and credentials."
)

snapshot = MinerStateSnapshot(
status=current_status,
hash_rate=current_hashrate,
power_consumption=current_power,
)

if self.logger:
self.logger.debug(f"Connection test succeeded for controller '{controller.name}'")

return snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
PhGear,
PhPlugs,
PhCircuitry,
PhPlugsConnected,
PhCheckCircle,
PhWarningCircle,
} from "@phosphor-icons/vue";
import { formatType } from "../../core/utils/index";

Expand All @@ -33,6 +36,15 @@ const minerControllerService = new MinerControllerService();
const requiredExternalServiceType = ref<string | null>(null);
const isLoadingExternalServiceType = ref(false);

// Connection test state
const isTestingConnection = ref(false);
const testResult = ref<{ success: boolean; message: string } | null>(null);

// The connection test is only available for the PyASIC controller
const canTestConnection = computed(
() => formData.value.adapter_type === MinerControllerAdapter.PYASIC
);

// Local form state
const formData = ref<MinerController>({
name: "",
Expand All @@ -46,6 +58,7 @@ watch(
() => props.open,
(isOpen) => {
if (isOpen) {
testResult.value = null;
if (props.minerController) {
formData.value = {
...props.minerController,
Expand Down Expand Up @@ -90,6 +103,9 @@ const filteredExternalServices = computed(() => {
watch(
() => formData.value.adapter_type,
async (newType) => {
// Reset any previous connection test result when the adapter changes
testResult.value = null;

if (!newType) {
requiredExternalServiceType.value = null;
return;
Expand Down Expand Up @@ -130,6 +146,27 @@ function cleanMinerController(minerController: MinerController): MinerController
return cleaned;
}

async function handleTestConnection() {
if (!isFormValid.value || isTestingConnection.value) return;

isTestingConnection.value = true;
testResult.value = null;
try {
// Deep clone to remove Vue reactive proxies
const rawData = JSON.parse(JSON.stringify(toRaw(formData.value)));
const cleanedData = cleanMinerController(rawData);
const result = await minerControllerService.testConnection(cleanedData);
testResult.value = { success: result.success, message: result.message };
} catch (error) {
testResult.value = {
success: false,
message: error instanceof Error ? error.message : "Connection test failed",
};
} finally {
isTestingConnection.value = false;
}
}

function handleSave() {
if (isFormValid.value) {
// Deep clone to remove Vue reactive proxies
Expand Down Expand Up @@ -278,19 +315,46 @@ function handleSave() {
</div>
</div>

<!-- Connection test result -->
<div
v-if="canTestConnection && testResult"
class="alert"
:class="testResult.success ? 'alert-success' : 'alert-error'"
>
<PhCheckCircle v-if="testResult.success" :size="20" />
<PhWarningCircle v-else :size="20" />
<span class="text-sm">{{ testResult.message }}</span>
</div>

<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-base-300/40">
<button type="button" class="btn btn-ghost" @click="handleClose">
Cancel
</button>
<div class="flex justify-between items-center gap-3 pt-4 border-t border-base-300/40">
<!-- Test connection (PyASIC only) -->
<button
type="submit"
class="btn btn-primary gap-2"
:disabled="!isFormValid"
v-if="canTestConnection"
type="button"
class="btn btn-outline gap-2"
:disabled="!isFormValid || isTestingConnection"
@click="handleTestConnection"
>
<PhFloppyDisk :size="18" />
{{ isEdit ? "Save Changes" : "Create Controller" }}
<span v-if="isTestingConnection" class="loading loading-spinner loading-sm"></span>
<PhPlugsConnected v-else :size="18" />
{{ isTestingConnection ? "Testing..." : "Test Connection" }}
</button>
<div v-else></div>

<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="handleClose">
Cancel
</button>
<button
type="submit"
class="btn btn-primary gap-2"
:disabled="!isFormValid"
>
<PhFloppyDisk :size="18" />
{{ isEdit ? "Save Changes" : "Create Controller" }}
</button>
</div>
</div>
</form>
</div>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/core/models/minerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface MinerController {
external_service_id?: string;
}

export interface MinerControllerTestConnectionResult {
success: boolean;
message: string;
details?: unknown;
}

export interface ConfigSchemaProperty {
type?: string;
title?: string;
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/core/services/minerControllerService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { BaseService } from "./baseService";
import type { MinerController, MinerControllerAdapter, ConfigSchema } from "../models/minerController";
import type {
MinerController,
MinerControllerAdapter,
ConfigSchema,
MinerControllerTestConnectionResult,
} from "../models/minerController";
import type { MinerStateSnapshot } from "../models/miner";

export class MinerControllerService extends BaseService {
Expand Down Expand Up @@ -36,6 +41,13 @@ export class MinerControllerService extends BaseService {
.then((result) => (result === "null" ? null : result));
}

testConnection(minerController: MinerController): Promise<MinerControllerTestConnectionResult> {
return this.post<MinerControllerTestConnectionResult>(
"/miner-controllers/test-connection",
minerController
).getData();
}

getMinerDetails(controllerId: string): Promise<MinerStateSnapshot> {
return this.get<MinerStateSnapshot>(`/miner-controllers/${controllerId}/miner-details`).getData();
}
Expand Down
Loading