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
52 changes: 1 addition & 51 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
)
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import BaseModel, field_validator
from settings_store import VALID_PLATFORMS

from core.bess import time_utils
Expand Down Expand Up @@ -2816,7 +2815,7 @@ async def run_setup_discovery():
sensors[key] = entity_id

# Discover Octopus Energy entity IDs for pricing form auto-fill
octopus_entities = ha.discover_octopus_entities(states)
octopus_entities = ha.discover_octopus_entities(registry)

# Convert top-level keys to camelCase but preserve sensor keys as
# snake_case since they are BESS config keys, not API field names.
Expand Down Expand Up @@ -2864,55 +2863,6 @@ async def run_setup_discovery():
raise HTTPException(status_code=500, detail=str(e)) from e


class ConfirmSetupPayload(BaseModel):
"""Request body for POST /api/setup/confirm."""

sensors: dict[str, str] = {}
nordpool_area: str | None = None
nordpool_config_entry_id: str | None = None
growatt_device_id: str | None = None

@field_validator("sensors")
@classmethod
def validate_entity_ids(cls, sensors: dict[str, str]) -> dict[str, str]:
for value in sensors.values():
if value and not _ENTITY_ID_RE.match(value):
raise ValueError(f"Invalid entity ID format: {value}")
return sensors


@router.post("/api/setup/confirm")
async def confirm_setup(payload: ConfirmSetupPayload):
"""Persist and apply discovered sensor configuration.

Saves the confirmed sensor mapping to /data/bess_discovered_config.json and
applies the configuration to the running ha_controller so BESS operations
can start immediately without a restart.

Args:
payload: ConfirmSetupPayload with sensors (bess_key → entity_id) and
optional nordpool_area, nordpool_config_entry_id, growatt_device_id

Returns:
dict: success confirmation
"""
from app import bess_controller

try:
bess_controller.apply_discovered_config(
sensor_map=payload.sensors,
nordpool_area=payload.nordpool_area,
nordpool_config_entry_id=payload.nordpool_config_entry_id,
growatt_device_id=payload.growatt_device_id,
)

logger.info(f"Setup confirmed: applied {len(payload.sensors)} sensor mappings")
return {"success": True, "applied_sensors": len(payload.sensors)}
except Exception as e:
logger.error(f"Error confirming setup: {e}")
raise HTTPException(status_code=500, detail=str(e)) from e


@router.post("/api/setup/complete")
async def setup_complete(payload: APISetupCompletePayload):
"""Atomic wizard completion: persist all sections and apply live.
Expand Down
28 changes: 0 additions & 28 deletions backend/tests/test_setup_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,34 +187,6 @@ def test_partially_configured_still_needs_wizard(self, mock_controller):
assert body["configuredSensors"] == 1


class TestConfirmSetup:
"""POST /api/setup/confirm."""

def test_rejects_invalid_entity_ids(self, mock_controller):
resp = _client.post(
"/api/setup/confirm",
json={"sensors": {"battery_soc": "not-valid-format"}},
)
assert resp.status_code == 422

def test_accepts_valid_entity_ids(self, mock_controller):
mock_controller.ha_controller.apply_discovered_config = MagicMock()
resp = _client.post(
"/api/setup/confirm",
json={
"sensors": {"battery_soc": "sensor.growatt_battery_soc"},
"nordpool_area": "SE4",
"nordpool_config_entry_id": "abc-123",
},
)
assert resp.status_code == 200

def test_accepts_empty_sensors(self, mock_controller):
mock_controller.ha_controller.apply_discovered_config = MagicMock()
resp = _client.post("/api/setup/confirm", json={"sensors": {}})
assert resp.status_code == 200


class TestSetupCompleteLegacy:
"""POST /api/setup/complete — legacy persistence tests."""

Expand Down
20 changes: 10 additions & 10 deletions core/bess/ha_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2444,25 +2444,25 @@ def _match_optional_sensor(

return None

def discover_octopus_entities(self, states: list[dict]) -> dict[str, str]:
"""Discover Octopus Energy pricing entity IDs.
def discover_octopus_entities(self, entity_registry: list[dict]) -> dict[str, str]:
"""Discover Octopus Energy pricing entity IDs from the entity registry.

Scans entity states for Octopus Energy event entities that provide
half-hourly rate data. These map to the 4 pricing form fields:
importToday, importTomorrow, exportToday, exportTomorrow.
Uses the immutable ``platform`` field (same approach as Growatt/SolaX
discovery) so renamed entities are still found. Classifies each entity
by keywords in the ``entity_id``.

Args:
states: List of state dicts from /api/states
entity_registry: Entity registry list from HA WebSocket API.

Returns:
dict mapping form field keys to entity_ids, empty if not found
"""
result: dict[str, str] = {}
for state in states:
entity_id = str(state.get("entity_id", ""))
lower_id = entity_id.lower()
if "octopus_energy" not in lower_id:
for entry in entity_registry:
if entry.get("platform") != "octopus_energy":
continue
entity_id = str(entry.get("entity_id", ""))
lower_id = entity_id.lower()
if "export" in lower_id:
if "next_day" in lower_id:
result["exportTomorrow"] = entity_id
Expand Down
97 changes: 97 additions & 0 deletions core/bess/tests/unit/test_registry_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,3 +814,100 @@ def test_system_production_none_when_nothing_available(self):
"""Returns None when neither direct nor fallback is available."""
with self._mock_sensor({}):
assert self.ctrl.get_system_production_lifetime() is None


