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
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ claude-opus-4-7[1m] (Claude Code, bmad-dev-story-Skill)

### Decision-Needed

- [ ] [Review][Decision] **Wall-clock `setInterval` STOP-Signal-Verletzung formal abnicken oder härten** — `Running.svelte:254-265` setzt einen 1 s-`setInterval`, was die in der Story unter „Anti-Pattern" gelistete Regel „kein eigenes Tick-System" verletzt. Dev-Note in den Completion-Notes erkennt das an („sonst ist `isStale` design-bedingt nie testbar"). Optionen: (a) als bewusste Spec-Abweichung dokumentieren und Anti-Pattern-Liste in der Story korrigieren; (b) `visibilitychange`-Pause + cleanup-discipline zusätzlich härten (siehe defer W6); (c) auf reine `polling.error`-basierte Stale-Detection umstellen (kein Wall-Clock nötig, aber ohne Polling-Hang-Detection).
- [ ] [Review][Decision-OFFEN] **Wall-clock `setInterval` STOP-Signal-Verletzung formal abnicken oder härten** (cosmetic; nicht beta-blocking, kann in v1.5-Polish-Story entschieden werden) — `Running.svelte:254-265` setzt einen 1 s-`setInterval`, was die in der Story unter „Anti-Pattern" gelistete Regel „kein eigenes Tick-System" verletzt. Dev-Note in den Completion-Notes erkennt das an („sonst ist `isStale` design-bedingt nie testbar"). Optionen: (a) als bewusste Spec-Abweichung dokumentieren und Anti-Pattern-Liste in der Story korrigieren; (b) `visibilitychange`-Pause + cleanup-discipline zusätzlich härten (siehe defer W6); (c) auf reine `polling.error`-basierte Stale-Detection umstellen (kein Wall-Clock nötig, aber ohne Polling-Hang-Detection).

### Patches

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,12 +380,14 @@ Keine Debug-Log-Referenzen — alle Validation-Gates sofort grün durchgelaufen.

### Decision-Needed

- [ ] [Review][Decision] **MULTI-Mode Reason-Verhalten — wer „gewinnt" wenn Speicher und Drossel beide Reasons setzen?** — `_dispatch_by_mode` (controller.py:564) reset `_last_policy_noop_reason = None` einmal vor Dispatch. `_policy_multi` ruft (Spec) Speicher zuerst und Drossel als Fallback bei Cap+Einspeisung. Beide setzen Reasons → letzter writer gewinnt. Der User sieht im Cycle-Header nur einen Grund. Optionen: (a) Last-write-wins akzeptieren (zeigt warum die FINALE Branch nicht regelte) — current behavior; (b) First-write-wins (zeigt die ursprünglich blockierende Branch); (c) Concat zu `multi: speicher=…, drossel=…` mit `;`-Trenner. Risiko `(a)`: bei Speicher-Cap+Drossel-Deadband zeigt UI „Im Toleranzbereich" — verschluckt das eigentliche „Akku voll"-Signal.
- [ ] [Review][Decision] **`_effective_value_w` für non-grid_meter Rolle: `None` (Spec AC 14) oder raw value (aktueller Code)?** [backend/src/solalex/api/routes/control.py:247-248] — Spec sagt explizit „bei anderen Rollen `None` und der `state`-Wert ist die Wahrheit". Code returnt aber den raw `value` für alle non-grid_meter-Rollen. Test wurde an Code angepasst (`test_entity_snapshot_non_grid_meter_passes_state_through` asserts `effective_value_w == 800.0`). Optionen: (a) Code an Spec anpassen (return `None`) und Test umdrehen; (b) Spec an Code anpassen (raw-passthrough für alle Rollen, dokumentieren als „grid_meter ist die einzige Rolle, wo Sign-Invert greift, aber `effective_value_w` ist immer der UI-fertige Wert"). Frontend liest `effective_value_w` derzeit für Grid-Tile — wenn (b), kann Frontend für alle Tiles auf `effective_value_w` umgestellt werden (DRY).
- [ ] [Review][Decision] **`StateSnapshot.ha_ws_connected` defaults to `True` — Pre-Connect-Window zeigt fälschlich „Verbunden"** [backend/src/solalex/state_cache.py:818] — Code-Kommentar rechtfertigt das mit „Tests / dev without HA-WS supervisor". Aber Production-Risiko: wenn der Reconnect-Hook (main.py-Verkabelung) je bricht, zeigt UI dauerhaft „Verbunden" obwohl gar keine Verbindung besteht — silent failure. Optionen: (a) Default auf `False` umstellen, Test-Fixtures explizit auf `True` setzen wenn nötig; (b) `Optional[bool]` mit `None`=„unbekannt, wir warten auf erste Transition" → Frontend zeigt „Verbindung wird geprüft …"; (c) wie aktuell, plus startup-Lifespan-Hook im `main.py`, der explizit `False` setzt bevor der Reconnect-Loop startet.
- [x] [Review][Decision] **MULTI-Mode Reason-Verhalten** — Resolved (2026-04-25): Option (a) Last-write-wins akzeptieren. Aktuelles Verhalten bleibt — keine Code-Änderung nötig. Bei Speicher-Cap+Drossel-Deadband zeigt UI „Im Toleranzbereich"; das ist das gewollte Verhalten („zeige warum die finale Branch nicht regelte").
- [x] [Review][Decision] **`_effective_value_w` für non-grid_meter Rolle** — Resolved (2026-04-25): Option (b) Spec an Code anpassen. `effective_value_w` ist UI-fertiger Wert für alle Rollen; nur grid_meter wendet Sign-Invert an. Wird zu Patch P-D3 (Spec-Update AC 14). Eröffnet Folge-Patch: Frontend kann auf `entity.effective_value_w` für alle Tiles umstellen (DRY mit P16, sowie P2 Chart-Konsistenz).
- [x] [Review][Decision] **`StateSnapshot.ha_ws_connected` Default** — Resolved (2026-04-25): Option (a) Default auf `False` umstellen. Test-Fixtures explizit auf `True` setzen wo nötig. Wird zu Patch P-D4.

