Design for explicit meter lifecycle (epochs) so the register-difference
billing engine never computes a delta across a meter swap (2G->4G or house
move). Meter-centric (no Home entity); per-meter cumulative reset; retroactive
declaration with recompute; delta sanity guard as backstop. 7 atomic task cards.
- MQTT import_cost_total / export_revenue_total now anchor fixed-fee / tax-credit
accrual at max(contract start, first recorded period), so standing charges for
untracked pre-recording days are no longer folded into the running total.
- Add energy.import_cost_today / energy.export_revenue_today (monetary,
total_increasing) that reset at server-local midnight, for per-day cost
aggregation in Home Assistant alongside the running totals.
- ContractForm add-version mode prefills the contract's open version values, so
only the fields that actually change need editing.
- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py).
- summarize: fixed fee / tax credit accrue only for elapsed whole local days,
integrated across contract versions — no future-day inflation, no version-switch collapse.
- MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline
apportionment), anchored at the active contract's earliest version effective_from so the
total sensors stay monotonic and never jump backward.
- effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC.
- frontend ContractForm sends local-midnight naive datetime (was UTC midnight).
- get_costs_summary default window uses server-local "today".
A disabled device with historical readings still cannot be deleted by default
(409 guard stays, to prevent accidental data loss). A new explicit opt-in lets
the user remove it for real:
- DELETE /api/modbus/devices/{uuid}?cascade=true removes the device's readings
and exposed_entity_toggle rows, then the device, in one transaction (FK order:
readings before device). Best-effort clears the HA discovery configs first
(MQTT not connected -> no-op, never blocks the delete). Default (no cascade)
unchanged. Returns 200 with deletion counts.
- Frontend: a 'Force Delete (all data)' button appears only after the 409, with
a red irreversible warning; cascade is sent only on that explicit action.
- Also corrected a stale comment: FK RESTRICT IS enforced at runtime now.
Follow the standard MQTT Discovery split: discovery config stays under the HA
discovery prefix (homeassistant/.../config, required), but the actual state and
availability are published under our own prefix (default home_automation/). The
config payload's state_topic/availability fields point at the new prefix so HA
reads state from there.
- new config ha_state_topic_prefix (default home_automation).
- build_discovery_payload + all publish_* split discovery_prefix (config topic)
from state_prefix (state/availability). energy-cost still omits availability;
modbus availability structure unchanged. Tests updated.
Subscribe the separate DSMR tariff topic (dsmr/meter-stats/electricity_tariff,
1=dal/off-peak, 2=normal/peak), keep the latest slot in memory, and make the
manual-contract buy/sell_price_now sensors show the matching tariff's price
(tariff 1 -> dal, 2/unknown -> normal). Tibber (single 15-min price) unchanged.
Billing is untouched (register-split already handles tariffs correctly).
- new config dsmr_tariff_topic; subscribe managed by apply_dsmr_subscription
(restart-free on config save). In-memory tariff is lock-guarded.
- import_cost_total / export_revenue_total cumulative getters unchanged.
So HA can show the full payable: net = import - export = Total Payable, with
both entities staying monotonic-positive (no negative-value/reset pitfalls).
- import_cost_total = SUM(import_cost) + days*(network_fee+management_fee)/30
- export_revenue_total = SUM(export_revenue) + days*heffingskorting/365
(days = calendar days since the active version's effective_from, incl. today)
- state_class total_increasing -> total (monetary-compatible; values still only
increase). device_class/key/names: key unchanged, names clarified.
- buy/sell_price_now and DeviceInfo untouched. Decimal money math. Tests added.
Backend serializes SQLite-read (naive) UTC datetimes without a Z, so the browser
was parsing them as local time -> the UI effectively showed UTC values.
- New src/utils/datetime.ts: treats a tz-less timestamp as UTC, then formats in
the browser's local timezone with hour12:false (no AM/PM).
- Applied to Energy tabs (contracts/prices/costs/DSMR), per-device readings &
trends, and the meter readings table on the Records page.
- CostView Today/This month now use local day boundaries (queries stay UTC ISO).
- Untouched: location/poo (raw Zulu strings), the map, and query-window params.
- Tests are timezone-independent (verified under TZ=America/Los_Angeles).
Root cause (found in the live DB): register_at() returned the latest reading
for any boundary beyond the DSMR data range (a future instant or a long gap),
so future periods got start==end -> delta 0 and were written as degraded=False
'ok' rows with cost 0. recompute_range() over a future end then filled the whole
month with such fake rows, which then occupied the slots so the per-minute tick
(immutability guard skips non-degraded rows) never recomputed the real data.
- register_at: ignore a reading older than one period (15min) before the
boundary -> returns None -> period is marked degraded instead of a fake 0.
- recompute_range: only process closed periods (t1 <= now); never future ones.
- Tests cover staleness window, out-of-range -> degraded, recompute skips
future, and a regression that normal closed periods are unaffected.
The energy-cost device has only sensors (no online/offline heartbeat), but the
discovery config still declared an availability topic that nothing ever publishes
'online' to — so Home Assistant marked every energy-cost entity 'unavailable'
even though the state topic was being published (visible in MQTT Explorer).
- DeviceInfo gains provides_availability (default True, keeps Modbus behavior).
- _energy_cost_provider sets provides_availability=False.
- build_discovery_payload only emits availability/availability_mode for devices
that publish a heartbeat; HA treats the rest as always-available.
- Test asserts energy-cost configs omit availability.
- New 'DSMR' tab showing the latest parsed telegram (GET /api/energy/dsmr/latest)
as a key/value table — the source-of-truth view so you can see ingest is
landing data without reading the DB.
- Auto-refresh toggle (~10s); loading/error/empty states (empty explains the
likely cause); null phase values shown as a dash.
- useDsmrLatest extended with optional auto-refresh. Tests added.
Config hot-reload (no restart):
- MqttManager.unsubscribe(); apply_dsmr_subscription(settings) re-applies the
DSMR subscription (subscribe/unsubscribe/topic-change, fresh snapshot so the
sample interval also takes effect).
- Called from lifespan AND PUT /api/config, so toggling DSMR ingest in the UI
takes effect immediately without restarting the app.
Decouple ingest from the DSMR telegram id (overflows / resets to zero):
- Migration 20260624_12: drop UNIQUE(source_id), make recorded_at UNIQUE.
- Idempotency now keys on recorded_at (telegram timestamp); the table's own
autoincrement PK is the stable identity. source_id kept only as a reference.
- Regression test: colliding telegram ids no longer drop new data.
- routes/api/energy.py + schemas/energy.py: GET prices (tibber points or manual
tariff, buy/sell consistent with the pricing strategies), GET costs (time
window + limit, ascending), GET costs/summary (summarize passthrough), GET
dsmr/latest, POST costs/recompute (idempotent, <=366d guard, CSRF), POST
tibber/test (three-state, token never echoed, CSRF).
- main.py registers router; OpenAPI re-exported; tests for all endpoints.
Rework the M5 design doc data model and add manual-testing surface:
- Two-layer model: rename energy_meters/energy_readings -> generic
modbus_device + modbus_reading; readings become a JSON payload blob
instead of a wide fixed-column table (aggregation via SQLite
json_extract, generated-column index as a future lever).
- Protocol knowledge moves to read-only YAML profiles (data+functions,
no OOP inheritance); deployment/config (friendly_name, unit_id, host)
stays on the device row; multiple devices share one profile.
- UUID = stable internal identity, also anchors HA discovery unique_id
(Z2M rename model); device metadata for HA derived from the profile.
- Naming scheme C: modbus_* / /api/modbus/devices at the data+API layer,
'Energy' as the first domain view in the frontend.
- New task M5-T01B: ConfigPage Accordion sectioning (avoids a second
in-page side nav conflicting with the T01 app sidebar).
- Manual testing: read-only modbus_cli 'probe' subcommand (FC03/04 only,
no register writes); mqtt/test now publishes a test message viewable
in MQTT Explorer; no MQTT CLI (manual verification via UI + HA).