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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- **The setup wizard no longer locks you out of an inverter platform it failed to auto-detect** — every platform stays selectable, and a re-scan keeps the one you picked. ([#621](https://github.com/johanzander/bess-manager/issues/621))
- **System no longer gets stuck on "initializing" when many consecutive periods are near-tied** — a long run of volatile prices could make every hourly optimization fail, leaving no schedule at all. ([#624](https://github.com/johanzander/bess-manager/issues/624))
- **Grid charging now reaches the planned amount instead of stopping just short** — the charge rate is written as a whole percent, and rounding it down meant the battery charged slightly less than the plan counted on.
- **Growatt VPP no longer briefly executes the previous period's power command when switching modes** — enabling remote control commits immediately, so the power target is now written before it, and cleared on release. ([#593](https://github.com/johanzander/bess-manager/issues/593))
Expand Down
1 change: 1 addition & 0 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2962,6 +2962,7 @@ async def run_setup_discovery():
{
"growatt_found": integrations["growatt_found"],
"growatt_device_id": integrations["growatt_device_id"],
"huawei_found": integrations["huawei_found"],
"huawei_device_id": integrations.get("huawei_device_id"),
"solax_found": integrations["solax_found"],
"solax_has_growatt_tou": integrations.get(
Expand Down
97 changes: 97 additions & 0 deletions backend/tests/test_setup_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,7 @@ def test_octopus_only_persists_gbp_defaults(self):
integrations = {
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"solax_found": False,
"nordpool_found": False,
"nordpool_area": None,
Expand Down Expand Up @@ -986,6 +987,7 @@ def test_nordpool_discovery_does_not_clear_costs(self):
integrations = {
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"solax_found": False,
"nordpool_found": True,
"nordpool_area": "SE3",
Expand Down Expand Up @@ -1019,6 +1021,7 @@ def test_norwegian_nordpool_updates_currency_preserves_costs(self):
integrations = {
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"solax_found": False,
"nordpool_found": True,
"nordpool_area": "NO1",
Expand Down Expand Up @@ -1049,6 +1052,7 @@ def test_no_locale_hints_leaves_defaults_unchanged(self):
integrations = {
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"solax_found": False,
"nordpool_found": False,
"nordpool_area": None,
Expand Down Expand Up @@ -1080,6 +1084,7 @@ def test_discover_optional_sensors_receives_entity_registry(self):
integrations = {
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"solax_found": False,
"nordpool_found": False,
"nordpool_area": None,
Expand Down Expand Up @@ -1108,6 +1113,96 @@ def test_discover_optional_sensors_receives_entity_registry(self):
)


class TestDiscoverForwardsInverterDetectionFlags:
"""POST /api/setup/discover must forward a detection flag for EVERY
inverter platform the wizard shows (#621).

`discover_integrations()` produces `huawei_found`, but the endpoint
dropped it while forwarding the other three. The wizard's
`DiscoveryResult` declares `huaweiFound: boolean` (non-optional), so the
missing key surfaced as `undefined` rather than a type error, and the
Huawei detection dot read grey for every user including a correctly
detected stock `huawei_solar` install.

Asserting the flag on `discover_integrations()` alone is what let this
through — `test_scenario_discovery.py::...` already did that and passed.
The gap is in the endpoint's payload, so that is what these assert.
"""

def _run_discover(self, ctrl, integrations):
ha = ctrl.ha_controller
ha.discover_integrations.return_value = (integrations, [])
ha.fetch_entity_registry.return_value = []
ha.discover_sensors_from_registry.return_value = ({}, None, {})
ha.discover_current_sensors.return_value = {}
ha.discover_optional_sensors.return_value = {}
ha.discover_octopus_entities.return_value = {}
ha.ENTITY_SUFFIX_MAP = {}
ha.SOLAX_GROWATT_MIN_SUFFIX_MAP = {}
ha.SOLAX_GROWATT_SPH_SUFFIX_MAP = {}
ha.SOLAX_NATIVE_SUFFIX_MAP = {}
sys.modules["app"].bess_controller = ctrl
return _client.post("/api/setup/discover")

@staticmethod
def _integrations(**overrides):
base = {
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"huawei_device_id": None,
"solax_found": False,
"solis_found": False,
"nordpool_found": False,
"nordpool_area": None,
"nordpool_custom_area": None,
"nordpool_custom_entity": None,
"nordpool_config_entry_id": None,
"octopus_found": False,
"detected_inverter_platforms": [],
"detected_phase_count": None,
"currency": None,
"vat_multiplier": None,
}
base.update(overrides)
return base

def test_every_platform_detection_flag_is_present_in_the_payload(self):
"""All four wizard platform tabs need their flag, not just three."""
ctrl = _make_discover_controller(deepcopy(_PRE_EXISTING_STORE))
resp = self._run_discover(ctrl, self._integrations())

assert resp.status_code == 200
body = resp.json()
for key in ("growattFound", "solaxFound", "solisFound", "huaweiFound"):
assert key in body, f"{key} missing from /api/setup/discover payload"

def test_detected_huawei_is_reported_as_found(self):
"""A stock huawei_solar install must light the Huawei dot green."""
ctrl = _make_discover_controller(deepcopy(_PRE_EXISTING_STORE))
resp = self._run_discover(
ctrl,
self._integrations(
huawei_found=True,
huawei_device_id="dev-huawei-1",
detected_inverter_platforms=["huawei_solar_luna2000"],
),
)

assert resp.status_code == 200
assert resp.json()["huaweiFound"] is True

def test_undetected_huawei_is_reported_as_not_found(self):
"""The reporter's case: EMMA integration, so the flag is False --
False, not absent. The wizard must still be able to offer the tab.
"""
ctrl = _make_discover_controller(deepcopy(_PRE_EXISTING_STORE))
resp = self._run_discover(ctrl, self._integrations(huawei_found=False))

assert resp.status_code == 200
assert resp.json()["huaweiFound"] is False


class TestDiscoverReportsDisabledSensors:
"""POST /api/setup/discover must tell the wizard which sensors are
unmapped because their entity is disabled in HA (#549).
Expand All @@ -1123,6 +1218,7 @@ def _run_discover(self, ctrl, platform_sensors, platform_disabled, platform):
{
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"solax_found": True,
"nordpool_found": False,
"nordpool_area": None,
Expand Down Expand Up @@ -1243,6 +1339,7 @@ def _integrations(self, **overrides) -> dict:
base = {
"growatt_found": False,
"growatt_device_id": None,
"huawei_found": False,
"solax_found": False,
"nordpool_found": False,
"nordpool_area": None,
Expand Down
11 changes: 10 additions & 1 deletion docs/SOFTWARE_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,12 +494,21 @@ On first startup with no sensors configured, or when the user triggers discovery

The HA WebSocket API (`config/entity_registry/list`) returns every registered entity with its `platform` field.

Matching is by exact platform name, so a supported inverter reached through a
different integration is not detected — Huawei LUNA2000/EMMA via
`huawei_emma_management` rather than the stock `huawei_solar`, for example.
Detection therefore narrows the wizard's **defaults**, never its **choices**:
every platform stays selectable so such a user can pick theirs and map the
sensors by hand (#621).

Detected integrations:

| Category | HA Platform | Detected As |
|-----------|---------------------|-------------|
| Inverter | `growatt_server` | Growatt |
| Inverter | `solax_modbus` | SolaX |
| Inverter | `solis_modbus` | Solis |
| Inverter | `huawei_solar` | Huawei |
| Price | `nordpool` | Nordpool |
| Price | `octopus_energy` | Octopus Energy |
| Forecast | `solcast_solar` | Solcast solar forecast |
Expand Down Expand Up @@ -597,7 +606,7 @@ The setup wizard is a 6-step flow for first-time configuration. It is triggered
#### Wizard Steps (Frontend: `SetupWizardPage.tsx`)

1. **Scan** — Calls `/api/setup/discover` to auto-detect integrations and sensors
2. **Review Sensors** — Displays discovered sensor mappings, allows manual correction, selects inverter platform. Blocked while a required sensor is unmapped, or while a required sensor's only entity is disabled in HA (the entities to enable are listed by name)
2. **Review Sensors** — Displays discovered sensor mappings, allows manual correction, selects inverter platform. Every platform is selectable regardless of what was detected; the per-platform status dot reports detection, and the auto-detected platform is merely preselected. Blocked while a required sensor is unmapped, or while a required sensor's only entity is disabled in HA (the entities to enable are listed by name)
3. **Electricity Pricing** — Configure price area, provider (Nordpool/Octopus), markup, VAT (pre-filled from discovery hints). Blocked until the selected provider's required configuration is filled in, mirroring the server-side check on `/api/setup/complete`
4. **Battery** — Set capacity, SOC limits, power rating, cycle cost
5. **Home** — Set consumption, fuse current, voltage, phase count (pre-filled from detected phase count)
Expand Down
101 changes: 101 additions & 0 deletions e2e/tests/setup-wizard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,104 @@ test.describe('Setup Wizard', () => {
await expect(page.getByRole('button', { name: /Next: Battery/i })).toBeEnabled();
});
});

// ---------------------------------------------------------------------------
// Undetected inverter platform (#621)
// ---------------------------------------------------------------------------

/**
* A user whose inverter integration is not one BESS recognises — the
* reporter runs Huawei LUNA2000/EMMA through `huawei_emma_management`, so
* `_INVERTER_PLATFORMS` (which matches only `huawei_solar`) detects nothing.
*
* Discovery failing must narrow the wizard's *defaults*, not its *choices*:
* a user who knows their platform has to be able to pick it and configure
* the sensors by hand. These tests stub a discovery in which nothing at all
* was recognised, which is exactly that user's state.
*/
test.describe('Setup Wizard — undetected inverter platform', () => {
const NOTHING_DETECTED = {
growattFound: false,
growattDeviceId: null,
huaweiFound: false,
huaweiDeviceId: null,
solaxFound: false,
solaxHasGrowattTou: false,
solaxHasGrowattGen3: false,
solisFound: false,
nordpoolFound: true,
nordpoolArea: 'SE3',
nordpoolCustomArea: null,
nordpoolCustomEntity: null,
nordpoolConfigEntryId: 'entry1',
octopusFound: false,
entsoeFound: false,
entsoeEntity: null,
sensors: {},
platformSensors: {},
missingSensors: [],
detectedInverterPlatforms: [],
detectedPhaseCount: null,
currency: 'SEK',
vatMultiplier: 1.25,
};

test.beforeEach(async ({ page }) => {
await page.route('**/api/setup/discover', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(NOTHING_DETECTED),
});
});
});

/** Fill every sensor input the wizard reports as undetected. */
async function fillAllUndetectedSensors(page: Page) {
const missing = page.locator('input[placeholder="Not detected — enter entity ID"]');
// The placeholder clears as each field is filled, so the set shrinks and
// the loop terminates. The cap only guards against a non-shrinking set.
for (let i = 0; i < 200 && (await missing.count()) > 0; i++) {
await missing.first().fill(`sensor.manual_entity_${i}`);
}
await expect(missing).toHaveCount(0);
}

test('every platform tab stays selectable when nothing is detected', async ({ page }) => {
await page.goto('/setup');
await expectActiveStep(page, 1);

// Detection failed for all four, so under the old gate every tab was
// disabled and the wizard had no reachable platform at all.
for (const name of [/Growatt Cloud/i, /SolaX Modbus/i, /Solis Modbus/i, /Huawei/i]) {
await expect(page.getByRole('tab', { name })).toBeEnabled();
}

// And the choice actually takes: selecting Huawei reveals its panel.
await page.getByRole('tab', { name: /Huawei/i }).click();
await expect(page.getByText('LUNA2000')).toBeVisible();
await expect(page.getByPlaceholder('Huawei battery device ID')).toBeVisible();
});

test('a manually selected platform survives a re-scan', async ({ page }) => {
await page.goto('/setup');
await expectActiveStep(page, 1);

await page.getByRole('tab', { name: /Huawei/i }).click();
await expect(page.getByPlaceholder('Huawei battery device ID')).toBeVisible();

// Re-scan is the button this user reaches for after a failed detection.
// It must not silently revert the platform they just chose: the required
// -sensor list follows the selected tab while the filled-in check reads
// the sensor dict named by `sensors.platform`, so if those two diverge
// the step can never be completed no matter what is typed.
await page.getByRole('button', { name: /Re-scan/i }).click();
await expect(page.getByPlaceholder('Huawei battery device ID')).toBeVisible();

await fillAllUndetectedSensors(page);

await expect(
page.getByRole('button', { name: /Next: Electricity Pricing/i }),
).toBeEnabled();
});
});
24 changes: 12 additions & 12 deletions frontend/src/components/settings/SensorConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,15 @@ export function SensorConfigSection({ sensors, onChange, inverterForm, onInverte
|| activeInverterIntegrationId === 'solax_modbus_growatt_min'
|| activeInverterIntegrationId === 'solax_modbus_growatt_sph';

// Detection flags for disabling platform options.
// Detection flags for the wizard's per-platform status dot.
//
// These deliberately do NOT gate selection (#621). Discovery matches on a
// fixed list of HA integration names (_INVERTER_PLATFORMS), so a supported
// inverter reached through a custom integration — Huawei LUNA2000/EMMA via
// huawei_emma_management, say — is simply not recognised. Disabling the
// undetected platforms left such a user with no selectable platform at all
// and no way to finish setup. Detection narrows the *defaults*, never the
// *choices*; the dot tells the user what was found, and they can override it.
const growattDetected = wizardMode
? discovery.growattFound
: Boolean((sensors.growatt_server_min ?? {})['battery_charging_power_rate'] || (sensors.growatt_server_min ?? {})['grid_charge']);
Expand Down Expand Up @@ -357,7 +365,6 @@ export function SensorConfigSection({ sensors, onChange, inverterForm, onInverte
<TabsList className="bg-gray-100 dark:bg-gray-700/60">
<TabsTrigger
value="cloud"
disabled={wizardMode && !cloudDetected}
className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-600 dark:text-gray-300 dark:data-[state=active]:text-white"
>
<span className="flex items-center gap-1.5">
Expand All @@ -369,7 +376,6 @@ export function SensorConfigSection({ sensors, onChange, inverterForm, onInverte
</TabsTrigger>
<TabsTrigger
value="modbus"
disabled={wizardMode && !modbusDetected}
className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-600 dark:text-gray-300 dark:data-[state=active]:text-white"
>
<span className="flex items-center gap-1.5">
Expand All @@ -381,7 +387,6 @@ export function SensorConfigSection({ sensors, onChange, inverterForm, onInverte
</TabsTrigger>
<TabsTrigger
value="solis"
disabled={wizardMode && !solisDetected}
className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-600 dark:text-gray-300 dark:data-[state=active]:text-white"
>
<span className="flex items-center gap-1.5">
Expand All @@ -393,7 +398,6 @@ export function SensorConfigSection({ sensors, onChange, inverterForm, onInverte
</TabsTrigger>
<TabsTrigger
value="huawei"
disabled={wizardMode && !huaweiDetected}
className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-600 dark:text-gray-300 dark:data-[state=active]:text-white"
>
<span className="flex items-center gap-1.5">
Expand Down Expand Up @@ -455,21 +459,17 @@ export function SensorConfigSection({ sensors, onChange, inverterForm, onInverte
{ value: 'solax_modbus_growatt_sph' as const, label: 'Growatt SPH/GEN3', detected: growattModbusGen3Detected },
]).map(opt => {
const selected = inverterForm.inverterPlatform === opt.value;
const disabled = wizardMode && !opt.detected;
return (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => {
selectPlatform(opt.value);
}}
className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
disabled
? 'opacity-40 cursor-not-allowed bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500'
: selected
? 'bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300'
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-500'
selected
? 'bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300'
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<span className="flex items-center gap-1.5">
Expand Down
Loading
Loading