### Patches

- [ ] [Review][Patch] **P-D3: Spec AC 14 korrigieren — `effective_value_w` ist UI-fertiger Wert für alle Rollen, Sign-Invert nur bei grid_meter** [_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md AC 14] — Doc-Patch: Spec-Wortlaut „bei anderen Rollen `None`" durch „raw passthrough für non-grid_meter; nur grid_meter wendet `invert_sign` an" ersetzen. Code bleibt wie ist; Test bleibt wie ist.
- [ ] [Review][Patch] **P-D4: `StateSnapshot.ha_ws_connected` Default auf `False`** [backend/src/solalex/state_cache.py:818] — Init-Wert von `True` → `False` umstellen. Test-Fixtures, die heute den Default nutzen und auf `True` rechnen, explizit auf `True` setzen. Reconnect-Hook flippt beim ersten erfolgreichen Connect ohnehin auf `True` (transition-detection), also keine Race im Production-Pfad. Frontend-Verhalten: in der Pre-Connect-Lücke (typisch < 2 s nach Start) zeigt Tile „Getrennt" statt fälschlich „Verbunden" — semantisch korrekt.
- [ ] [Review][Patch] **4 Backend-Reasons fehlen im Frontend `formatCycleStatus`-Mapper — fallen alle in `default → 'Beobachtung'`** [frontend/src/routes/Running.svelte:60-101] — Backend produziert `noop: adapter_unbekannt` (controller.py:644, 1269), `noop: kein_device_id` (controller.py:651, 1259), `noop: pool_alle_offline` (controller.py:1424), `noop: soc_member_inkonsistent` (controller.py:1435). Keine `startsWith`-Branches → User sieht „Beobachtung" für echte Misskonfigurationen. Fix: 4 Mappings ergänzen UND Reason-Vokabular-Tabelle in dieser Story (Zeilen 254-269) erweitern (Single-Source-Vertrag).
- [ ] [Review][Patch] **Reason-Vokabular-Tabelle in Story-Spec ergänzen** [_bmad-output/implementation-artifacts/5-1d-live-betrieb-klartext-diagnose.md:254-269] — Tabelle erfasst aktuell nur 11 Reasons; Code erzeugt mindestens 15. Beide Single-Sources (Backend-Emitter + Frontend-Mapper + Spec-Tabelle) müssen synchron sein.
- [ ] [Review][Patch] **`update_ha_ws_connection` mutiert `ha_ws_connected` und `ha_ws_disconnected_since` ohne Lock — Race vs. `snapshot()`-Reader** [backend/src/solalex/state_cache.py:822-843] — `snapshot()` hält `self._lock` beim Lesen; `update_ha_ws_connection` ist die einzige neue Methode, die schreibt OHNE `self._lock`. Kurz-Race-Window: Reader sieht `connected=True` mit nicht-`None` `disconnected_since` (oder umgekehrt). Fix: Methode als `async def` (oder mit `with self._lock:` wenn der Lock sync ist) wrap analog zu `update()`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ Keine weiteren Files.