# ---------------------------------------------------------------------------
# Octopus Energy entity discovery from registry
# ---------------------------------------------------------------------------


class TestDiscoverOctopusEntities:
"""discover_octopus_entities uses platform field, not entity_id substring."""

def setup_method(self):
self.ctrl = _make_controller()

def _octopus_registry(self) -> list[dict]:
"""Typical Octopus Energy registry with all 4 rate entities."""
return [
_entity(
"event.octopus_energy_electricity_current_day_rates",
"octopus_energy",
"oe_current_day_rates",
),
_entity(
"event.octopus_energy_electricity_next_day_rates",
"octopus_energy",
"oe_next_day_rates",
),
_entity(
"event.octopus_energy_electricity_export_current_day_rates",
"octopus_energy",
"oe_export_current_day_rates",
),
_entity(
"event.octopus_energy_electricity_export_next_day_rates",
"octopus_energy",
"oe_export_next_day_rates",
),
# Non-Octopus entity should be ignored
_entity(
"sensor.growatt_battery_soc",
"growatt_server",
"growatt_battery_soc",
),
]

def test_all_four_fields_discovered(self):
result = self.ctrl.discover_octopus_entities(self._octopus_registry())
assert result == {
"importToday": "event.octopus_energy_electricity_current_day_rates",
"importTomorrow": "event.octopus_energy_electricity_next_day_rates",
"exportToday": "event.octopus_energy_electricity_export_current_day_rates",
"exportTomorrow": "event.octopus_energy_electricity_export_next_day_rates",
}

def test_empty_registry(self):
assert self.ctrl.discover_octopus_entities([]) == {}

def test_no_octopus_entities(self):
registry = [
_entity("sensor.growatt_battery_soc", "growatt_server", "soc"),
]
assert self.ctrl.discover_octopus_entities(registry) == {}

def test_renamed_entities_still_matched(self):
"""Platform field is immutable — renamed entity_ids are still found."""
registry = [
_entity(
"event.my_custom_name_current_day_rates",
"octopus_energy",
"oe_current_day_rates",
),
]
result = self.ctrl.discover_octopus_entities(registry)
assert result == {
"importToday": "event.my_custom_name_current_day_rates",
}

def test_partial_discovery(self):
"""Only import entities present — export keys absent."""
registry = [
_entity(
"event.octopus_energy_electricity_current_day_rates",
"octopus_energy",
"oe_current_day_rates",
),
_entity(
"event.octopus_energy_electricity_next_day_rates",
"octopus_energy",
"oe_next_day_rates",
),
]
result = self.ctrl.discover_octopus_entities(registry)
assert result == {
"importToday": "event.octopus_energy_electricity_current_day_rates",
"importTomorrow": "event.octopus_energy_electricity_next_day_rates",
}
assert "exportToday" not in result
assert "exportTomorrow" not in result
28 changes: 3 additions & 25 deletions frontend/src/pages/SetupWizardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ const SetupWizardPage: React.FC = () => {
const [scanError, setScanError] = useState<string | null>(null);
const [discovery, setDiscovery] = useState<DiscoveryResult | null>(null);
const [sensors, setSensors] = useState<PerPlatformSensors>(emptyPerPlatformSensors());
const [confirming, setConfirming] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
const [completing, setCompleting] = useState(false);
const [completeError, setCompleteError] = useState<string | null>(null);
const existingSensorsRef = useRef<PerPlatformSensors>(emptyPerPlatformSensors());
Expand Down Expand Up @@ -251,24 +249,9 @@ const SetupWizardPage: React.FC = () => {
});
}, [handleScan]);

const handleConfirm = async () => {
const handleConfirm = () => {
if (!discovery) return;
setConfirming(true);
setConfirmError(null);
try {
await api.post('/api/setup/confirm', {
sensors: getActiveSensorsFlat(sensors),
nordpool_area: discovery.nordpoolArea,
// Prefer the user-entered form value; fall back to auto-detected value
nordpool_config_entry_id: pricingForm.nordpoolConfigEntryId || discovery.nordpoolConfigEntryId,
growatt_device_id: inverterForm.deviceId || discovery.growattDeviceId,
});
setStep(2);
} catch (err: unknown) {
setConfirmError(err instanceof Error ? err.message : 'Configuration failed');
} finally {
setConfirming(false);
}
setStep(2);
};

const handleComplete = async () => {
Expand Down Expand Up @@ -443,18 +426,13 @@ const SetupWizardPage: React.FC = () => {
</button>
<button
onClick={handleConfirm}
disabled={confirming || !allRequiredFilled}
disabled={!allRequiredFilled}
className="flex items-center space-x-2 px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium disabled:opacity-60"
>
{confirming && <div className="h-4 w-4 border-2 border-white rounded-full border-t-transparent animate-spin" />}
<span>Next: Electricity Pricing</span>
<ChevronRight className="h-4 w-4" />
</button>
</div>

{confirmError && (
<p className="text-sm text-red-600 dark:text-red-400 text-center">{confirmError}</p>
)}
</div>
)}

Expand Down
Loading