### Decision-Needed

- [ ] [Review][Decision] **`--color-text-primary` Token existiert nicht in `app.css` — Hover/Focus-Visible auf `.settings-link` ist sichtbar broken** — Spec AC 3 schreibt: „Hover-Zustand wechselt nur die Farbe auf `var(--color-text-primary)`". Der Token ist aber nirgends definiert (`grep --color-text-primary frontend/src/app.css` → leer). `app.css` definiert nur `--color-text` (Hauptfarbe = `var(--color-brand-ink)`) und `--color-text-secondary`. Resultat: `.settings-link:hover { color: var(--color-text-primary); }` resolved zur CSS-Default „kein Color-Override" — also kein sichtbarer Effekt im Hover. Die Spec selbst schrieb den falschen Token-Namen, der Dev folgte ihr 1:1. Optionen: (a) `var(--color-text)` verwenden (existierende Hauptfarbe — semantisch korrekt für „dunkler werden bei Hover"); (b) neuen Token `--color-text-primary` in `app.css` ergänzen (gleicher Wert wie `--color-text`, aber als Synonym); (c) `var(--color-accent-primary)` (Teal-Akzent — schöner Hover, aber lauter als die UX-Anweisung „dezent" erlaubt). Empfehlung: (a) — Single-Source bleibt `--color-text`, Spec auf den existierenden Token korrigieren.
- [x] [Review][Decision] **`--color-text-primary` Token existiert nicht in `app.css`** — Resolved (2026-04-25): Option (b) — neuen Token `--color-text-primary` in `app.css` als Synonym für `--color-text` ergänzen. Wird zu Patch P-D1 (siehe unten).

### Patches (resolved Decisions)

- [ ] [Review][Patch] **`--color-text-primary` Token in `app.css` ergänzen** [frontend/src/app.css:46-47] — Im `:root`-Block direkt nach `--color-text:` neue Zeile `--color-text-primary: var(--color-text);` einfügen. Dadurch wird `.settings-link:hover` (und alle künftigen Konsumenten desselben Tokens) sichtbar. Spec-AC 3 dieser Story bleibt unverändert (Token ist jetzt definiert).

### Patches

Expand Down
2 changes: 1 addition & 1 deletion addon/config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Solalex by ALKLY
version: "0.1.16"
version: "0.1.17"
# Minimum HA Core version — inkompatible Versionen erhalten Install-Warning im Add-on-Store.
homeassistant: "2026.4.0"
slug: solalex
Expand Down
6 changes: 4 additions & 2 deletions backend/src/solalex/api/routes/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ async def get_control_mode(request: Request) -> ControlModeResponse:
controller = _require_controller(request)
return ControlModeResponse(
forced_mode=cast("ForcedMode | None", _mode_or_none(controller.forced_mode)),
active_mode=cast("ForcedMode", controller.current_mode.value),
active_mode=controller.current_mode.value,
baseline_mode=cast(
"Literal['drossel','speicher','multi']", controller.mode_baseline.value
),
Expand Down Expand Up @@ -213,7 +213,9 @@ async def put_control_mode(
return ControlModeResponse(
forced_mode=cast("ForcedMode | None", _mode_or_none(controller.forced_mode)),
active_mode=controller.current_mode.value,
baseline_mode=controller.mode_baseline.value,
baseline_mode=cast(
"Literal['drossel','speicher','multi']", controller.mode_baseline.value
),
)


Expand Down
4 changes: 3 additions & 1 deletion backend/tests/unit/test_api_devices_patch_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ def _read_config(device_id: int) -> dict[str, object]:
finally:
conn.close()
assert row is not None
return json.loads(row[0])
parsed = json.loads(row[0])
assert isinstance(parsed, dict)
return parsed


# ----- AC 19: Surplus ohne Max-Limit → 422 --------------------------------
Expand Down
37 changes: 27 additions & 10 deletions backend/tests/unit/test_controller_multi_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ def _build_controller(

def _drive_until_decision(
controller: Controller, device: DeviceRecord, sample: float, repeats: int = 6
) -> list:
last: list = []
) -> list[Any]:
last: list[Any] = []
for _ in range(repeats):
last = controller._policy_multi(device, sensor_value_w=sample)
if last:
Expand Down Expand Up @@ -197,22 +197,39 @@ async def test_multi_cap_branch_calls_drossel_when_toggle_off(
assert decisions[0].command_kind == "set_limit"


# ----- AC 10: Cap-Branch + Bezug → [] ------------------------------------
# ----- AC 10: Cap-Branch + kein Feed-in → kein Export-Aufruf -------------


@pytest.mark.asyncio
async def test_multi_cap_branch_no_decision_on_load_when_pool_full(
tmp_path: Path,
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Bezug bei vollem Akku ist kein Surplus-Export-Szenario — Pool entlädt
sich auf der nächsten Tick-Runde, Cap-Branch erzeugt kein Drossel UND
kein Export.
"""Bezug-Symmetrie: Cap-Branch ruft weder Drossel noch Export, wenn kein
Feed-in vorliegt.

Spiegelt das Stale-Cap-Flag-Pattern aus dem 3.5-Test
``test_multi_max_soc_small_feed_in_inside_deadband_skips_drossel``: Cap-
Flag steht aus einem früheren Tick, die aktuelle geglättete Stichprobe
liegt im Deadband. Speicher liefert deshalb [], der Cap-Branch sieht
kein Feed-in → MULTI returns []. Der Export-Spy pinnt die zentrale
AC-10-Forderung: EXPORT wird nicht aufgerufen, auch nicht bei aktivem
Toggle.
"""
db = tmp_path / "test.db"
seeds = await _seed_multi_setup(db)
cache = StateCache()
controller = _build_controller(db, seeds, cache, soc_pct=98.0)
decisions = _drive_until_decision(
controller, seeds["grid_meter"], sample=200.0
)
controller._speicher_max_soc_capped = True

export_calls = [0]
real_export = controller._policy_export

def _export_spy(device: DeviceRecord, sensor_value_w: float | None) -> Any:
export_calls[0] += 1
return real_export(device, sensor_value_w)

monkeypatch.setattr(controller, "_policy_export", _export_spy)

decisions = controller._policy_multi(seeds["grid_meter"], sensor_value_w=10.0)
assert decisions == []
assert export_calls[0] == 0
9 changes: 4 additions & 5 deletions backend/tests/unit/test_controller_policy_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
from typing import Any, cast

import pytest

Expand All @@ -29,7 +29,7 @@ async def _seed_export_setup(
*,
wr_limit_config: str = '{"max_limit_w": 600, "allow_surplus_export": true}',
with_wr_limit: bool = True,
) -> dict[str, DeviceRecord | list[DeviceRecord]]:
) -> dict[str, Any]:
await run_migration(db)
async with connection_context(db) as conn:
await upsert_device(
Expand Down Expand Up @@ -71,17 +71,16 @@ async def _seed_export_setup(

def _build_controller(
db: Path,
seeds: dict[str, DeviceRecord | list[DeviceRecord]],
seeds: dict[str, Any],
cache: StateCache,
*,
current_wr_limit_w: int | None = 200,
) -> Controller:
devices_by_role: dict[str, DeviceRecord] = {
"grid_meter": cast(DeviceRecord, seeds["grid_meter"]),
"grid_meter": seeds["grid_meter"],
}
wr_limit = seeds["wr_limit"]
if wr_limit is not None:
wr_limit = cast(DeviceRecord, wr_limit)
devices_by_role["wr_limit"] = wr_limit
if current_wr_limit_w is not None:
cache.last_states[wr_limit.entity_id] = HaStateEntry(
Expand Down
30 changes: 30 additions & 0 deletions backend/tests/unit/test_migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ async def test_devices_table_schema(tmp_path: Path) -> None:
async def test_adapter_key_rename_migration(tmp_path: Path) -> None:
db = tmp_path / "test.db"
async with aiosqlite.connect(str(db)) as conn:
# Pre-seed the schema as if migrations 001+002 had already run so
# that 003 (this test's subject) and 004 (Story 3.8 — needs the
# control_cycles + latency_measurements tables to copy rows from)
# both have something to apply.
await conn.executescript(
"""
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
Expand All @@ -83,6 +87,32 @@ async def test_adapter_key_rename_migration(tmp_path: Path) -> None:
updated_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(entity_id, role)
);
CREATE TABLE control_cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TIMESTAMP NOT NULL,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('drossel','speicher','multi')),
source TEXT NOT NULL CHECK (source IN ('solalex','manual','ha_automation')),
sensor_value_w REAL,
target_value_w INTEGER,
readback_status TEXT CHECK (readback_status IN ('passed','failed','timeout','vetoed','noop')),
readback_actual_w REAL,
readback_mismatch INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER,
cycle_duration_ms INTEGER NOT NULL,
reason TEXT
);
CREATE INDEX idx_control_cycles_ts ON control_cycles(ts DESC);
CREATE INDEX idx_control_cycles_device ON control_cycles(device_id);
CREATE TABLE latency_measurements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
command_at TIMESTAMP NOT NULL,
effect_at TIMESTAMP NOT NULL,
latency_ms INTEGER NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('drossel','speicher','multi'))
);
CREATE INDEX idx_latency_device_ts ON latency_measurements(device_id, command_at DESC);
"""
)
await conn.execute(
Expand Down
83 changes: 81 additions & 2 deletions frontend/src/lib/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isApiError } from './errors.js';
import { getEntities, getEntityState, saveDevices, updateDevices } from './client.js';
import type { EntitiesResponse, EntityState } from './types.js';
import {
getEntities,
getEntityState,
saveDevices,
setSurplusExport,
updateDevices,
} from './client.js';
import type { DeviceResponse, EntitiesResponse, EntityState } from './types.js';

const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
Expand Down Expand Up @@ -191,6 +197,79 @@ describe('updateDevices', () => {
});
});

describe('setSurplusExport (Story 3.8)', () => {
beforeEach(() => {
mockFetch.mockReset();
});

it('sends PATCH /api/v1/devices/{id}/config with allow_surplus_export', async () => {
const updated: DeviceResponse = {
id: 7,
type: 'generic',
role: 'wr_limit',
entity_id: 'number.opendtu_limit',
adapter_key: 'generic',
config_json: '{"allow_surplus_export": true, "max_limit_w": 600}',
last_write_at: null,
commissioned_at: '2026-04-25T12:00:00Z',
created_at: '2026-04-25T11:00:00Z',
updated_at: '2026-04-25T12:30:00Z',
};
mockFetch.mockResolvedValue(okResponse(updated));

const result = await setSurplusExport(7, true);

expect(result.id).toBe(7);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/v1/devices/7/config');
expect(init.method).toBe('PATCH');
expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' });
const body = JSON.parse(init.body as string) as Record<string, unknown>;
expect(body).toEqual({ allow_surplus_export: true });
});

it('returns the parsed DeviceResponse on 200', async () => {
const updated: DeviceResponse = {
id: 9,
type: 'generic',
role: 'wr_limit',
entity_id: 'number.opendtu_limit',
adapter_key: 'generic',
config_json: '{"allow_surplus_export": false}',
last_write_at: null,
commissioned_at: null,
created_at: '2026-04-25T11:00:00Z',
updated_at: '2026-04-25T12:30:00Z',
};
mockFetch.mockResolvedValue(okResponse(updated));

const result = await setSurplusExport(9, false);
expect(result.config_json).toContain('allow_surplus_export');
expect(JSON.parse(result.config_json)).toEqual({ allow_surplus_export: false });
});

it('throws ApiError with German detail on 422 missing max_limit_w', async () => {
mockFetch.mockResolvedValue(
errResponse(422, {
type: 'urn:solalex:validation-error',
title: 'Validierungsfehler',
detail:
'Surplus-Einspeisung erfordert ein konfiguriertes Hardware-Max-Limit (max_limit_w).',
}),
);

try {
await setSurplusExport(3, true);
} catch (err) {
expect(isApiError(err)).toBe(true);
if (isApiError(err)) {
expect(err.status).toBe(422);
expect(err.detail).toContain('max_limit_w');
}
}
});
});

describe('getEntityState', () => {
beforeEach(() => {
mockFetch.mockReset();
Expand Down
Loading
Loading