137 Commits
Author SHA1 Message Date
tliu93 3e04b15656 feat(modbus): add DDSU666 profile and select read function code per profile
frontend / frontend (push) Successful in 2m21s
pytest / test (push) Successful in 9m50s
docker-image / build-and-push (push) Successful in 4m24s
- Add CHINT DDSU666 profile (ddsu666.yaml): FC03 holding registers, voltage/
  current/active power (kW)/reactive power (kvar)/PF/frequency, import+export
  active energy. Word/byte order left at big-endian as a documented best guess
  (manual has no float example) — to be confirmed on-device.
- Add DDSU666-Modbus-Protocol.md reference extracted from the official manual,
  plus the source PDF (parity with the SDM120 reference).
- Generalize driver.read_blocks to dispatch FC03 (holding) or FC04 (input)
  based on a function_code argument (default 4, SDM120 behaviour unchanged);
  the code is validated before any connection is attempted.
- Wire profile.function_code through the CLI read command, the background
  poller, and the device /test endpoint — previously the profile field was
  declared but never honoured (read path was hardcoded to FC04).
- Tests: default -> FC04, function_code=3 -> FC03 holding, invalid FC rejected
  before connecting.
2026-06-30 17:16:25 +02:00
tliu93 f2e8f6a8e7 docs(roadmap): queue Authentication next-steps — sliding session renewal + long-lived token targets (location/poo ingestion)
frontend / frontend (push) Successful in 2m16s
pytest / test (push) Successful in 10m40s
2026-06-27 22:16:02 +02:00
tliu93 90a03e7fd6 FUE-T09: grace-shift *_today daily reset past local midnight + dedicated 00:00:10 publish
docker-image / build-and-push (push) Successful in 4m29s
frontend / frontend (push) Successful in 2m17s
pytest / test (push) Successful in 11m2s
2026-06-26 12:21:51 +02:00
tliu93 d3fc90b320 FUE-T08: defer daily fixed-fee/heffingskorting settlement to local 01:05 in summarize() 2026-06-26 11:44:34 +02:00
tliu93 d4acfc438a FUE-T07: use meter label directly as energy-cost HA device name (drop 'Energy Cost' prefix)
frontend / frontend (push) Successful in 2m14s
pytest / test (push) Successful in 10m49s
2026-06-25 21:28:13 +02:00
tliu93 c26160b10b FUE-T06: trigger HA discovery republish after meter declare/update
frontend / frontend (push) Successful in 2m11s
pytest / test (push) Successful in 10m36s
2026-06-25 20:54:42 +02:00
tliu93 efbe36d7c0 FUE-T05: anchor energy-cost HA device identity to active meter (uuid id, label name, empty when none) 2026-06-25 20:43:07 +02:00
tliu93 f663981cdb FUE-T04: add Meter.uuid (stable HA identity anchor) + backfill migration 2026-06-25 20:20:01 +02:00
tliu93 da05fd2f09 FUE-T03: correct meters POST docstring (no recomputed_periods field) and inaccurate test comments
frontend / frontend (push) Successful in 2m11s
pytest / test (push) Successful in 8m51s
2026-06-25 18:36:46 +02:00
tliu93 188168b16a FUE-T02: fix EditMeterForm timestamp parsing to use shared parseBackendTimestamp (handles offset form) 2026-06-25 18:33:42 +02:00
tliu93 f1e7ce3133 FUE-T01: count billing window start day in full for fixed-fee/credit accrual (symmetric with end day) 2026-06-25 18:33:42 +02:00
tliu93 e3d0d17cac M7-T07: finalize M7 docs (roadmap entry, design index, status done, meter-epochs module doc)
frontend / frontend (push) Successful in 2m10s
pytest / test (push) Successful in 8m34s
2026-06-25 17:01:54 +02:00
tliu93 33f5de0591 M7-T06: add meter management UI (timeline, declare swap, edit) to Energy view 2026-06-25 17:01:54 +02:00
tliu93 839faaf95b M7-T05: add meter CRUD API + retroactive recompute + OpenAPI regen 2026-06-25 17:01:54 +02:00
tliu93 82bb329500 M7-T04: anchor cumulative cost entities to active meter start (per-meter reset) 2026-06-25 17:01:54 +02:00
tliu93 17057ed41e M7-T03: make billing engine meter-aware (no cross-meter delta, delta sanity guard, period.meter_id) 2026-06-25 17:01:54 +02:00
tliu93 227b146c2c M7-T02: add meter service layer (declare/swap/close/edit, meter_at, mutual exclusion) 2026-06-25 17:01:54 +02:00
tliu93 32a20785ee M7-T01: add meter table + model + energy_cost_period.meter_id + backfill migration 2026-06-25 14:48:39 +02:00
tliu93 2544514f52 M7: add meter-epochs / meter-swap-archival milestone design doc
frontend / frontend (push) Successful in 2m6s
pytest / test (push) Successful in 7m51s
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.
2026-06-25 14:23:25 +02:00
tliu93 96e85fb48e FU11: anchor cumulative cost at recording start; add daily-reset cost entities; prefill add-version form
pytest / test (push) Successful in 7m32s
frontend / frontend (push) Successful in 2m6s
- 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.
2026-06-25 12:45:24 +02:00
tliu93 b71009620a compose add tz 2026-06-25 11:48:45 +02:00
tliu93 8b91146c29 FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
frontend / frontend (push) Successful in 2m5s
pytest / test (push) Successful in 7m34s
- 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".
2026-06-25 11:13:30 +02:00
tliu93 682e06d256 modbus: add explicit cascade delete (device + readings + toggles + HA cleanup)
frontend / frontend (push) Successful in 2m4s
pytest / test (push) Successful in 7m32s
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.
2026-06-24 17:38:52 +02:00
tliu93 d7f0731d1c ha_discovery: publish state/availability under home_automation prefix
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.
2026-06-24 16:21:15 +02:00
tliu93 9bc46f8d2d expose: buy/sell_price_now follow the live dual-tariff slot
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.
2026-06-24 15:39:21 +02:00
tliu93 6f8cb05eab expose: fold standing fee into import cost, tax credit into export revenue
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.
2026-06-24 14:26:32 +02:00
tliu93 effe434637 frontend: show Energy/DSMR/meter times in local timezone, 24h
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).
2026-06-24 13:22:07 +02:00
tliu93 9d3e28a529 Billing fix: don't bill periods from stale/out-of-range DSMR readings
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.
2026-06-24 12:44:05 +02:00
tliu93 7e61b0a938 HA Discovery fix: energy-cost sensors are always-available
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.
2026-06-24 11:54:49 +02:00
tliu93 e8521351d7 frontend: add DSMR data panel to Energy page
- 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.
2026-06-24 11:02:42 +02:00
tliu93 8d4f496ff4 DSMR hardening: restart-free config + decouple from telegram id
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.
2026-06-24 11:02:32 +02:00
tliu93 78f3cc4776 M6-T11: document M6 (energy pricing + DSMR + Tibber) across README/roadmap/arch
- README: M6 section + feature list + new tables/config items.
- roadmap: M6 row (graduated) + detail section; M6 in milestone index.
- architecture-overview: M6 models/services/integrations/routes/jobs/config.
- design/README: index m6, drop stale '三个里程碑' wording.
- OpenAPI already in sync (no diff).
2026-06-23 23:52:07 +02:00
tliu93 96e88861d4 M6-T10: frontend contract management + price/cost views + Tibber test
- EnergyPage: Mantine Tabs (Devices kept intact + Contracts/Prices/Costs).
- ContractManager/ContractForm: list/activate/add-version + version history;
  form fields rendered dynamically from /api/energy/profiles structure.
- TibberPrices + CostView: Recharts price curve, cost trend/detail/summary,
  recompute; window-bounded, currency/units from API, empty/error/loading states.
- ConfigPage: tri-state Tibber test button (token never shown).
- hooks.ts: typed energy hooks; schema.d.ts regenerated via codegen.
2026-06-23 23:32:39 +02:00
tliu93 57f2459f60 M6-T09: add Energy data API (prices/costs/summary/dsmr-latest/recompute/tibber-test)
- 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.
2026-06-23 23:05:45 +02:00
tliu93 c61fc2d4ba M6-T08: add energy_cost expose provider + post-billing state publish
- expose.py: _energy_cost_provider yields buy/sell_price_now + import_cost_total
  /export_revenue_total (total_increasing, monetary), value_getters read from
  energy_cost_period; 2-element identifiers so the existing HA Discovery builder
  (which indexes identifiers[1]) works. Other providers/mechanism untouched.
- main.py: energy-cost job calls publish_states after computing periods (no-op
  when MQTT/Discovery off). Tests added.
Note: energy-cost sensors lack an availability heartbeat (ha_discovery mechanism
limitation, out of scope here).
2026-06-23 22:44:41 +02:00
tliu93 d35ac7d752 M6-T07: add metering billing engine + period job + summary + recompute
- app/services/energy_cost.py: register_at (register values at a UTC boundary),
  compute_period (register deltas x active-version strategy, snapshot price +
  contract_version_id, immutable unless overwrite), compute_closed_periods
  (bounded lookback), recompute_range (explicit overwrite), summarize
  (sum net + standing fees month/30 - heffingskorting year/365).
- Missing tibber price -> skip; missing reading -> degraded (zeroed amounts,
  null version), consistently on both create and recompute paths.
- main.py: 1-minute energy-cost job (existing jobs/MQTT untouched). Tests added.
2026-06-23 22:26:56 +02:00
tliu93 ef48032adb M6-T06: extend MqttManager with subscribe + DSMR ingest
- mqtt.py: subscription registry, on_message dispatch (swallows handler
  errors), re-subscribe on (re)connect; publish/connect/disconnect/reconnect
  unchanged.
- app/services/dsmr_ingest.py: handle_message stores the full telegram frame
  (no allowlist; gas/phases/null preserved), 10s downsample by timestamp.second,
  source_id idempotency (select + IntegrityError rollback). Net-thread safe.
- main.py registers the DSMR subscription only when dsmr_ingest_enabled.
2026-06-23 21:52:57 +02:00
tliu93 7e266a21eb M6-T05: add Tibber GraphQL client + price refresh service + schedule
- app/integrations/tibber/client.py: fetch_price_range/fetch_current_price
  (priceInfoRange QUARTER_HOURLY, startsAt->UTC, no fixed node count assumption),
  TibberError/TibberAuthError; token never logged or put in exception text.
- app/services/tibber_prices.py: refresh_prices upserts by starts_at (idempotent),
  no-op unless an active kind=tibber contract + token exist.
- main.py: hourly tibber-refresh job (existing jobs/MQTT untouched). Tests added.
2026-06-23 21:35:29 +02:00
tliu93 4284bd40a7 M6-T04: add EnergyContract CRUD + versions + profile validation API
- app/services/contracts.py: create_contract, add_version (append-only,
  auto-closes prior version), activate_contract (mutex), and
  active_contract_version_at (half-open [from, to)) for the billing engine.
- app/schemas/energy_contract.py + routes/api/energy_contracts.py: 6 endpoints
  (GET/POST contracts, GET/PATCH contracts/{id}, POST .../versions, GET profiles).
  Values validated against pricing profiles (422 on mismatch); no DELETE.
- main.py registers router; OpenAPI re-exported; tests for CRUD/version/activate.
2026-06-23 21:19:25 +02:00
tliu93 bedea196c3 M6-T03: add pricing profile framework + strategy registry
- app/integrations/pricing: profiles.py (pydantic ManualProfile/TibberProfile,
  load_profile/list_profiles/validate_values), manual.yaml + tibber.yaml
  (structure-only, no price values), strategies.py (register/get_strategy,
  manual dual-tariff + tibber strategies, all Decimal).
- tibber strategy matches nearest tibber_price with starts_at <= t0.
- tests for profiles + strategies (hand-checked dual-tariff & tibber math).
2026-06-23 20:58:34 +02:00
tliu93 df54f5518b M6-T02: add DSMR + Tibber flat config fields
- Settings: dsmr_ingest_enabled, dsmr_mqtt_topic, dsmr_sample_interval_s,
  tibber_api_token (secret), tibber_home_id (all opt-in / off by default).
- CONFIG_FIELDS DSMR + Tibber sections; _settings_payload extended.
- .env.example examples; test_api_config coverage (sections, secret, 422).
2026-06-23 20:43:46 +02:00
tliu93 18fe18281b M6-T01: add 5 energy tables, models, and migration
- app/models/energy.py: DsmrReading, EnergyContract, EnergyContractVersion,
  TibberPrice, EnergyCostPeriod (per design §3.5; JSON blobs, RESTRICT FKs).
- alembic_app migration 20260623_11_energy_tables (head), env.py imports.
- app_db_adopt APP_BASELINE_REVISION bumped to new head.
- tests/test_energy_models.py + expose_catalog test fixups for new head.
2026-06-23 20:33:35 +02:00
tliu93 355c8eb8b8 fix: add paho-mqtt and pymodbus to dev-requirements lock (CI install was missing M5 deps)
pytest / test (push) Successful in 5m13s
frontend / frontend (push) Successful in 1m48s
2026-06-23 20:06:13 +02:00
tliu93 1ffdaebbbd M6: add Tibber API + NL energy pricing + DSMR reference doc
frontend / frontend (push) Successful in 1m54s
pytest / test (push) Failing after 32s
2026-06-23 19:56:02 +02:00
tliu93 c64693b8e5 M6: add design doc — generic pricing layer (manual/tibber) + DSMR cost engine 2026-06-23 19:53:26 +02:00
tliu93 c89d083942 M5: add read-only per-meter readings tabs to Records page; English-only UI strings 2026-06-22 22:16:55 +02:00
tliu93 6090b98ab0 M5: enable SQLite foreign_keys pragma for DB-level FK enforcement 2026-06-22 20:45:47 +02:00
tliu93 489cd7df3d M5: default Modbus polling to off (opt-in) to avoid unnecessary DB writes 2026-06-22 20:28:12 +02:00
tliu93 35cdc7f22a M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion 2026-06-22 20:11:54 +02:00
tliu93 75d685f04d M5: roll Energy chart window for live auto-refresh (English label); render boolean config fields as switches 2026-06-22 19:40:18 +02:00
tliu93 935e68846c M5: Energy view — per-metric decimal formatting and chart auto-refresh toggle 2026-06-22 19:18:29 +02:00
tliu93 4767a7af60 M5-T13: document Modbus/Energy/MQTT, mark M5 done, finalize OpenAPI 2026-06-22 16:10:11 +02:00
tliu93 45d87f36f7 M5-T12: add expose toggle API and Home Assistant Expose settings panel 2026-06-22 15:50:49 +02:00
tliu93 35b95f5c88 M5-T11: add HA discovery and state publishing wired to polling 2026-06-22 15:32:43 +02:00
tliu93 9d05f5ea62 M5-T10: add paho MQTT client with lifespan connect/reconnect and mqtt/test endpoint 2026-06-22 15:06:30 +02:00
tliu93 360cddc05b M5-T09: add exposed_entities table and ExposableEntity provider framework 2026-06-22 14:50:40 +02:00
tliu93 14b846549e M5-T08: add MQTT, HA Discovery, and Modbus polling config fields 2026-06-22 14:27:21 +02:00
tliu93 68165f0e01 M5-T07: add latest-reading cards and Recharts trend charts; readings return most-recent rows 2026-06-22 14:17:59 +02:00
tliu93 2dc469274a M5-T06: add Energy device management UI and sidebar entry 2026-06-22 13:52:33 +02:00
tliu93 702a1cec00 M5-T05: add Modbus JSON API (device CRUD, readings, metrics, test) 2026-06-22 13:37:48 +02:00
tliu93 49d15d8ffe M5-T04: add Modbus poll service and APScheduler polling job 2026-06-22 13:24:11 +02:00
tliu93 c89cea4953 M5-T03: add Modbus TCP driver, YAML profile framework, sdm120, modbus_cli 2026-06-22 13:03:50 +02:00
tliu93 d9c82e39fb M5-T02: add modbus_device and modbus_reading tables and models 2026-06-22 12:45:50 +02:00
tliu93 eac7860b51 M5-T01B: render ConfigPage sections as Mantine Accordion 2026-06-22 12:34:04 +02:00
tliu93 da7d0f9d62 M5-T01: refactor AppLayout from top bar to sidebar navigation 2026-06-22 12:26:52 +02:00
tliu93 e49a6ad963 M5(design): generic modbus_device + JSON readings; YAML profiles; Accordion config; manual CLI/MQTT testing
frontend / frontend (push) Successful in 1m26s
pytest / test (push) Successful in 3m52s
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).
2026-06-22 11:55:39 +02:00
tliu93 d70b985eb0 fix(ci): recompile dev-requirements.txt to include pyotp (pytest CI install)
pytest / test (push) Successful in 3m51s
frontend / frontend (push) Successful in 1m26s
2026-06-22 11:08:06 +02:00
tliu93 e3c47b2762 docs(CLAUDE): switch to direct-on-main workflow for this single-user repo
frontend / frontend (push) Successful in 1m35s
pytest / test (push) Failing after 31s
2026-06-22 10:37:34 +02:00
tliu93 dbf17f3592 docs(CLAUDE): pin default sub-agent models (Implementer/Fixer=Sonnet, Reviewer=Opus) with manual-override clause 2026-06-22 09:50:46 +02:00
tliu93 ae75e4582d M4-T09: document login hardening, finalize OpenAPI and roadmap 2026-06-21 23:01:50 +02:00
tliu93 ee1264b66b M4-T08: add two-step login and TOTP settings panel (qrcode.react) 2026-06-21 22:42:41 +02:00
tliu93 51da8c5716 M4-T07: add admin_cli disable-totp and reissue-totp escape commands 2026-06-21 22:27:07 +02:00
tliu93 81bd32f613 M4-T06: add TOTP second factor to login (totp_code + recovery codes) 2026-06-21 22:08:55 +02:00
tliu93 ee3031013e M4-T05: add TOTP service and setup/enable/disable/status API 2026-06-21 21:55:17 +02:00
tliu93 5026967cee M4-T04: add AuthUser TOTP columns and auth_recovery_code table 2026-06-21 21:38:22 +02:00
tliu93 5481fbc99c M4-T03: add admin_cli escape hatch (reset-password, unlock, list-admin) 2026-06-21 21:28:41 +02:00
tliu93 e480a84a44 M4-T02: add exponential login back-off service and wire into login endpoint 2026-06-21 21:20:28 +02:00
tliu93 64d3882c16 M4-T01: add auth_login_throttle table + LoginThrottle model 2026-06-21 21:03:20 +02:00
tliu93 6b67b8e0f8 modify compose dev port 2026-06-21 20:49:24 +02:00
tliu93 0cb94d85ec docs(design): plan M4 login-hardening (first) + M5 IoT/energy
- Add docs/references/SDM120-Modbus-Protocol.md extracted from the SDM120 PDF
- M4 login hardening: brute-force exponential backoff, CLI escape hatch
  (reset-password/unlock/disable-totp), optional TOTP 2FA
- M5 IoT/energy: Modbus-TCP SDM120 polling + energy tables, MQTT/HA
  Discovery expose framework, frontend sidebar + energy view
- Add manual acceptance walkthroughs (M4 lock/unlock; M5 energy_cli read)
- Index both milestones in docs/design/README.md
2026-06-21 20:46:12 +02:00
tliu93 9da88db221 docs(roadmap): graduate next-phase work out of Future Ideas
frontend / frontend (push) Successful in 1m18s
pytest / test (push) Successful in 1m32s
Promote four now-decided directions from Future Ideas into a new
"下一阶段:已确定要做" section (roadmap altitude, not yet broken into task cards):
TOTP second factor for the public dashboard, frontend optimization (scope TBD),
MQTT / IoT integration, and a settings-page long-lived API token (PAT-style,
related to but distinct from M3's mobile OAuth). Future Ideas is now an empty,
purpose-stated bucket for not-yet-decided ideas.
2026-06-13 17:38:50 +02:00
tliu93 1a3aaea933 fix(docker): pin frontend-build to $BUILDPLATFORM to avoid QEMU V8 crash
frontend / frontend (push) Successful in 1m19s
pytest / test (push) Successful in 1m31s
docker-image / build-and-push (push) Successful in 3m55s
Multi-arch image builds (linux/amd64,linux/arm64) emulate the foreign arch via
QEMU. The unpinned `FROM node:22-slim` frontend-build stage was built per
target arch, so Node ran under emulation and V8's baseline JIT crashed (SIGTRAP
/ exit 133 on `npm ci`) on the CI runner's QEMU.

Pin the stage to --platform=$BUILDPLATFORM so the static, arch-independent SPA
dist/ is built once on the native build host and COPYd into each arch's runtime
stage. Node never runs emulated; both arch images are still produced.
2026-06-13 17:21:05 +02:00
tliu93 bf7fd71a21 Merge pull request 'Feature/m2 frontend v2' (#8) from feature/m2-frontend-v2 into main
pytest / test (push) Successful in 1m29s
frontend / frontend (push) Successful in 1m15s
docker-image / build-and-push (push) Failing after 59s
Reviewed-on: #8
2026-06-13 17:00:19 +02:00
tliu93 962ba26c7c docs(roadmap): add Future Ideas — TOTP 2FA for the public dashboard
frontend / frontend (push) Successful in 1m15s
pytest / test (push) Successful in 1m30s
frontend / frontend (pull_request) Successful in 1m16s
pytest / test (pull_request) Successful in 1m30s
Record TOTP (RFC 6238) as a deferred hardening idea for the now public-facing
Web dashboard: second factor on the single-admin login, with CLI-only password
reset and a CLI TOTP reset/recovery path that works even if the recovery codes
are lost (no lock-out dead end). Not M2.5, not scheduled — parked under a new
Future Ideas section.
2026-06-13 15:29:20 +02:00
tliu93 da236643f2 M2: frontend walkthrough fixes + explicit dev compose stack
frontend / frontend (push) Successful in 2m0s
pytest / test (push) Successful in 1m32s
Post-M2 self-walkthrough polish, batched into one commit.

Map / heat:
- fix heat-layer white-screen crash after login (add layer to map before
  setLatLngs; an off-map leaflet.heat layer has a null _map and throws)
- normalize each heat layer to the densest pixel cell visible in the CURRENT
  viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend,
  so sparse poo data reaches red and stays normalized at any zoom level
- dark CARTO basemap tiles when the color scheme is dark

UI:
- dark-mode toggle in the top-right, beside the settings gear
- switch top-right nav (records / theme / settings / logout) to Feather icons
  with hover tooltips
- home: Grafana-style quick time-range presets + back/forward shift buttons,
  placed between the From/To pickers and Apply; fix Select/tooltip z-index
  (Leaflet stacking) and the shift-button height alignment

API client:
- stop flooding GET /api/session with 401s: the session probe and the login
  endpoint own their 401s (no global redirect), which fixes the logout hang and
  the spinning login page

Compose:
- rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit,
  non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB);
  update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and
  the README "Docker Compose" section

Tests:
- pixel-grid peak counter, time-range presets, heat-layer ordering regression,
  and 401-redirect regression
2026-06-13 15:20:50 +02:00
tliu93 bd09523e94 M2-T13: docs wrap-up + retire frontend constraints + dependency cleanup
- README: add 前端 v2 (React SPA) section (dev/build/codegen/hosting/gates),
  update directory listing, drop stale Jinja descriptions
- architecture-overview: retire '不引入前后端分离' constraint; reflect SPA + JSON API
- roadmap: mark M2 done
- remove orphaned jinja2 dependency (recompile requirements*.txt; no other churn)
- delete empty tests/test_auth.py stub; drop dead _extract_csrf_token in test_api_data
- verified image still builds and app imports with the slimmer deps
2026-06-13 15:20:50 +02:00
tliu93 53f1245d83 docs(m2): mark M2-T12 done 2026-06-13 15:20:50 +02:00
tliu93 51f712f602 M2-T12: multi-stage Dockerfile (node build -> python runtime) + frontend CI
- Dockerfile: node:22-slim stage runs npm ci + npm run build; python runtime
  stage COPY --from copies dist to /app/frontend/dist (matches SPA_DIST_DIR);
  runtime image has no node
- .dockerignore: exclude frontend/node_modules and frontend/dist from context
- .github/workflows/frontend.yml: npm ci + codegen-sync + lint/typecheck/test/build
- tests/test_deployment.py: skip COPY --from sources in the context-existence
  check; assert the multi-stage frontend build wiring
- verified with a real docker build (image serves SPA, no node at runtime)
2026-06-13 15:20:50 +02:00
tliu93 f8b1e5fc71 docs(m2): mark M2-T11 done 2026-06-13 15:20:50 +02:00
tliu93 a9830c42d8 M2-T11: serve React SPA from FastAPI and remove Jinja pages
- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist):
  mounts /assets and a GET catch-all returning index.html for client routes;
  catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets,
  ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI)
- delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/
  (all replaced by /api/* + SPA; auth service layer kept)
- remove/replace Jinja page tests (JSON coverage already in test_api_*);
  add tests/test_spa_hosting.py for the fallback contract
- regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
2026-06-13 15:20:50 +02:00
tliu93 8aa7316b26 docs(m2): mark M2-T09 done 2026-06-13 15:20:50 +02:00
tliu93 32d93bba2a M2-T09: build data visualization UI (heatmap map as home page)
- self-contained RecordsMap (only module importing leaflet/react-leaflet/
  leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props
- heatmap layers for location + poo (primary); time-range selector fetches
  only the window (locations server-filtered; poo client-filtered)
- toggleable scatter layer with marker clustering; point-select reuses T10's
  edit/delete modals + hooks; query-key prefixes refresh map on mutation
- pure map logic isolated + unit-tested; leaflet mocked in component tests
- responsive layout; typed client only
2026-06-13 15:20:50 +02:00
tliu93 0d988a9b28 docs(m2): mark M2-T10 done 2026-06-13 15:20:50 +02:00
tliu93 ef7ea6b971 M2-T10: build records management UI (paginated lists + single-record CRUD)
- reusable src/records/ module: useUpdate/useDelete Poo+Location hooks
  (encodeURIComponent PK, prefix-based query invalidation), EditPooModal,
  EditLocationModal, ConfirmDeleteModal — exported for the map (T09) to reuse
- RecordsPage (/records): paginated poo + location tables (page size 100),
  edit + delete-with-confirm, refresh on success
- query keys ['poo']/['locations'] so map and list invalidations cross-cut
- typed client only; vitest tests
2026-06-13 15:20:50 +02:00
tliu93 6cc6382515 docs(m2): mark M2-T08 done 2026-06-13 15:20:50 +02:00
tliu93 ef2bd3c9c5 M2-T08: build config UI (replaces Jinja config page)
- GET /api/config renders sections; secret fields shown as empty password inputs
- save handles full-field submission semantics: always send non-secret values,
  send secret only when user typed a new value (blank secret keeps old)
- SMTP test button reflects tri-state (success / config-error 400 / failed 502)
  by reading ApiError.body.result
- typed client only; responsive Mantine layout; vitest tests
2026-06-13 15:20:50 +02:00
tliu93 cc2c02a2e2 docs(m2): mark M2-T07 done 2026-06-13 15:20:50 +02:00
tliu93 b2e26f0b17 M2-T07: build auth UI (login, session bootstrap, forced password change, logout)
- real Mantine login form -> POST /api/auth/login; 401 inline error; redirect when already authed
- ProtectedRoute: loading state, preserves intended destination, gates force_password_change
- ChangePasswordPage forced-change gate -> POST /api/auth/password
- logout control in AppLayout nav -> POST /api/auth/logout
- typed client only; vitest tests for the login flow
2026-06-13 15:20:50 +02:00
tliu93 8975acc48b docs(m2): mark M2-T06 done 2026-06-13 15:20:50 +02:00
tliu93 6cfeb2b865 M2-T06: scaffold React SPA frontend with typed OpenAPI client
- Vite + React 18 + TypeScript + Mantine + TanStack Query + react-router-dom
- typed client: openapi-typescript -> src/api/schema.d.ts (committed), openapi-fetch
- fetch wrapper middleware: cookies, X-CSRF-Token on writes, 401 -> /login,
  non-401 errors carry parsed JSON body
- SessionProvider/useSession (GET /api/session), ProtectedRoute skeleton
- app shell (Mantine + router) with placeholder login/home/config pages + gear nav
- dev proxy to FastAPI; vitest smoke test; frontend README
- npm scripts: dev/build/preview/lint/typecheck/test/codegen
2026-06-13 15:20:50 +02:00
tliu93 dba9e28540 docs(m2): mark M2-T05 done 2026-06-13 15:20:50 +02:00
tliu93 2bc5d6ea9a M2-T05: add SMTP test action API (POST /api/config/smtp/test)
- reuses send_smtp_test_email; tri-state result success(200)/config-error(400)/failed(502)
- session + CSRF protected; never echoes SMTP secrets
- SmtpTestResponse schema; regenerate openapi/
- extend tests/test_api_config.py (3 states + 401 + missing-CSRF 403)
2026-06-13 15:20:50 +02:00
tliu93 3ec663e138 docs(m2): mark M2-T04 done 2026-06-12 23:35:56 +02:00
tliu93 048414c5cb M2-T04: add single-row record CRUD API (patch/delete)
- PATCH/DELETE /api/locations/{person}/{datetime} and /api/poo/{timestamp}
- update only non-PK fields (PK immutable); 404 on missing PK
- delete scoped to exact full PK with rowcount guard (0->404, 1->ok);
  no batch/truncate/drop path
- session + CSRF protected; bare ingestion endpoints untouched
- service helpers in app/services/location.py and poo.py; regenerate openapi/
- tests/test_api_record_crud.py
2026-06-12 23:33:08 +02:00
tliu93 9ce3f2a0b8 docs(m2): mark M2-T03 done 2026-06-12 23:27:02 +02:00
tliu93 0fba7cfe11 M2-T03: add read-only data JSON API
- GET /api/locations (inclusive time window start/end, pagination, cap 5000)
- GET /api/poo (pagination, cap 1000, newest first)
- GET /api/public-ip (current state + recent history, cap 1000)
- all session-protected, read-only, bounded (no full-table export)
- typed response schemas; register router; regenerate openapi/
- tests/test_api_data.py
2026-06-12 23:24:17 +02:00
tliu93 d8303eaa3d docs(m2): mark M2-T02 done 2026-06-12 23:18:43 +02:00
tliu93 8da1f13e60 M2-T02: add session/auth JSON API for the SPA
- GET /api/session (user + csrf_token, 401 when unauthenticated)
- POST /api/auth/login (sets HttpOnly session cookie; 401 on bad creds; no CSRF)
- POST /api/auth/logout (session+CSRF; revokes session, clears cookie; 204)
- POST /api/auth/password (session+CSRF; reuses change_password; 400 on failure; 204)
- reuses app/services/auth.py and shared require_session/require_csrf deps
- register router in app/main.py; regenerate openapi/
- tests/test_api_session.py
2026-06-12 23:15:56 +02:00
tliu93 de77019ce3 docs(m2): mark M2-T01 done 2026-06-12 23:11:38 +02:00
tliu93 c2b1b7b751 M2-T01: add config JSON API (GET/PUT /api/config)
- new app/api/routes/api/ package with shared require_session (401) and
  require_csrf (presence-only X-CSRF-Token, 403) dependencies
- GET /api/config returns masked config sections; PUT /api/config reuses
  save_config_updates (blank secret keeps old; invalid -> 422, no write)
- session-protected; PUT also CSRF-protected
- register router in app/main.py; regenerate openapi/
- tests/test_api_config.py
2026-06-12 23:08:14 +02:00
tliu93 3628ac51e5 chore(m2): green the ruff baseline before M2 orchestration
- ignore E402 in scripts/*.py (deliberate sys.path bootstrap before app imports)
- drop unused pathlib.Path import in tests/test_auth.py

Establishes a clean ruff gate so each M2 task can be verified green at its boundary.
2026-06-12 22:56:21 +02:00
tliu93 1756192270 docs: record future-ideas backlog and refine CLAUDE.md workflow rules
pytest / test (push) Successful in 46s
- Add docs/future-ideas.md: unscheduled backlog (more data sources/types,
  long-term storage, Home Assistant data persistence, MQTT client, near-term PWA).
- CLAUDE.md: codify M1 lessons — reviewer blind-review discipline,
  build-context integrity checks when deleting/moving files,
  fixup-vs-standalone-commit boundary, and a pre-release walkthrough
  (run app + docker build + manual smoke) before tagging.
2026-06-12 22:50:47 +02:00
tliu93 66ec9979cc docs(m2): lock M2 frontend design decisions
pytest / test (push) Failing after 11m46s
Record the decisions reached in planning into docs/design/m2-frontend-v2.md:
component library = Mantine; map = Leaflet (react-leaflet + leaflet.heat +
markercluster, isolated behind a component seam for a future MapLibre swap);
OpenAPI typed client committed + CI-checked; CSRF simplified to SameSite=Lax +
a custom write header (no per-session token); heatmap-first map as the home
view with a required time-range picker and an auxiliary paginated list;
record CRUD edits non-PK fields and deletes single rows (no UI create); bare
ingestion endpoints stay until M3; trips optional. Wireframes intentionally
skipped for this milestone.
2026-06-12 22:40:57 +02:00
tliu93 c1a5d7a425 fix(docker): stop COPYing removed alembic_location/alembic_poo into the image
pytest / test (push) Successful in 50s
docker-image / build-and-push (push) Successful in 3m52s
M1-T04 deleted the alembic_location / alembic_poo chains and their .ini files,
but the Dockerfile still COPYed those four paths, so the release image build
failed at 'COPY alembic_poo.ini ./' (path not found). Drop the four stale COPY
lines (only alembic_app remains). Add test_dockerfile_copy_sources_exist, which
asserts every Dockerfile COPY source exists in the build context, so this class
of breakage fails pytest instead of only surfacing in the image CI.

Verified with a real local 'docker build' (succeeds) and pytest (98 passed).
2026-06-12 20:48:34 +02:00
tliu93 1e0b235cef Merge pull request 'Feature/m1 db consolidation' (#7) from feature/m1-db-consolidation into main
pytest / test (push) Has been cancelled
docker-image / build-and-push (push) Has been cancelled
Reviewed-on: #7
2026-06-12 20:33:34 +02:00
tliu93 a337b06c94 M1-rework: rename leftover pk_cols param in reconciliation test stubs
pytest / test (push) Successful in 48s
pytest / test (pull_request) Failing after 10m49s
Round-2 audit nit (review-notes/M1-full-review-2.md): two _always_fail
monkeypatch stubs still named their (ignored) third positional parameter
pk_cols after _reconcile switched to a full columns list. Rename to columns
for consistency. Test-only, no behavior change.

pytest 97 passed; ruff clean.
2026-06-12 19:11:13 +02:00
tliu93 1cbe6c46d2 M1-rework: harden legacy-migration reconciliation to full-row equality
Audit finding (review-notes/M1-full-review-1.md, FINDING 1): _reconcile only
checked primary-key presence, so a source row skipped by INSERT OR IGNORE due
to a value difference against a pre-existing same-PK target row would
false-pass. Compare ALL columns with SQLite's NULL-safe IS operator instead,
so reconciliation is a true full-row guarantee (idempotent re-runs still pass
because the rows match column-for-column). Add tests for the value-mismatch
abort and for idempotency under full-row reconciliation. Remove the now-unused
pk_cols parameter.

pytest 97 passed; ruff clean (pre-existing only); data-safety grep still empty.
2026-06-12 19:05:56 +02:00
tliu93 2f634006d2 M1-T07: align docs to single-DB reality and re-export OpenAPI
Rewrite README (single app.db + one alembic_app chain, legacy data moved
once via scripts.migrate_legacy_data, accurate test list) and remove the
Grafana Provisioning section. Update architecture-overview to the unified
data layer (one Base, app-DB engine with WAL) and retire the
alembic_location / alembic_poo sections. Mark M1 done in the roadmap.
Re-export openapi/, which catches the spec up to the already-existing
/config/smtp/test and /public-ip/check endpoints (purely additive; M1's
DB-session dependency swap produced no schema change).

pytest 95 passed; ruff clean (pre-existing only); OpenAPI export idempotent.
2026-06-12 17:13:28 +02:00
tliu93 dc624bb7e5 M1-T06: remove Grafana from compose and delete provisioning
Drop the grafana service and the homeautomation_grafana_storage volume
declaration from docker-compose.yml (migration and app services unchanged),
and delete the grafana/ provisioning + dashboards directory. Visualization
moves to the M2 React frontend; the named volume's actual data is retired
manually as an ops step, never by automation.

docker compose config -q passes; pytest 95 passed; ruff clean.
2026-06-12 17:02:49 +02:00
tliu93 af8c602988 M1-T05: drop location/poo database config from Settings and tests
Remove the dead location_database_url / poo_database_url fields and the
location_sqlite_path / poo_sqlite_path computed properties from Settings;
drop them from the config-page payload and from .env.example. Update the
test hardcodes (test_config, test_public_ip, test_smtp) and reduce the
conftest test_database_urls fixture to the single app DB. The one-time
migration script keeps reading legacy URLs from env/CLI, independent of
Settings.

pytest 95 passed; ruff clean (pre-existing only).
2026-06-12 16:57:54 +02:00
tliu93 0d898e09f2 M1-T04: converge startup chain onto the single app DB
run_all_migrations() now adopts/initializes only the app DB and returns
{'app': ...}. app/main.py drops the location/poo readiness checks
(ensure_location_db_ready / ensure_poo_db_ready) and their imports;
ensure_runtime_dirs only provisions the app DB path; lifespan still
fail-closes on a missing/unmanaged app DB. Delete the retired
location/poo adopt scripts and the alembic_location / alembic_poo
chains. Update tests to single-DB expectations and drop the obsolete
location/poo adoption + readiness tests.

pytest 95 passed; ruff clean (pre-existing only); a fresh app DB
initialized via scripts.run_migrations contains location + poo_records.
2026-06-12 16:50:05 +02:00
tliu93 3d3c2bcc57 M1-T03: unify data layer, models, deps and routes onto single app DB
Collapse the three data layers into one. app/db.py now exposes a single
Base, a cached engine bound to app_database_url with SQLite WAL enabled, and
get_engine/get_session_local/reset_db_caches/get_db_session. Delete
app/auth_db.py, app/poo_db.py and app/models/base.py. All models (auth,
config, public_ip, location, poo) inherit the one Base and register on a
single metadata. Dependencies converge to a single get_db; all routes use it.

Also update the alembic env.py files (app/location/poo) and tests that
imported the removed modules so the suite stays green, and drop the obsolete
test_legacy_style_location_db test whose flow (app reading a separate location
DB) no longer exists. Location/poo Alembic chains, adopt scripts and adoption
tests remain for M1-T04; config fields remain for M1-T05.

pytest 109 passed; ruff clean (pre-existing only); WAL verified; single
Base.metadata holds all seven tables.
2026-06-12 16:35:07 +02:00
tliu93 bc8dd062d5 M1-T02: add idempotent legacy data migration script
scripts/migrate_legacy_data.py copies rows from the legacy locationRecorder.db
/ pooRecorder.db into the unified app DB's location / poo_records tables using
ATTACH + INSERT OR IGNORE (idempotent via PK-conflict skip; explicit columns,
never SELECT *). After copy it reconciles every source row against the target
and raises / exits non-zero on any shortfall. Missing legacy files are a safe
no-op (skipped); --dry-run writes nothing. Not part of the Alembic chain; run
manually once at cut-over. Never deletes or overwrites any file.

Validated end-to-end on copies of the real production DBs: dry-run reported
75103 location + 874 poo rows and wrote nothing; the real run copied all rows
with reconciliation passing; a second run copied 0 (idempotent).
2026-06-12 16:13:55 +02:00
tliu93 427a491380 M1-T01: add app-chain revision creating location + poo_records tables
Add alembic_app revision 20260611_06_merge_location_poo_tables that builds
empty location and poo_records tables (REAL float columns, matching the
production schema and the adopt scripts' EXPECTED_*_TABLE_INFO constants).
Update APP_BASELINE_REVISION to the new head. Schema-only; data migration
is handled separately by scripts/migrate_legacy_data.py (M1-T02).
2026-06-12 16:02:46 +02:00
tliu93 b359bbe3bf docs: add next-phase roadmap, milestone design docs, and CLAUDE.md
pytest / test (push) Successful in 54s
- roadmap.md: M1 (DB consolidation) -> M2 (React SPA) -> M3 (token/mobile)
- docs/design/: agent-pipeline design docs with atomic tasks for M1-M3
- CLAUDE.md: workflow, doc map, commit conventions, review-notes briefing flow
- .gitignore: ignore local review-notes/
2026-06-12 15:37:17 +02:00
tliu93 636bb2b80b Merge pull request 'add get public and storage feature' (#6) from feature/public_ip into main
docker-image / build-and-push (push) Successful in 3m59s
pytest / test (push) Successful in 53s
Reviewed-on: #6
2026-04-29 13:16:58 +02:00
tliu93 eda49489e0 update reademe and docs
pytest / test (push) Successful in 56s
pytest / test (pull_request) Successful in 59s
2026-04-29 13:07:59 +02:00
tliu93 779e160b95 add ip change notification and refine sender display
pytest / test (push) Successful in 57s
pytest / test (pull_request) Successful in 54s
2026-04-29 13:03:12 +02:00
tliu93 3ea3498e58 add smtp module and testing 2026-04-29 12:11:10 +02:00
tliu93 5a420bd37b add get public and storage feature 2026-04-29 11:45:49 +02:00
tliu93 a24e402d47 add grafana provisioning
pytest / test (push) Successful in 46s
2026-04-23 00:12:51 +02:00
tliu93 8565534b73 Merge pull request 'fix ci test' (#5) from feature/add_separate_migration_container into main
pytest / test (push) Successful in 45s
docker-image / build-and-push (push) Successful in 3m40s
Reviewed-on: #5
2026-04-22 13:35:40 +02:00
tliu93 4acdd2dc60 fix ci test
pytest / test (push) Successful in 45s
pytest / test (pull_request) Successful in 44s
2026-04-22 13:31:26 +02:00
tliu93 c9af7530e5 Merge pull request 'change adoption to separate step' (#4) from feature/add_separate_migration_container into main
pytest / test (push) Failing after 44s
docker-image / build-and-push (push) Successful in 3m40s
Reviewed-on: #4
2026-04-22 13:28:30 +02:00
tliu93 a76d6bfb71 change adoption to separate step
pytest / test (push) Failing after 46s
pytest / test (pull_request) Failing after 45s
2026-04-22 13:28:00 +02:00
tliu93 35aee79d93 Restore legacy poo inbound dispatch
pytest / test (push) Successful in 43s
docker-image / build-and-push (push) Successful in 3m38s
2026-04-20 23:33:57 +02:00
tliu93 b9e7f51d51 Split compose dev build from registry deploy
pytest / test (push) Successful in 44s
2026-04-20 23:16:13 +02:00
265 changed files with 80295 additions and 2680 deletions
+3
View File
@@ -8,3 +8,6 @@ data
openapi
src
# Frontend host build artifacts — built inside the node stage, not needed from context
frontend/node_modules
frontend/dist
+35 -2
View File
@@ -4,8 +4,6 @@ APP_NAME=Home Automation Backend (Python)
APP_ENV=production
APP_HOSTNAME=home-automation.example.com
APP_DATABASE_URL=sqlite:////app/data/app.db
LOCATION_DATABASE_URL=sqlite:////app/data/locationRecorder.db
POO_DATABASE_URL=sqlite:////app/data/pooRecorder.db
AUTH_BOOTSTRAP_USERNAME=admin
AUTH_BOOTSTRAP_PASSWORD=change-me
@@ -30,3 +28,38 @@ TICKTICK_CLIENT_ID=
TICKTICK_CLIENT_SECRET=
TICKTICK_TOKEN=
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
# Optional: Modbus polling (global kill-switch; default false — opt-in).
# MODBUS_POLLING_ENABLED=false
# Optional: MQTT broker connection.
# Leave MQTT_ENABLED=false (or unset) when MQTT is not needed.
MQTT_ENABLED=false
MQTT_BROKER_HOST=
MQTT_BROKER_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=
MQTT_TLS_ENABLED=false
# Optional: Home Assistant MQTT Discovery.
# Requires MQTT_ENABLED=true and a running MQTT broker.
# HA_DISCOVERY_PREFIX is the config topic prefix (must be "homeassistant" for HA auto-discovery).
# HA_STATE_TOPIC_PREFIX is the prefix for state/availability topics (separate from discovery).
HA_DISCOVERY_ENABLED=false
HA_DISCOVERY_PREFIX=homeassistant
HA_STATE_TOPIC_PREFIX=home_automation
# Optional: DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
# Set DSMR_INGEST_ENABLED=true to subscribe to the DSMR MQTT topic and
# store 10-second downsampled telegram frames in dsmr_reading.
# DSMR_INGEST_ENABLED=false
# DSMR_MQTT_TOPIC=dsmr/json
# DSMR_SAMPLE_INTERVAL_S=10
# DSMR tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak).
# Empty string disables tariff-aware pricing (buy/sell_price_now always show normal rate).
# DSMR_TARIFF_TOPIC=dsmr/meter-stats/electricity_tariff
# Optional: Tibber dynamic pricing credentials (M6).
# Only used when an active energy contract with kind=tibber is configured.
# TIBBER_API_TOKEN=
# TIBBER_HOME_ID=
+49
View File
@@ -0,0 +1,49 @@
name: frontend
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Check codegen is in sync
working-directory: frontend
run: |
npm run codegen
git diff --exit-code src/api/schema.d.ts
- name: Lint
working-directory: frontend
run: npm run lint
- name: Type-check
working-directory: frontend
run: npm run typecheck
- name: Test
working-directory: frontend
run: npm run test
- name: Build
working-directory: frontend
run: npm run build
+1
View File
@@ -5,3 +5,4 @@
__pycache__/
*.pyc
data/
review-notes/
+181
View File
@@ -0,0 +1,181 @@
# CLAUDE.md — Home Automation Backend
本文件每次会话自动加载。它定义本项目的**工作流程、文档位置、commit 规范**。请在动手前先读完。
## 项目速览
- 个人用 home-automation 后端:**FastAPI + SQLite + SQLAlchemy + Alembic**,服务端模板(JinjaM2 将换成 React SPA)。
- 单 admin 鉴权(Argon2 + server-side session cookie),runtime config 落 `app_config` 表。
- 模块:public IPv4 monitor、SMTP 通知、location recorder、poo recorder、Home Assistant in/out、TickTick OAuth。
- 已发布 `v1.0.3`。下一阶段方向:**M1 单库化 → M2 React 前端 → M3 token/移动端(远期,M2 后再说)**。
- **当前现实**:在 M1 完成前仍是**三个独立 SQLite 库**app / location / poo),三套 DeclarativeBase、三条 Alembic 链。不要假设已经单库——以代码现状为准。
- 明确不做:Notion 模块。
## 文档地图与「开工前必读」
文档都在 `docs/`
| 路径 | 作用 |
| --- | --- |
| `docs/roadmap.md` | 全局规划与里程碑总览 |
| `docs/design/README.md` | **协作契约**:任务卡格式、原子任务定义、校验闸门、数据安全红线 |
| `docs/design/m1-db-consolidation.md` | M1 原子任务(含真实代码现状盘点 + 人工 runbook) |
| `docs/design/m2-frontend-v2.md` | M2 原子任务 + API 契约 + 前端校验闸门 |
| `docs/design/m3-token-mobile.md` | M3(远期,暂缓) |
| `docs/*.md`auth / public-ip-monitor / location-recorder …) | 各模块说明,按需读 |
**开工时读取顺序**
1. `docs/design/README.md`(每轮都读,它是流程与验收的共同契约)。
2. 本轮对应的 milestone 文档(如 `docs/design/m1-db-consolidation.md`),定位要做的任务卡。
3. 任务卡 `Files` 列出的源文件 + 该模块的 `docs/*.md`(按需)。
4. `docs/roadmap.md` 仅在需要全局视角时读。
## 工作流程
### 实现模式(由用户的提示词决定)
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent**时,才起 implementer / reviewer / fixer sub-agent(模型用下方**『默认模型档位』**,用户人工指定则覆盖),按任务依赖顺序跑完整条链。
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时按下方**『默认模型档位』**选择模型(用户人工指定则以人工指定为准),用 Agent 工具的 `model` 字段落实。
### 默认模型档位(实现模式 sub-agent;可被人工指定覆盖)
起 implementer / reviewer / fixer sub-agent 时,**默认**用下列模型档位,无需用户每次人工指定:
| 角色 | 默认模型 | 推理档位 |
| --- | --- | --- |
| **Implementer** | **Sonnet** | high reasoning effort |
| **Fixer**(返工) | **Sonnet** | high reasoning effort |
| **Reviewer** | **Opus** | extra-high reasoning effort |
- **人工指定覆盖**:若用户在提示词里**显式指定了其他模型**(针对任一角色),则**以用户人工指定为准**,覆盖上述默认。
- 用 Agent 工具的 `model` 字段落实模型选择;该字段当前仅支持 `sonnet` / `opus` / `haiku` / `fable`
- **推理档位说明**:Agent 工具未暴露独立的 reasoning-effort 旋钮,"high / extra-high reasoning" 通过 spawn prompt 里的显式指令传达(要求 implementer/fixer 动手前充分推理边界条件;要求 reviewer 以对抗性外部审计心态最高强度复核)。若所在 harness 提供真正的 effort 设置,则一并按此档位设置。
### 角色(Orchestrator → Implementer → Reviewer → Fixer
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`
- **Implementer**(默认 **Sonnet**high reasoning;见上方『默认模型档位』):一次一个任务,严格按任务卡,不扩范围。
- **Reviewer**(默认 **Opus**extra-high reasoning):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动返工直到本轮 PASS。
- **Fixer**(默认 **Sonnet**high reasoning):按 reviewer 的编号返工清单返工;**每轮返工起一个干净的 Fixer**(与首次实现的 Implementer 分开冷启动),先读对应 `review-notes/<task>-review-<n>.md` 再改。
#### Reviewer 盲审纪律(M1 教训)
M1 里 review **从未触发过一次 rework**,根因是 orchestrator 把自己的结论 / 辩护喂给了 reviewer,造成 context bleed、review 沦为橡皮图章。所以:
- reviewer 必须**冷启动(Clear-Agent)、最小化喂料**——spawn prompt 只给:① 任务卡(`Acceptance criteria` + `Reviewer checklist`)、② 对应的 `review-notes/<task>-impl|rework-<n>.md` 路径、③ 要审的 diff / commit 范围。
- **不要**在 prompt 里塞 orchestrator 自己的判断、"我觉得没问题"、对实现选择的辩护,或上一轮 reviewer 的倾向性结论。让它**独立得出结论、独立重跑校验闸门**。
- 事后另起的整库**独立盲审**(如对抗复审)同理:Clear-Agent、最小上下文,把它当"**外部审计**"而非"确认自己没错"。
### 校验闸门(每个任务结束都要全绿)
根目录、激活 `.venv` 后:
```bash
pytest # 权威闸门(CI 跑的就是它)
ruff check . # line-length=100
python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路由/schema 才需要,且产物须入库
```
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
### 构建上下文完整性(M1 Dockerfile 教训)
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile``COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
- 任务**删除 / 移动 / 重命名文件或目录**时,必须 grep 构建清单是否还在引用它们:`Dockerfile`(尤其 `COPY` 源)、`docker/``*.ini`、CI workflow、`requirements*.txt` 等。
- 已有回归测试 `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 守"Dockerfile `COPY` 源必须存在于构建上下文";新增 / 改动 `COPY` 时确保它仍覆盖得到。
- Reviewer 审"删 / 移文件"类任务时,**必须顺带核对构建清单引用**,把它当 acceptance 的一部分。
## 每轮简报(`review-notes/`
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
- **实现 / 返工简报**:每轮实现完成后(无论首次实现还是返工),写一份。文件名建议 `<task-id>-impl-<n>.md` / `<task-id>-rework-<n>.md`(如 `M1-T03-impl-1.md``M1-T03-rework-1.md`)。至少包含:
1. **本轮修改的具体内容**(改了哪些文件、做了什么、为什么)。
2. **自动化测试结果**`pytest` / `ruff` / 前端闸门的实际输出或结论,通过/失败逐项写清)。
3. **若需人工 walkthrough**:写明具体步骤(怎么启动、点哪里、预期看到什么);若无需人工验证,明确写"无需人工 walkthrough"。
- **review 简报**:每轮 review 后写一份,文件名建议 `<task-id>-review-<n>.md`(如 `M1-T03-review-1.md`)。至少包含:评审结论(`PASS` 或带编号的返工清单)、对照任务卡 `Acceptance criteria` + `Reviewer checklist` 的逐条核对、reviewer 独立重跑校验闸门的结果。
**用途**:① reviewer 审核时参考对应的实现简报;② implementer 返工时参考对应的 review 简报;③ 人类(用户)通读这些简报确认有无问题。简报之间用文件名里的 `<task-id>` 与轮次 `<n>` 对应起来。
### Orchestrator 派发契约(让简报真正被读到)
**关键**:sub-agent 冷启动、不继承主线上下文,**不会因为本文件提到简报就自动去读**对应文件。简报能流转,靠的是 orchestrator(主线)在**每次 spawn 时把路径显式写进 prompt**,而不是被动约定。所以派发时必须做到:
- **显式告诉它「先读哪个简报」**:
- 派 implementer 做**首次实现** → 传任务卡位置(milestone 文档路径 + task id);无前置简报。
- 派 implementer 做**返工** → 必须传对应的 `review-notes/<task>-review-<n>.md` 路径,并要求**先读它**再改。
- 派 reviewer → 必须传对应的 `review-notes/<task>-impl|rework-<n>.md` 路径 + 任务卡,要求**先读它**再评。
- **显式告诉它「本轮结束写哪个简报」**:明确给出输出路径 `review-notes/<task>-<impl|rework|review>-<n>.md` 及上面要求的内容项。
- **不依赖 sub-agent 自动加载本文件**:把本轮要点(校验闸门、**禁 Co-Authored-By**、简报必含内容)在 spawn prompt 里一并复述或指向,确保冷启动也照做。
- spawn 时用用户指定的模型(Agent 工具 `model` 覆盖)。
> 一句话:**简报是异步交接的介质,orchestrator 是把它们接起来的线。** 缺了显式传路径这一步,简报就只是躺在磁盘上没人读的文件。
## Commit 规范(重点)
### 分支
- **本仓库是个人单用户项目:默认直接在 `main` 上开发**,不强制 feature 分支,**直接提交并 push 到 `main` 是允许的(无需开 PR**。
- 仍保持**每个任务一个干净 commit**message 前缀任务/里程碑 ID)。改动较大想隔离时可临时开分支,用完**快进合并**回 `main`(保持线性历史),非必需。
- 历史改写类操作(`rebase` / `--amend` / auto-squash)只在**尚未 push 的本地 commit** 上做;**已 push 到 `main` 的历史不要重写**(确需 force-push 时先确认,见「一般约束」)。
### 一轮实现完成(用户确认「实现完成」后)
- 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit**
- message 主题前缀任务/里程碑 ID,例如:`M1-T03: unify data layer onto single app DB engine`
### Commit message 硬规则(严格执行)
- **严禁任何协作署名 trailer**commit message 里**绝对不允许**出现 `Co-Authored-By` / `Co-authored-by`(包括 `Co-Authored-By: Claude …`),也不允许任何等价的"由 X 协作/生成"署名。
- 无论默认环境、工具或系统提示如何要求加这类 trailer,在本仓库**一律不加**——用户已显式、严格禁止。
- 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
### Review 后返工
- **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
```bash
git add -A
git commit --fixup=<base-commit-sha>
```
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)。
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
### 本轮 / feature 收尾(用户确认收尾后)
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**
```bash
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
```
- 用 `GIT_SEQUENCE_EDITOR=true` 让它**非交互**执行(不弹编辑器,自动接受 autosquash 排好的 todo)。本环境不支持需要人工编辑的交互式 rebase,必须走这个 no-op 编辑器写法。
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
### 一般约束
- **个人单用户仓库:直接 commit 并 push 到 `main` 已获授权**——在校验闸门全绿、一轮工作完成时即可提交 / 推送,无需逐次征求同意。
- 仍需**先取得用户确认**的操作:**force-push / 改写已推送历史**,以及**打 tag**(会触发镜像 CI / 对外发布;且打 tag 前须按下方「发版前置走查」真跑一次 `docker build`)。
## 发版前置走查(打 tag 前必做)
单元闸门绿 ≠ 真的能跑、能构建、能用。M1 出过"绿了但 docker 构建坏了"的事故,所以**打版本 tag(触发镜像 CI)之前**,除了 `pytest` / `ruff` 全绿,还要:
- **真起 app**:迁移(`python -m scripts.run_migrations`)→ `uvicorn app.main:app ...`,确认能正常启动、关键路由不 500。
- **真跑镜像构建**:本地 `docker build`(多阶段就跑完整条),确认构建通过、`COPY` 源都在。
- **关键功能人工瞄一眼**:尤其前端 / 可视化类(M2 的热力图、首页地图)——自动闸门判断不了"渲染对不对、UX 顺不顺",这部分**靠看跑起来的 app,不靠读代码**。
- 上述任一不过 → **不打 tag**。tag 一旦 push 会触发 docker 镜像 CI / 对外发布,属对外操作,**先确认**。
## 数据安全红线(不可违反)
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
- 涉及历史数据的迁移**先在备份副本上演练**;迁移脚本必须幂等且搬完对账行数。
- Review 时只要发现"删文件 / drop 有数据的表 / truncate"出现在自动化任务里,直接判返工。
## 常用命令
```bash
# 环境
python -m venv .venv && source .venv/bin/activate && pip install -r dev-requirements.txt
# 迁移(初始化/适配 DB
python -m scripts.run_migrations
# 起服务
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 测试 / lint / OpenAPI 导出
pytest
ruff check .
python scripts/export_openapi.py
```
+21 -4
View File
@@ -1,3 +1,20 @@
# Stage 1: build the React SPA.
# Pin to the native build host ($BUILDPLATFORM) so the Node/V8 build never runs
# under QEMU during multi-arch builds — emulated Node crashes V8's baseline JIT
# (SIGTRAP / exit 133 on `npm ci`). The dist/ output is static JS/CSS, i.e.
# architecture-independent, so building it once and COPYing it into each
# target-arch runtime stage is both correct and avoids the emulator entirely.
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: python runtime (no node)
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -11,15 +28,15 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY alembic_app ./alembic_app
COPY alembic_app.ini ./
COPY alembic_location ./alembic_location
COPY alembic_location.ini ./
COPY alembic_poo ./alembic_poo
COPY alembic_poo.ini ./
COPY scripts ./scripts
COPY docker ./docker
COPY README.md ./
RUN mkdir -p /app/data
# Copy the built SPA dist from the frontend-build stage
COPY --from=frontend-build /frontend/dist ./frontend/dist
EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+410 -55
View File
@@ -4,14 +4,23 @@
当前系统已经包含:
- FastAPI Web 应用与服务端模板页面
- SQLite + SQLAlchemy + Alembic 的库结构
- username/password + server-side session 鉴权
- FastAPI Web 应用React SPA 前端 + JSON API
- SQLite + SQLAlchemy + Alembic 的库结构
- username/password + server-side session 鉴权(含登录加固,见下文)
- runtime config 页面与 app DB 持久化
- public IPv4 monitor、历史持久化与定时检查
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知
- location recorder
- poo recorder
- Home Assistant inbound / outbound integration
- Home Assistant inbound / outbound integrationREST 通道)
- TickTick OAuth 与 action task 集成
- **Modbus 设备采集**:通过 YAML profile(首个:SDM120 电表)按设备周期轮询 Modbus-TCP 网关,解码工程量并落通用读数表(`modbus_device` + `modbus_reading`
- **MQTT + Home Assistant Discovery**:以可勾选方式把 Modbus 设备/工程量注册为 HA device/entity(含 binary_sensor online),state 周期发布;配置变更可重连重发
- **前端侧边栏 + Energy 视图**:侧边导航替换顶栏;Energy 页管理 Modbus 设备、展示最新读数与 Recharts 走势图;Config 页 Accordion 分区展开;Expose 设置勾选 HA 可暴露实体
- **DSMR 实时电表接入**:订阅 DSMR Reader 的 `dsmr/json`(每秒一帧)、整帧 JSON blob 按 10 秒降采样落库(`dsmr_reading`
- **通用电价合同层**YAML profile 定合同结构(manual 固定/双费率 / tibber 动态电价);`EnergyContract`+`EnergyContractVersion` 存 UI 可填的数值,改价加新版本旧版本保留;price strategy 按 kind 出价
- **实时买卖电费计算**:每 15 分钟按寄存器差值(`_1`=dal/低、`_2`=normal/高)× 买/卖价算计量电费,快照不可变;日/月/年汇总加固定费减 heffingskorting
- **反哺 Home Assistant Energy**:当前买/卖价 + 累计买电支出/卖电收入(`total_increasing`)发成 HA 实体,可直接挂 HA Energy 仪表盘
- pytest 测试与 OpenAPI 导出脚本
- Docker / Compose 部署入口
@@ -21,43 +30,44 @@
## 当前配置现实
当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库
当前系统使用单一 SQLite 数据库文件`app.db`),所有数据表都在其中
- `app` 级共享数据使用自己的 DB 文件
- `location` 模块使用自己的 DB 文件
- `poo` 模块使用自己的 DB 文件
- auth(单个 admin 用户、server-side session
- runtime config 持久化(`app_config` 表)
- public IPv4 当前状态与变化历史
- location 记录(`location` 表)
- poo 记录(`poo_records` 表)
- Modbus 设备定义(`modbus_device` 表)
- Modbus 通用读数(`modbus_reading` 表,JSON payload
- HA 实体暴露开关(`exposed_entity_toggle` 表)
- DSMR 电表实时读数(`dsmr_reading` 表,整帧 JSON blob10s 降采样)
- 电价合同(`energy_contract` 表)与版本(`energy_contract_version` 表,values JSON
- Tibber 15 分钟电价缓存(`tibber_price` 表,不可变)
- 每 15 分钟计量电费(`energy_cost_period` 表,快照价,不可变)
当前阶段明确不借这次重构把这些 DB 合并。配置层已经显式反映这一点
配置层只保留一个数据库环境变量
- `APP_DATABASE_URL`
- `LOCATION_DATABASE_URL`
- `POO_DATABASE_URL`
目前 auth、`location``poo` 都已经接到各自独立的数据库文件。
`app.db` 不会在应用启动时自动创建,需要先运行:
其中 `app` 级共享 DB 当前主要用于:
```bash
python -m scripts.run_migrations
```
- 单个 admin 用户
- server-side session
- runtime config 持久化
这部分现在也使用 Alembic 管理:
- `app db` 不会在应用启动时自动创建
- 需要先运行 `python scripts/app_db_adopt.py`
- 这个脚本会创建新 DB 并建好 schema
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含全部表,包括 M5 新增的 `modbus_device``modbus_reading``exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading``energy_contract``energy_contract_version``tibber_price``energy_cost_period`)。
## 当前目录
主要目录如下:
- `app/`: FastAPI 应用代码
- `alembic_app/`: App DB 的 Alembic migration 环境
- `alembic_location/`: Location DB 的 Alembic migration 环境
- `alembic_poo/`: Poo DB 的 Alembic migration 环境
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine
- `alembic_app/`: App DB 的 Alembic migration 环境(管理所有表,含 M5 新增的 `modbus_device``modbus_reading``exposed_entity_toggle`,以及 M6 新增的 `dsmr_reading``energy_contract``energy_contract_version``tibber_price``energy_cost_period`
- `tests/`: pytest 测试
- `docs/`: 当前系统说明文档
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制
## 依赖管理
@@ -107,9 +117,7 @@ cp .env.example .env
3. 初始化数据库
```bash
python scripts/app_db_adopt.py
python scripts/location_db_adopt.py
python scripts/poo_db_adopt.py
python -m scripts.run_migrations
```
4. 启动服务
@@ -120,30 +128,81 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
启动后可访问:
- 应用首页:`http://localhost:8000/`
- 应用首页React SPA`http://localhost:8000/`
- 健康检查:`http://localhost:8000/status`
- Swagger UI`http://localhost:8000/docs`
- ReDoc`http://localhost:8000/redoc`
## 前端 v2React SPA
M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托管(同一容器、同一 origin)。
### 技术栈
- **Vite + React + TypeScript + Mantine**(组件库)
- **TanStack Query**(数据请求/缓存)
- **Leaflet / react-leaflet**(地图与热力图)
- **Recharts**Energy 视图走势图,M5 引入)
- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成)
### 本地开发(前端)
前端开发服务器会把 `/api``/location``/poo``/public-ip``/homeassistant``/ticktick``/status` 等路径代理到后端 FastAPI`:8000`)。
```bash
cd frontend
npm install
npm run dev # 启动 Vite dev server(默认 :5173),代理后端
```
### 构建
```bash
cd frontend
npm run build # 产出 frontend/dist
```
FastAPI 启动时若 `frontend/dist/index.html` 存在,则自动挂载该目录,并对非 `/api` 路径做 SPA fallback(返回 `index.html`)。该路径可通过环境变量 `SPA_DIST_DIR` 覆盖(默认值为 `frontend/dist`,与多阶段 Dockerfile 中 `COPY``/app/frontend/dist` 一致)。
### 类型化 API Client
前端 API client 由后端 OpenAPI schema 自动生成:
```bash
cd frontend
npm run codegen # 从 ../openapi/openapi.json 生成 src/api/schema.d.ts
```
生成物(`src/api/schema.d.ts`)已提交入库,CI 会校验它与 `openapi/openapi.json` 保持同步。
### 前端校验闸门
```bash
cd frontend
npm run lint # ESLint
npm run typecheck # TypeScript 类型检查
npm run test # Vitest 单元测试
npm run build # 构建,确认产出 dist
```
## 数据库与 Alembic
当前默认使用 SQLite,并区分三个数据库文件:
当前使用单一 SQLite 数据库文件:
- App DB`sqlite:///./data/app.db`
- Location DB`sqlite:///./data/locationRecorder.db`
- Poo DB`sqlite:///./data/pooRecorder.db`
- 数据目录:`./data/`
初始化 migration 环境后,可继续添加模型并生成迁移
所有模型(auth / config / public_ip / location / poo / modbus / expose)共用同一个 `Base`,均通过单一 Alembic 链管理
当前 `app``location` `poo` 都已经有各自独立的 Alembic 链路。
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
- 统一 migration job`python -m scripts.run_migrations`
- App DB 接管 / 初始化:`python scripts/app_db_adopt.py`
- App Alembic 环境:`alembic_app.ini` + `alembic_app/`
- Location Alembic 环境:`alembic_location.ini` + `alembic_location/`
- Poo Alembic 环境:`alembic_poo.ini` + `alembic_poo/`
- App DB 初始化:`python scripts/app_db_adopt.py`
- Location DB 接管 / 初始化:`python scripts/location_db_adopt.py`
- Poo DB 接管 / 初始化:`python scripts/poo_db_adopt.py`
历史 location / poo 数据(旧版本遗留的独立 DB 文件)已通过以下脚本一次性迁移至 `app.db`(幂等,不删除旧文件):
```bash
python -m scripts.migrate_legacy_data
```
## 基础鉴权
@@ -151,9 +210,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 认证模型:`username/password`
- 会话模型:server-side session + cookie
- 当前主要受保护页面:`/config`
- 当前公开页面:`/login`
- 当前公开 API现有业务 API 暂未在这一轮统一收口到 auth 下
- 当前受保护入口:React SPA`/` 等客户端路由)调用 `/api/*` JSON 端点
- 当前公开页面:`/login`SPA 登录页)
- 当前公开 API裸 ingestion 端点(`/location/record``/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
安全实现的当前边界:
@@ -161,7 +220,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- session cookie 使用 `HttpOnly`
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
- `SameSite=Lax`
- 登录表单和登出表单都有基础 CSRF 防护
- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` headerSameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
@@ -175,12 +234,203 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
当前前端主要有两条页面路径
React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`
- `/login`
- `/config`
- `/login`:登录页
- `/`:首页(地图热力图主视图)
- `/config`:配置页(取代原 Jinja `/config`
- `/records`:记录管理列表页
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`
## M4 登录加固
M4 在基础鉴权之上叠加了三层防御,详细说明见 [`docs/auth.md`](./docs/auth.md)。
### 防爆破 / 指数退避
登录失败超过 3 次后进入指数退避(`wait = min(900s, 1s × 2^(failures-3))`),期间请求返回 `429 Too Many Requests`(含 `Retry-After` 响应头);成功登录后自动清零。退避按 **client IP****username** 双键取较大值,不会因此永久锁定账号(只是延迟,不是封号)。
- 全局开关:`AUTH_LOGIN_THROTTLE_ENABLED`CONFIG_FIELDS,默认 `true`
- 反代后需要 `AUTH_TRUST_FORWARDED_FOR=true` 才会读 `X-Forwarded-For``.env` 部署级配置,默认 `false`
### CLI 逃生通道
拿到服务器 CLI 权限时,可以在**不依赖任何已存凭据**(无需密码、恢复码)的情况下重置密码、解锁退避、关停 TOTP:
```bash
# 重置密码(不加 --password 则交互式输入,不回显)
python -m scripts.admin_cli reset-password admin
# 解锁退避(被 429 挡住时使用)
python -m scripts.admin_cli unlock --all # 清所有退避行
python -m scripts.admin_cli unlock --ip 1.2.3.4 # 按 IP 清
python -m scripts.admin_cli unlock --username admin # 按 username 清
# TOTP 相关(需要先启用,见下文)
python -m scripts.admin_cli disable-totp admin # 关停 TOTP(零凭据,逃生口)
python -m scripts.admin_cli reissue-totp admin # 重新发放 TOTP secret(打印新 URI
# 查看用户列表
python -m scripts.admin_cli list-admin
```
在 Docker 容器内执行时:
```bash
docker compose exec app python -m scripts.admin_cli <command>
```
### 可选 TOTP 二次验证
admin 可在 React SPA 设置页(`/config`)自选启用 RFC 6238 TOTP
1. 设置页点「启用 TOTP」→ 后端生成 `otpauth://` URI,前端渲染二维码(`qrcode.react`
2. 用 Authenticator App(如 Google Authenticator、Authy)扫码
3. 输入当前 6 位动态码确认 → TOTP 启用
4. 妥善保存一次性展示的 10 个恢复码(格式 `xxxx-xxxx`
启用后,登录需要两步:密码 → 6 位动态码(或恢复码,一次性)。不启用则维持纯密码登录,行为不变。
恢复码丢失时,可用 CLI 逃生:`python -m scripts.admin_cli disable-totp admin`,随后即可纯密码登录。
TOTP issuer 标签(显示在 Authenticator 里)通过 `AUTH_TOTP_ISSUER` 环境变量配置(`.env` 部署级),默认回退 `app_name`
## M5 Modbus 设备采集 / Energy / MQTT + HA Discovery
M5 给后端接入家庭 IoT 生态,新增通用 Modbus 采集链路(首个领域:能耗)、MQTT + Home Assistant Discovery 发布,以及前端侧边栏与 Energy 视图。
### 依赖
后端新增:
- `pymodbus`Modbus-TCP 客户端(轮询电表等 slave 设备)
- `paho-mqtt`MQTT 客户端(HA Discovery 与 state 发布)
- `pyyaml`YAML profile 加载(设备协议声明式描述)
前端新增:
- `recharts`Energy 视图走势图
### Modbus 设备采集
采集链路采用两层分离:**YAML profile**(协议知识,随代码走)+ **`modbus_device` 数据库行**(部署/可配置信息)+ **`modbus_reading` 通用读数表**JSON payload 遥测)。
- **profile**(如 `sdm120.yaml`)描述:读哪些寄存器(FC04 块读)、每个量的 key/unit/device_class/ha_component。纯协议知识,不含 unit_id / friendly_name 等部署项。
- **`modbus_device` 行**friendly_name、网关 host/port、Modbus slave `unit_id`(电表 Meter ID,设备面板可改故落 DB)、选用哪个 profile、采样周期、是否启用。
- **`modbus_reading` 行**device_id FK、recorded_at、payloadJSON,如 `{"voltage": 230.2, "current": 1.3, ...}`)。
- 多设备可共享同一 profile(如两块 SDM120 共用 `sdm120` profile,各自独立 unit_id 和 friendly_name)。
- APScheduler 后台 job 周期轮询所有 `enabled` 设备,更新 `last_poll_at` / `last_poll_ok`。全局开关 `MODBUS_POLLING_ENABLED`CONFIG_FIELDS)。
**手工命令行试读**(不依赖 DB,最快验证网关连通性):
```bash
# 按 profile 解码读一次(验证整套解码链路)
python -m scripts.modbus_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120
# 手工指定请求内容(first-contact 验证,不依赖 profile
python -m scripts.modbus_cli probe --host <网关IP> --port 502 --unit 1 --fc 4 --address 0x0000 --count 2 --decode float32
```
CLI 工具为受控手工验证而设(设备需接市电),仅暴露读功能码(FC03/04),无写寄存器子命令。
### MQTT + Home Assistant Discovery
后端作为 MQTT 发布方,把 Modbus 设备/工程量以 HA Discovery 协议自动注册为 device/entity
- 每个 Modbus 设备 = 一个 HA device`unique_id` 锚定于设备 `uuid`,不随改名变)
- 各工程量 = sensor entitydevice_class/unit 取自 YAML profile);另有 binary_sensor `online`(取 `last_poll_ok`
- 每次轮询成功后推 state topic;连接成功或勾选变更时重发 retained discovery config
- `POST /api/config/mqtt/test`:试连 broker 并发布一条测试消息(可用 MQTT Explorer 验证链路)
**Config 页配置流程**
1.`/config` 的「MQTT」section 填写 broker host/port/username/password,启用 `MQTT_ENABLED`
2. 点「发送测试消息」确认 broker 链路通
3. 启用 `HA_DISCOVERY_ENABLED`
4. 在「Home Assistant Expose」面板勾选要暴露的实体,点「重新发布 discovery」
5. 在 Home Assistant 确认对应 device/entity 出现
### API 端点(M5 新增)
| 端点 | 用途 |
| --- | --- |
| `GET /api/modbus/devices` | 列出 Modbus 设备 |
| `POST /api/modbus/devices` | 新建设备 |
| `GET /api/modbus/devices/{uuid}` | 单个设备 |
| `PATCH /api/modbus/devices/{uuid}` | 修改设备(含 enable/disable|
| `DELETE /api/modbus/devices/{uuid}` | 删除设备;有读数时 409 |
| `GET /api/modbus/devices/{uuid}/metrics` | 该设备 profile 的量目录(key/unit/device_class|
| `GET /api/modbus/devices/{uuid}/latest` | 最新一条读数 payload |
| `GET /api/modbus/devices/{uuid}/readings` | 时间范围读数(start/end/limit),供走势图 |
| `POST /api/modbus/devices/{uuid}/test` | 即时试读(不落库) |
| `GET /api/modbus/profiles` | 列出可用 profile 名 + 描述 |
| `GET /api/expose` | 可暴露实体目录 + 勾选状态 + MQTT/Discovery 状态 |
| `PUT /api/expose` | 设置逐 key 暴露开关 |
| `POST /api/expose/republish` | 手动重发 discovery |
| `POST /api/config/mqtt/test` | 试连 broker 并发布测试消息 |
### 前端视图(M5 新增)
- **侧边栏**:把顶栏改为侧边导航(Home / Records / Energy / Config + 主题切换 + 注销),当前路由高亮,移动端可折叠。
- **`/energy`Energy 视图)**:设备 CRUD(新建/编辑/删除,删除有二次确认;有读数时引导改用禁用);最新读数卡片(字段标签/单位取自 profile metrics);时间序列走势图(Recharts,支持电压/电流/功率/电能,带时间范围选择)。
- **Config 页 Accordion**:各大 config section 可独立折叠/展开;「Home Assistant Expose」面板按设备分组勾选可暴露实体、显示 MQTT/Discovery 连接状态、「重新发布 discovery」按钮。
- SPA 路由新增 `/energy`
## M6 DSMR 接入 / 电价合同 / 实时电费计算 / HA Energy 反哺
M6 在 M5 IoT 基建之上接入 DSMR 实时智能电表数据,建立通用电价合同层,按每 15 分钟算出实际买卖电费并反哺 HA Energy。
### 依赖
M6 **不新增任何 Python 依赖**,复用 M5 已有的 `httpx`Tibber GraphQL)、`paho-mqtt`DSMR 订阅)、`pyyaml`pricing profile 加载)、`apscheduler`(抓价 job、计费 job)。
### DSMR 实时电表接入
订阅 DSMR Reader 的 `dsmr/json` topic(每秒一帧完整 telegram),整帧存为 JSON blob、按 `dsmr_sample_interval_s`(默认 10 秒)降采样落 `dsmr_reading``source_id` 幂等去重)。`dsmr_ingest_enabled`(默认 falseopt-in)。
### 电价合同层
- **YAML profile 定结构**(仓库内,不放数值):`manual.yaml`(固定/双费率:buy_normal/dal、sell_normal/dal、energy_tax、ode、固定费、heffingskorting);`tibber.yaml`(动态:source=tibber_apienergy_tax、sell_adjust
- **`EnergyContract` + `EnergyContractVersion`**(UI 填数值):改价 = 加新版本行(带 `effective_from`),旧版本保留(审计链);一次只有一个 active 合同
- **price strategy**`manual` 用双费率常数(`buy = energy_buy_档 + energy_tax``sell = sell_档`);`tibber``tibber_price.total` 作买价(已含税,demo 确认 `total=energy+tax`),`total energy_tax sell_adjust` 作卖价(卖价残差 `sell_adjust` 默认 0,待真实账单核定)
### 每 15 分钟计量电费(不可变)
APScheduler 1 分钟 tick,取每个闭合 15 分钟窗口的 DSMR 寄存器差值(`delivered_1/2``returned_1/2``_1`=dal/低,`_2`=normal/高,NL 惯例)× 当时合同版本的 strategy 出价,upsert `energy_cost_period`(快照当时价 + `contract_version_id`)。缺价/缺数据时标 `degraded``POST /api/energy/costs/recompute` 显式重算。
日/月/年汇总 = Σnet + 固定费(network_fee + management_fee 按月→天 × 天数)- heffingskorting(按年→天 × 天数),读时计算、不落表。能源税 `energy_tax` 参考值约 0.1108 EUR/kWh2026 第一档含 VAT,待真实账单核定;该值由 UI 填入合同版本,YAML profile 仅声明字段 unit,代码无写死默认数值)。
### Tibber 动态电价
`app/integrations/tibber/client.py` httpx POST GraphQL`priceInfoRange(QUARTER_HOURLY, first=96)`),解析 `startsAt`/`total`/`energy`/`tax`/`level`,按 `starts_at` upsert `tibber_price`(幂等)。启动 + 每小时抓取今明两天 15 分钟价、幂等 upserthourly trigger,确保每日刷新且可补重试);仅当 active 合同 kind=tibber 且 `tibber_api_token` 存在时运行。`POST /api/energy/tibber/test` 试连三态(success 带当前价 / config-error / failed)。
### 反哺 Home Assistant Energy
`_energy_cost_provider` 向 expose 框架注册 4 个实体:`buy_price_now``sell_price_now`(€/kWh sensor)、`import_cost_total``export_revenue_total``total_increasing` monetary,可直接挂 HA Energy 仪表盘)。默认未勾选,在 Expose 面板启用。
### API 端点(M6 新增)
| 端点 | 用途 |
| --- | --- |
| `GET /api/energy/contracts` | 列出合同 + active 标记 |
| `POST /api/energy/contracts` | 新建合同(kind + 首版本值,按 profile 校验)|
| `GET /api/energy/contracts/{id}` | 单个合同 + 版本历史 |
| `PATCH /api/energy/contracts/{id}` | 改名 / 激活 |
| `POST /api/energy/contracts/{id}/versions` | 加新版本(改价,带生效日期)|
| `GET /api/energy/profiles` | 列出 pricing profile 结构(前端按它渲染表单)|
| `GET /api/energy/prices` | 区间价格点(曲线)|
| `GET /api/energy/costs` | 区间 `energy_cost_period`(走势/明细)|
| `GET /api/energy/costs/summary` | 区间汇总(计量电费 + 固定费 − 抵扣)|
| `POST /api/energy/costs/recompute` | 幂等重算 |
| `GET /api/energy/dsmr/latest` | 最新 `dsmr_reading` |
| `POST /api/energy/tibber/test` | 试连 Tibber + 拉当前价,三态 |
DSMR/Tibber 标量配置复用现有 `GET/PUT /api/config`(新增 `dsmr_ingest_enabled``dsmr_mqtt_topic``dsmr_sample_interval_s``tibber_api_token`secret)、`tibber_home_id`)。
### 前端视图(M6 新增,并入 Energy 视图)
- **Contracts Tab**:合同列表 + 新建/编辑(表单按 `/api/energy/profiles` 结构渲染,不 hardcode 字段)+ 激活 + 改价加版本 + 版本历史只读。
- **Prices Tab**15 分钟价格曲线(tibber 动态或 manual 档位),复用 Recharts。
- **Costs Tab**:费用走势/明细 + 汇总卡片(含固定费/抵扣)。
- **Config 页 Tibber 测试**:三态(success/config-error/failed)。
## Config 持久化
@@ -195,11 +445,89 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
这意味着:
- location / poo / app DB 地址仍然属于 bootstrap 范畴
- app DB 地址`APP_DATABASE_URL`仍然属于 bootstrap 范畴
- 运行时可编辑配置主要通过 `app_config` 表持久化
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储
当前已经接入 config 页面的运行时配置包括:
- 基础系统配置
- auth cookie 相关配置
- SMTP 基础配置
- TickTick OAuth 配置
- Home Assistant 配置
- MQTT broker 配置(`MQTT_ENABLED``MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD``MQTT_TLS_ENABLED`
- Home Assistant Discovery 配置(`HA_DISCOVERY_ENABLED``HA_DISCOVERY_PREFIX`
- Modbus 采集配置(`MODBUS_POLLING_ENABLED`
- DSMR 接入配置(`DSMR_INGEST_ENABLED``DSMR_MQTT_TOPIC``DSMR_SAMPLE_INTERVAL_S`
- Tibber 凭据(`TIBBER_API_TOKEN`secret)、`TIBBER_HOME_ID`
其中 SMTP password 与其他 secret 字段一致:
- 页面不明文回显
- 留空提交时保留旧值
- 用于测试发信与自动通知时不会写入响应
## Public IPv4 Monitor
当前系统已经提供最小可用的 public IPv4 monitor
- 使用单一 provider 检查当前公网 IPv4
- 将状态与变化历史持久化到 app DB
- 提供受保护的手动检查入口:`GET /public-ip/check`
- 启动时注册 APScheduler job,默认每 4 小时检查一次
当前 app DB 中与此功能相关的新表:
- `public_ip_state`
- `public_ip_history`
状态语义如下:
- `first_seen`:首次发现当前公网 IPv4
- `unchanged`:与上次状态一致
- `changed`:公网 IPv4 发生变化
- `error`:provider 请求失败或返回无效值
## SMTP 与邮件通知
当前系统已经提供最小可用的 SMTP 能力:
- SMTP 配置可在 React SPA `/config` 页面填写并保存到 `app_config`(通过 `PUT /api/config`
- 可通过 config 页面发送测试邮件(`POST /api/config/smtp/test`
- 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>`
当前 SMTP 配置项包括:
- `SMTP_ENABLED`
- `SMTP_HOST`
- `SMTP_PORT`
- `SMTP_USERNAME`
- `SMTP_PASSWORD`
- `SMTP_FROM_NAME`
- `SMTP_FROM_ADDRESS`
- `SMTP_TO_ADDRESS`
- `SMTP_USE_STARTTLS`
当前 public IPv4 monitor 已与 SMTP sender 接通,但只处理一个很小的通知场景:
- 当 public IPv4 check 结果为 `changed` 时,自动发送一封英文纯文本邮件
以下情况不会发邮件:
- `first_seen`
- `unchanged`
- `error`
当前通知邮件内容固定,不提供模板系统,正文会包含:
- previous IP
- current IP
- detected time
手动测试时,如果需要再次模拟一次 IP 变化,可以临时修改 `public_ip_state.current_ipv4` 为一个保留测试地址,然后再次调用 `GET /public-ip/check`
## OpenAPI
可使用下面的脚本重新导出当前 API 定义:
@@ -217,10 +545,26 @@ python scripts/export_openapi.py
当前默认 Compose 服务名为 `app`,容器名固定为 `home-automation-app`
启动方式
当前 Compose 分成两层
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
本地开发启动方式(显式叠加 dev 层):
```bash
docker compose up -d --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件:
```bash
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
```
持续查看日志:
@@ -238,6 +582,10 @@ docker compose logs -f app
- registry`code.wanderingbadger.dev`
- image`code.wanderingbadger.dev/<owner>/<repo>`
`docker-compose.yml` 中生产默认使用的 app image 当前为:
- `code.wanderingbadger.dev/tliu93/home-automation:latest`
当前 workflow 不再把 image name 硬编码到特定 user package 路径,而是直接使用当前仓库标识生成镜像路径:
- `code.wanderingbadger.dev/${github.repository}:${tag}`
@@ -269,9 +617,16 @@ pytest
当前测试包含:
- app 基本启动测试
- `/status` endpoint 测试
- 登录 / session 基础流程测试
- app 启动与 `/status` 检查
- 登录 / session / 鉴权流程
- runtime config 读写
- public IPv4 monitor
- SMTP 配置与测试发信
- location / poo recorder 端点
- Home Assistant inbound 集成
- TickTick OAuth
- 部署与迁移(`run_migrations`
- legacy 数据迁移脚本(`migrate_legacy_data`
## OpenAPI 导出
+16 -3
View File
@@ -3,10 +3,23 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.auth_db import AuthBase
from app.config import get_settings
from app.db import Base
from app.models.config import AppConfigEntry # noqa: F401
from app.models.auth import AuthSession, AuthUser # noqa: F401
from app.models.auth import AuthSession, AuthUser, RecoveryCode # noqa: F401
from app.models.auth_throttle import LoginThrottle # noqa: F401
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
from app.models.location import Location # noqa: F401
from app.models.poo import PooRecord # noqa: F401
from app.models.modbus import ModbusDevice, ModbusReading # noqa: F401
from app.models.expose import ExposedEntityToggle # noqa: F401
from app.models.energy import ( # noqa: F401
DsmrReading,
EnergyContract,
EnergyContractVersion,
TibberPrice,
EnergyCostPeriod,
)
config = context.config
@@ -18,7 +31,7 @@ configured_url = config.get_main_option("sqlalchemy.url")
if not configured_url or configured_url == "sqlite:///./data/app.db":
config.set_main_option("sqlalchemy.url", settings.app_database_url)
target_metadata = AuthBase.metadata
target_metadata = Base.metadata
def run_migrations_offline() -> None:
@@ -0,0 +1,55 @@
"""public ip monitor tables
Revision ID: 20260429_05_public_ip_monitor
Revises: 20260420_04_app_config_table
Create Date: 2026-04-29 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260429_05_public_ip_monitor"
down_revision: Union[str, None] = "20260420_04_app_config_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"public_ip_history",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("ipv4", sa.String(length=45), nullable=False),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("change_type", sa.String(length=32), nullable=False),
sa.Column("provider", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_public_ip_history_observed_at",
"public_ip_history",
["observed_at"],
unique=False,
)
op.create_table(
"public_ip_state",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("current_ipv4", sa.String(length=45), nullable=False),
sa.Column("previous_ipv4", sa.String(length=45), nullable=True),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_changed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_check_status", sa.String(length=32), nullable=False),
sa.Column("last_check_error", sa.String(length=255), nullable=True),
sa.Column("last_provider", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("public_ip_state")
op.drop_index("ix_public_ip_history_observed_at", table_name="public_ip_history")
op.drop_table("public_ip_history")
@@ -0,0 +1,43 @@
"""merge location and poo_records tables into app chain
Revision ID: 20260611_06_merge_location_poo_tables
Revises: 20260429_05_public_ip_monitor
Create Date: 2026-06-11 00:00:01.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260611_06_merge_location_poo_tables"
down_revision: Union[str, None] = "20260429_05_public_ip_monitor"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"location",
sa.Column("person", sa.Text(), nullable=False),
sa.Column("datetime", sa.Text(), nullable=False),
sa.Column("latitude", sa.REAL(), nullable=False),
sa.Column("longitude", sa.REAL(), nullable=False),
sa.Column("altitude", sa.REAL(), nullable=True),
sa.PrimaryKeyConstraint("person", "datetime"),
)
op.create_table(
"poo_records",
sa.Column("timestamp", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("latitude", sa.REAL(), nullable=False),
sa.Column("longitude", sa.REAL(), nullable=False),
sa.PrimaryKeyConstraint("timestamp"),
)
def downgrade() -> None:
op.drop_table("poo_records")
op.drop_table("location")
@@ -0,0 +1,43 @@
"""add auth_login_throttle table for exponential back-off throttling
Revision ID: 20260621_07_auth_login_throttle
Revises: 20260611_06_merge_location_poo_tables
Create Date: 2026-06-21 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260621_07_auth_login_throttle"
down_revision: Union[str, None] = "20260611_06_merge_location_poo_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"auth_login_throttle",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("scope", sa.String(length=16), nullable=False),
sa.Column("failures", sa.Integer(), nullable=False),
sa.Column("first_failed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_failed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("next_allowed_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
)
op.create_index(
"ix_auth_login_throttle_scope_key",
"auth_login_throttle",
["scope", "key"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_auth_login_throttle_scope_key", table_name="auth_login_throttle")
op.drop_table("auth_login_throttle")
+61
View File
@@ -0,0 +1,61 @@
"""add TOTP fields to auth_users and create auth_recovery_code table
Revision ID: 20260621_08_totp
Revises: 20260621_07_auth_login_throttle
Create Date: 2026-06-21 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260621_08_totp"
down_revision: Union[str, None] = "20260621_07_auth_login_throttle"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add totp_secret (nullable) to auth_users — existing rows get NULL, which is correct.
op.add_column("auth_users", sa.Column("totp_secret", sa.String(length=64), nullable=True))
# Add totp_enabled (NOT NULL) with server_default="0" so existing rows default to false.
op.add_column(
"auth_users",
sa.Column(
"totp_enabled",
sa.Boolean(),
nullable=False,
server_default="0",
),
)
# Create auth_recovery_code table for one-time TOTP recovery codes.
op.create_table(
"auth_recovery_code",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("code_hash", sa.String(length=255), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["auth_users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_auth_recovery_code_user_id"),
"auth_recovery_code",
["user_id"],
unique=False,
)
def downgrade() -> None:
# Drop auth_recovery_code table first (has FK to auth_users).
op.drop_index(op.f("ix_auth_recovery_code_user_id"), table_name="auth_recovery_code")
op.drop_table("auth_recovery_code")
# Drop the two TOTP columns from auth_users.
# Use batch_alter_table for SQLite compatibility (alembic's portable column-drop path).
with op.batch_alter_table("auth_users") as batch_op:
batch_op.drop_column("totp_enabled")
batch_op.drop_column("totp_secret")
@@ -0,0 +1,81 @@
"""add modbus_device and modbus_reading tables
Revision ID: 20260622_09_modbus_tables
Revises: 20260621_08_totp
Create Date: 2026-06-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260622_09_modbus_tables"
down_revision: Union[str, None] = "20260621_08_totp"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# modbus_device — deployment/configurable metadata for each polled device.
op.create_table(
"modbus_device",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", sa.String(length=36), nullable=False),
sa.Column("friendly_name", sa.String(length=255), nullable=False),
sa.Column("transport", sa.String(length=16), nullable=False),
sa.Column("host", sa.String(length=255), nullable=False),
sa.Column("port", sa.Integer(), nullable=False),
sa.Column("unit_id", sa.Integer(), nullable=False),
sa.Column("profile", sa.String(length=64), nullable=False),
sa.Column("poll_interval_s", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("last_poll_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_poll_ok", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("uuid", name="uq_modbus_device_uuid"),
)
# modbus_reading — generic telemetry, one row per device per poll cycle.
op.create_table(
"modbus_reading",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("device_id", sa.Integer(), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.ForeignKeyConstraint(
["device_id"],
["modbus_device.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
)
# Individual index on recorded_at (from the ORM-level index=True).
op.create_index(
"ix_modbus_reading_recorded_at",
"modbus_reading",
["recorded_at"],
unique=False,
)
# Composite index for efficient time-range queries per device.
op.create_index(
"ix_modbus_reading_device_recorded",
"modbus_reading",
["device_id", "recorded_at"],
unique=False,
)
def downgrade() -> None:
# Drop the reading table first (it has a FK referencing modbus_device).
op.drop_index("ix_modbus_reading_device_recorded", table_name="modbus_reading")
op.drop_index("ix_modbus_reading_recorded_at", table_name="modbus_reading")
op.drop_table("modbus_reading")
# Drop the device table.
op.drop_table("modbus_device")
@@ -0,0 +1,44 @@
"""add exposed_entity_toggle table
Revision ID: 20260622_10_exposed_entities
Revises: 20260622_09_modbus_tables
Create Date: 2026-06-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260622_10_exposed_entities"
down_revision: Union[str, None] = "20260622_09_modbus_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# exposed_entity_toggle — per-entity on/off switch for MQTT / HA Discovery.
op.create_table(
"exposed_entity_toggle",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key", name="uq_exposed_entity_toggle_key"),
)
# Index on key for fast single-key lookups (toggle by key).
op.create_index(
"ix_exposed_entity_toggle_key",
"exposed_entity_toggle",
["key"],
unique=True,
)
def downgrade() -> None:
# Drop index then table — only removes what this revision created.
op.drop_index("ix_exposed_entity_toggle_key", table_name="exposed_entity_toggle")
op.drop_table("exposed_entity_toggle")
@@ -0,0 +1,132 @@
"""add energy pricing and DSMR metering tables
Revision ID: 20260623_11_energy_tables
Revises: 20260622_10_exposed_entities
Create Date: 2026-06-23 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260623_11_energy_tables"
down_revision: Union[str, None] = "20260622_10_exposed_entities"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# dsmr_reading — raw DSMR telegram blobs (10-second down-sampled).
# No device table: single P1 smart meter; a ``source`` column can be added later
# if a second meter is introduced.
op.create_table(
"dsmr_reading",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("source_id", name="uq_dsmr_reading_source_id"),
)
op.create_index(
"ix_dsmr_reading_recorded_at",
"dsmr_reading",
["recorded_at"],
unique=False,
)
# energy_contract — contract head (manual or tibber, one active at a time).
op.create_table(
"energy_contract",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("currency", sa.String(length=8), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# energy_contract_version — versioned pricing values; append-only for auditability.
# Must be created after energy_contract because of the FK dependency.
op.create_table(
"energy_contract_version",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("contract_id", sa.Integer(), nullable=False),
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("values", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["contract_id"],
["energy_contract.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
)
# tibber_price — cached Tibber 15-minute spot prices (immutable once fetched).
op.create_table(
"tibber_price",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("resolution", sa.String(length=32), nullable=False),
sa.Column("energy", sa.Float(), nullable=False),
sa.Column("tax", sa.Float(), nullable=False),
sa.Column("total", sa.Float(), nullable=False),
sa.Column("level", sa.String(length=32), nullable=True),
sa.Column("currency", sa.String(length=8), nullable=False),
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("starts_at", name="uq_tibber_price_starts_at"),
)
# energy_cost_period — computed 15-minute billing periods (immutable snapshot).
# Must be created after energy_contract_version because of the FK dependency.
op.create_table(
"energy_cost_period",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("d1_kwh", sa.Float(), nullable=False),
sa.Column("d2_kwh", sa.Float(), nullable=False),
sa.Column("r1_kwh", sa.Float(), nullable=False),
sa.Column("r2_kwh", sa.Float(), nullable=False),
sa.Column("import_cost", sa.Float(), nullable=False),
sa.Column("export_revenue", sa.Float(), nullable=False),
sa.Column("net_cost", sa.Float(), nullable=False),
sa.Column("currency", sa.String(length=8), nullable=False),
sa.Column("pricing", sa.JSON(), nullable=False),
sa.Column("contract_version_id", sa.Integer(), nullable=True),
sa.Column("degraded", sa.Boolean(), nullable=False),
sa.Column("computed_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["contract_version_id"],
["energy_contract_version.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("period_start", name="uq_energy_cost_period_period_start"),
)
def downgrade() -> None:
# Drop tables in reverse dependency order: tables with FKs first.
# energy_cost_period has a FK to energy_contract_version — drop it first.
op.drop_table("energy_cost_period")
# tibber_price has no FK dependencies on our new tables.
op.drop_table("tibber_price")
# energy_contract_version has a FK to energy_contract — drop it before energy_contract.
op.drop_table("energy_contract_version")
# energy_contract has no FK dependencies on our new tables.
op.drop_table("energy_contract")
# dsmr_reading has no FK dependencies.
op.drop_index("ix_dsmr_reading_recorded_at", table_name="dsmr_reading")
op.drop_table("dsmr_reading")
@@ -0,0 +1,58 @@
"""decouple dsmr_reading from the telegram id
The DSMR Reader's own ``id`` field is auto-incrementing but is known to overflow
and require a manual reset to zero (a long-standing DSMR firmware quirk). If we
keep a UNIQUE constraint on ``source_id`` (the telegram id) and use it for
idempotency, an overflow/reset would make legitimately-new telegrams collide with
old ids and be silently dropped as "duplicates" — data loss.
This migration removes that coupling:
- Drops the UNIQUE constraint on ``source_id`` (the column is kept as a plain,
nullable reference value; it is no longer relied upon for uniqueness/dedup).
- Makes ``recorded_at`` (the telegram timestamp) UNIQUE instead — a single P1
meter emits one telegram per timestamp, so this is a robust, telegram-id-
independent idempotency key. The table's own autoincrement ``id`` PK remains
the stable internal identity.
Revision ID: 20260624_12_dsmr_decouple_telegram_id
Revises: 20260623_11_energy_tables
Create Date: 2026-06-24 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260624_12_dsmr_decouple_telegram_id"
down_revision: Union[str, None] = "20260623_11_energy_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite cannot ALTER away a constraint in place, so recreate the table via
# Alembic's batch mode. The table is recreated and rows are copied; existing
# data (if any) is preserved. recorded_at must be unique for this to succeed
# — a single meter never emits two telegrams at the exact same timestamp.
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
# Old non-unique index on recorded_at is replaced by the unique constraint.
batch_op.drop_index("ix_dsmr_reading_recorded_at")
# Telegram id is no longer a uniqueness/idempotency key.
batch_op.drop_constraint("uq_dsmr_reading_source_id", type_="unique")
# Timestamp becomes the telegram-id-independent dedup key.
batch_op.create_unique_constraint(
"uq_dsmr_reading_recorded_at", ["recorded_at"]
)
def downgrade() -> None:
with op.batch_alter_table("dsmr_reading", schema=None) as batch_op:
batch_op.drop_constraint("uq_dsmr_reading_recorded_at", type_="unique")
batch_op.create_unique_constraint(
"uq_dsmr_reading_source_id", ["source_id"]
)
batch_op.create_index(
"ix_dsmr_reading_recorded_at", ["recorded_at"], unique=False
)
@@ -0,0 +1,238 @@
"""add meter table and energy_cost_period.meter_id
Introduces the ``meter`` table (one row per physical meter installation epoch)
and a nullable FK column ``energy_cost_period.meter_id`` that attributes each
billing period to a specific physical meter.
**Backfill logic (§3.7 of the M7 design doc)**:
If the database already contains any ``dsmr_reading`` or
``energy_cost_period`` rows, one initial ``meter`` row is created:
label = "Initial meter"
commodity = "electricity"
started_at = earliest dsmr_reading.recorded_at
(or, if none, earliest energy_cost_period.period_start,
or, if still none, the migration timestamp)
ended_at = NULL (still active)
reason = "initial"
All existing ``energy_cost_period`` rows are then back-filled with that
initial meter's id.
**Idempotency**: the backfill is guarded with a check for any existing
``meter`` row whose ``reason = 'initial'`` and ``commodity = 'electricity'``
and ``ended_at IS NULL``, so repeating the upgrade does not create duplicate
meters or overwrite already-filled meter_id values.
**Audit (on-non-degraded periods only)**: after backfilling, the number of
non-degraded ``energy_cost_period`` rows with ``meter_id IS NULL`` must be
zero; if it is not, the migration raises a ``RuntimeError`` and rolls back.
**Data safety**: this migration is additive only — no existing rows are
deleted or overwritten; it only creates a new table, adds a nullable column,
and back-fills that column.
Revision ID: 20260625_13_meter_table
Revises: 20260624_12_dsmr_decouple_telegram_id
Create Date: 2026-06-25 00:00:00.000000
"""
from datetime import datetime, timezone
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260625_13_meter_table"
down_revision: Union[str, None] = "20260624_12_dsmr_decouple_telegram_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ------------------------------------------------------------------ #
# 1. Create the meter table. #
# ------------------------------------------------------------------ #
op.create_table(
"meter",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("commodity", sa.String(length=32), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reason", sa.String(length=64), nullable=False),
sa.Column("note", sa.String(length=1024), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# ------------------------------------------------------------------ #
# 2. Add meter_id column to energy_cost_period (nullable FK). #
# ------------------------------------------------------------------ #
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.add_column(
sa.Column("meter_id", sa.Integer(), nullable=True)
)
batch_op.create_foreign_key(
"fk_energy_cost_period_meter_id",
"meter",
["meter_id"],
["id"],
ondelete="RESTRICT",
)
# ------------------------------------------------------------------ #
# 3. Backfill initial meter (idempotent). #
# ------------------------------------------------------------------ #
conn = op.get_bind()
# Check if there is already an initial meter (idempotency guard).
existing_initial = conn.execute(
sa.text(
"SELECT id FROM meter "
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
"LIMIT 1"
)
).fetchone()
if existing_initial is not None:
# Already backfilled — nothing to do.
return
# Determine whether there is any historical data to create a meter for.
has_readings = conn.execute(
sa.text("SELECT 1 FROM dsmr_reading LIMIT 1")
).fetchone()
has_periods = conn.execute(
sa.text("SELECT 1 FROM energy_cost_period LIMIT 1")
).fetchone()
if not has_readings and not has_periods:
# Empty database: no historical data, so no initial meter is needed.
# meter_id will remain NULL on any future rows until T02 service layer
# starts populating it.
return
# Determine started_at: earliest dsmr_reading.recorded_at, falling back to
# earliest energy_cost_period.period_start, and finally to now().
earliest_reading_row = conn.execute(
sa.text("SELECT MIN(recorded_at) AS ts FROM dsmr_reading")
).fetchone()
earliest_period_row = conn.execute(
sa.text("SELECT MIN(period_start) AS ts FROM energy_cost_period")
).fetchone()
started_at_value: datetime | None = None
if earliest_reading_row and earliest_reading_row[0] is not None:
# SQLite returns ISO strings for datetime columns; parse to datetime.
raw = earliest_reading_row[0]
started_at_value = _parse_sqlite_datetime(raw)
if started_at_value is None and earliest_period_row and earliest_period_row[0] is not None:
raw = earliest_period_row[0]
started_at_value = _parse_sqlite_datetime(raw)
if started_at_value is None:
started_at_value = datetime.now(tz=timezone.utc)
now_utc = datetime.now(tz=timezone.utc)
# Insert the initial meter row.
conn.execute(
sa.text(
"INSERT INTO meter (label, commodity, started_at, ended_at, reason, note, created_at) "
"VALUES (:label, :commodity, :started_at, NULL, :reason, NULL, :created_at)"
),
{
"label": "Initial meter",
"commodity": "electricity",
"started_at": _iso(started_at_value),
"reason": "initial",
"created_at": _iso(now_utc),
},
)
# Retrieve the newly created meter id.
meter_row = conn.execute(
sa.text(
"SELECT id FROM meter "
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
"LIMIT 1"
)
).fetchone()
assert meter_row is not None, "Initial meter row not found after insert"
meter_id: int = meter_row[0]
# Back-fill all existing energy_cost_period rows that have meter_id IS NULL.
conn.execute(
sa.text(
"UPDATE energy_cost_period SET meter_id = :mid WHERE meter_id IS NULL"
),
{"mid": meter_id},
)
# ------------------------------------------------------------------ #
# 4. Audit: verify all non-degraded periods have a meter_id. #
# ------------------------------------------------------------------ #
unmatched_row = conn.execute(
sa.text(
"SELECT COUNT(*) FROM energy_cost_period "
"WHERE meter_id IS NULL AND degraded = 0"
)
).fetchone()
unmatched_count: int = unmatched_row[0] if unmatched_row else 0
if unmatched_count != 0:
raise RuntimeError(
f"Meter backfill audit failed: {unmatched_count} non-degraded "
"energy_cost_period row(s) still have meter_id IS NULL after backfill. "
"Migration aborted to protect data integrity."
)
def downgrade() -> None:
# Remove the FK column from energy_cost_period first (references meter).
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.drop_constraint("fk_energy_cost_period_meter_id", type_="foreignkey")
batch_op.drop_column("meter_id")
# Drop the meter table.
op.drop_table("meter")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_sqlite_datetime(value: str | datetime) -> datetime:
"""Parse a SQLite datetime value into an aware UTC datetime.
SQLite stores datetimes as ISO 8601 strings. SQLAlchemy may return them
as plain strings or as naive datetimes (no tzinfo) depending on the driver
and column declaration. This helper normalises both forms to an aware UTC
``datetime``.
"""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
# String form — strip trailing Z or +00:00 variants, then attach UTC.
s = str(value).strip()
for suffix in ("+00:00", "Z", " UTC"):
if s.endswith(suffix):
s = s[: -len(suffix)]
# SQLite uses space as the T separator in some formats.
s = s.replace(" ", "T")
try:
dt = datetime.fromisoformat(s)
except ValueError:
# Fallback: strip subseconds if present to handle unusual formats.
dt = datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
return dt.replace(tzinfo=timezone.utc)
def _iso(dt: datetime) -> str:
"""Serialise a datetime to an ISO 8601 string for SQLite storage."""
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt.strftime("%Y-%m-%dT%H:%M:%S")
@@ -0,0 +1,104 @@
"""add uuid column to meter table
Adds a stable ``uuid`` (UUID v4 string) column to the ``meter`` table so that
each meter epoch has a durable identity anchor suitable for use as an HA
Discovery ``unique_id``.
**Migration strategy (SQLite-safe)**:
SQLite does not support adding a NOT NULL + UNIQUE column to a non-empty table
in a single ``ALTER TABLE ADD COLUMN`` statement (adding a NOT NULL column
without a default value is rejected if the table already has rows). The
safe approach used here is:
1. Add ``uuid`` as a **nullable** column (SQLite allows this).
2. **Back-fill** every existing ``meter`` row with a distinct ``str(uuid4())``
value. Each row gets its *own* random UUID — not a shared value — so the
subsequent UNIQUE constraint is satisfied.
3. Use ``batch_alter_table`` (which re-creates the table under the hood in
SQLite) to alter the column to ``NOT NULL`` and add a UNIQUE constraint.
**Idempotency**: only rows where ``uuid IS NULL`` are back-filled; rows that
already have a uuid (e.g. from a repeated upgrade after a partial failure) are
left untouched.
**Audit**: after back-fill, the count of rows with ``uuid IS NULL`` must be
exactly zero; if not, the migration raises ``RuntimeError`` and rolls back.
**Data safety**: this migration is additive only — no existing rows are deleted
or overwritten; it only adds a new column and fills it in.
Revision ID: 20260625_14_meter_uuid
Revises: 20260625_13_meter_table
Create Date: 2026-06-25 00:00:00.000000
"""
import uuid as _uuid
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260625_14_meter_uuid"
down_revision: Union[str, None] = "20260625_13_meter_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# ------------------------------------------------------------------ #
# 1. Add uuid as a nullable column. #
# ------------------------------------------------------------------ #
with op.batch_alter_table("meter", schema=None) as batch_op:
batch_op.add_column(
sa.Column("uuid", sa.String(length=36), nullable=True)
)
# ------------------------------------------------------------------ #
# 2. Back-fill: assign a distinct UUID to every row that has #
# uuid IS NULL. Each row gets its own random value so that the #
# subsequent UNIQUE constraint is satisfied. #
# ------------------------------------------------------------------ #
rows = conn.execute(sa.text("SELECT id FROM meter WHERE uuid IS NULL")).fetchall()
for (meter_id,) in rows:
new_uuid = str(_uuid.uuid4())
conn.execute(
sa.text("UPDATE meter SET uuid = :uuid WHERE id = :mid"),
{"uuid": new_uuid, "mid": meter_id},
)
# ------------------------------------------------------------------ #
# 3. Audit: verify no rows remain with uuid IS NULL. #
# ------------------------------------------------------------------ #
null_count_row = conn.execute(
sa.text("SELECT COUNT(*) FROM meter WHERE uuid IS NULL")
).fetchone()
null_count: int = null_count_row[0] if null_count_row else 0
if null_count != 0:
raise RuntimeError(
f"meter.uuid back-fill audit failed: {null_count} meter row(s) still have "
"uuid IS NULL after back-fill. Migration aborted to protect data integrity."
)
# ------------------------------------------------------------------ #
# 4. Alter column to NOT NULL + UNIQUE (requires batch on SQLite). #
# batch_alter_table re-creates the table, so the UNIQUE constraint #
# and NOT NULL are applied atomically. #
# ------------------------------------------------------------------ #
with op.batch_alter_table("meter", schema=None) as batch_op:
batch_op.alter_column(
"uuid",
existing_type=sa.String(length=36),
nullable=False,
)
batch_op.create_unique_constraint("uq_meter_uuid", ["uuid"])
def downgrade() -> None:
# Drop the UNIQUE constraint and the uuid column (batch on SQLite).
with op.batch_alter_table("meter", schema=None) as batch_op:
batch_op.drop_constraint("uq_meter_uuid", type_="unique")
batch_op.drop_column("uuid")
-37
View File
@@ -1,37 +0,0 @@
[alembic]
script_location = alembic_location
prepend_sys_path = .
path_separator = os
sqlalchemy.url = sqlite:///./data/locationRecorder.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
-2
View File
@@ -1,2 +0,0 @@
This directory contains the Alembic migration environment for the Python rewrite skeleton.
-48
View File
@@ -1,48 +0,0 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import get_settings
from app.models import Location # noqa: F401
from app.models.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
configured_url = config.get_main_option("sqlalchemy.url")
if not configured_url or configured_url == "sqlite:///./data/locationRecorder.db":
config.set_main_option("sqlalchemy.url", settings.location_database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-26
View File
@@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
-1
View File
@@ -1 +0,0 @@
@@ -1,33 +0,0 @@
"""location baseline
Revision ID: 20260419_01_location_baseline
Revises:
Create Date: 2026-04-19 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260419_01_location_baseline"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"location",
sa.Column("person", sa.Text(), nullable=False),
sa.Column("datetime", sa.Text(), nullable=False),
sa.Column("latitude", sa.Float(), nullable=False),
sa.Column("longitude", sa.Float(), nullable=False),
sa.Column("altitude", sa.Float(), nullable=True),
sa.PrimaryKeyConstraint("person", "datetime"),
)
def downgrade() -> None:
op.drop_table("location")
-37
View File
@@ -1,37 +0,0 @@
[alembic]
script_location = alembic_poo
prepend_sys_path = .
path_separator = os
sqlalchemy.url = sqlite:///./data/pooRecorder.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
-48
View File
@@ -1,48 +0,0 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import get_settings
from app.models.poo import PooRecord # noqa: F401
from app.poo_db import PooBase
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
configured_url = config.get_main_option("sqlalchemy.url")
if not configured_url or configured_url == "sqlite:///./data/pooRecorder.db":
config.set_main_option("sqlalchemy.url", settings.poo_database_url)
target_metadata = PooBase.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -1,32 +0,0 @@
"""poo baseline
Revision ID: 20260420_01_poo_baseline
Revises:
Create Date: 2026-04-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260420_01_poo_baseline"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"poo_records",
sa.Column("timestamp", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("latitude", sa.Float(), nullable=False),
sa.Column("longitude", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("timestamp"),
)
def downgrade() -> None:
op.drop_table("poo_records")
View File
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_db
from app.integrations.mqtt import MQTT_SETTINGS_KEYS, mqtt_manager
from app.schemas.config import (
ConfigField,
ConfigResponse,
ConfigSection,
ConfigUpdateRequest,
ConfigUpdateResponse,
MqttTestResponse,
SmtpTestResponse,
)
from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-config"])
def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]:
result = []
for section in sections_raw:
fields = [ConfigField(**f) for f in section["fields"]]
result.append(ConfigSection(name=section["name"], fields=fields))
return result
@router.get("/config", response_model=ConfigResponse)
def get_config(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
) -> ConfigResponse:
"""Return all configuration sections. Secret field values are masked (empty string)."""
sections_raw = build_config_sections(db, settings)
return ConfigResponse(sections=_sections_from_raw(sections_raw))
@router.put("/config", response_model=ConfigUpdateResponse)
def put_config(
body: ConfigUpdateRequest,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ConfigUpdateResponse:
"""
Save configuration updates.
- Blank secret value keeps the existing stored value (no change).
- Invalid values return 422 and nothing is written to the database.
- If MQTT-related settings changed, the MQTT client reconnects automatically.
"""
# Detect whether any MQTT-related key is being submitted (non-secret change
# or non-blank secret change) so we know to reconnect after saving.
mqtt_keys_submitted = any(k.lower() in MQTT_SETTINGS_KEYS for k in body.updates)
try:
save_config_updates(db, body.updates, settings)
except ConfigSaveError as exc:
logger.warning("Rejected config update via API: %s", exc)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="invalid config submission",
) from exc
# Re-read settings after save (save_config_updates clears the settings cache).
# Use build_runtime_settings so the reconnect picks up DB-stored values, not
# just the bootstrap env (otherwise a broker configured via the UI is ignored).
from app.services.config_page import build_runtime_settings
refreshed_settings = build_runtime_settings(db, get_settings())
# Reconnect MQTT client if any MQTT setting was updated.
if mqtt_keys_submitted:
logger.info("MQTT settings changed — triggering reconnect.")
mqtt_manager.reconnect(refreshed_settings)
# Re-apply the DSMR subscription so enabling/disabling DSMR ingest (or changing
# its topic / sample interval) takes effect immediately, without an app restart.
# Done after any MQTT reconnect so it operates on the current client.
from app.services.dsmr_ingest import apply_dsmr_subscription
apply_dsmr_subscription(refreshed_settings)
sections_raw = build_config_sections(db, refreshed_settings)
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
@router.post(
"/config/smtp/test",
responses={
200: {"model": SmtpTestResponse},
400: {"model": SmtpTestResponse},
502: {"model": SmtpTestResponse},
},
)
def post_smtp_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""
Send a test SMTP email using the current runtime settings.
Returns a structured result indicating success or the category of failure.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
SMTP credentials are never echoed in the response.
"""
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test rejected due to configuration: %s", exc)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"result": "config-error", "message": str(exc)},
)
except EmailDeliveryError as exc:
logger.warning("SMTP test delivery failed: %s", exc)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"result": "failed", "message": str(exc)},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"result": "success", "message": "Test email sent successfully."},
)
# ---------------------------------------------------------------------------
# POST /api/config/mqtt/test — M5-T10
# ---------------------------------------------------------------------------
class _MqttConfigurationError(ValueError):
"""Raised when MQTT settings are incomplete or disabled."""
class _MqttConnectionError(RuntimeError):
"""Raised when MQTT broker connection or publish fails."""
@router.post(
"/config/mqtt/test",
responses={
200: {"model": MqttTestResponse},
400: {"model": MqttTestResponse},
502: {"model": MqttTestResponse},
},
)
def post_mqtt_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""
Test MQTT broker connectivity by attempting to connect and publishing a
test message to ``<ha_discovery_prefix>/home-automation/test``.
The message is visible in MQTT Explorer (or any subscriber) so users can
confirm the full broker publish path is working.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (not configured)
- 502 { "result": "failed", "message": ... } (connection/publish error)
MQTT credentials are never echoed in the response.
"""
try:
_run_mqtt_test(settings)
except _MqttConfigurationError as exc:
logger.warning("MQTT test rejected due to configuration: %s", exc)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"result": "config-error", "message": str(exc)},
)
except _MqttConnectionError as exc:
logger.warning("MQTT test connection/publish failed: %s", exc)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"result": "failed", "message": str(exc)},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"result": "success",
"message": (
f"Test message published to "
f"{settings.ha_discovery_prefix}/home-automation/test."
),
},
)
def _run_mqtt_test(settings: Settings) -> None:
"""Attempt a transient MQTT connection and publish a test message.
Raises
------
_MqttConfigurationError
When MQTT is not enabled or broker host is not configured.
_MqttConnectionError
When the broker is unreachable or the publish fails.
"""
import socket
import paho.mqtt.client as mqtt
if not settings.mqtt_broker_host:
raise _MqttConfigurationError("MQTT broker host is not configured.")
test_topic = f"{settings.ha_discovery_prefix}/home-automation/test"
test_payload = '{"source": "home-automation", "event": "mqtt_test"}'
connected_event = __import__("threading").Event()
published_event = __import__("threading").Event()
connect_error: list[str] = []
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id="home-automation-test",
)
def _on_connect(
_client: mqtt.Client,
_userdata: object,
_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
if reason_code.is_failure:
connect_error.append(f"Broker refused connection: {reason_code}")
connected_event.set()
def _on_publish(
_client: mqtt.Client,
_userdata: object,
_mid: int,
_reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
published_event.set()
client.on_connect = _on_connect
client.on_publish = _on_publish
if settings.mqtt_tls_enabled:
try:
client.tls_set()
except Exception as exc:
raise _MqttConnectionError(f"TLS setup failed: {exc}") from exc
if settings.mqtt_username:
client.username_pw_set(
username=settings.mqtt_username,
password=settings.mqtt_password or None,
)
client.loop_start()
try:
try:
client.connect(
host=settings.mqtt_broker_host,
port=settings.mqtt_broker_port,
keepalive=10,
)
except (OSError, socket.error) as exc:
# Sanitise: ensure password never leaks into the error message.
msg = _sanitize_mqtt_error(str(exc), settings.mqtt_password)
raise _MqttConnectionError(f"Cannot reach broker: {msg}") from exc
# Wait up to 5 s for connection acknowledgement.
if not connected_event.wait(timeout=5):
raise _MqttConnectionError("Broker connection timed out (5 s).")
if connect_error:
raise _MqttConnectionError(connect_error[0])
# Publish test message.
client.publish(test_topic, payload=test_payload, qos=1, retain=False)
# Wait up to 5 s for the publish ACK (QoS 1).
if not published_event.wait(timeout=5):
raise _MqttConnectionError("Publish timed out (5 s) — broker reachable but no ACK.")
finally:
try:
client.disconnect()
except Exception:
pass
client.loop_stop()
def _sanitize_mqtt_error(message: str, password: str | None) -> str:
"""Replace *password* in *message* with ``[redacted]``."""
if password:
return message.replace(password, "[redacted]")
return message
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.location import Location
from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState
from app.schemas.data import (
LocationRecord,
LocationUpdateRequest,
LocationsResponse,
PooRecord as PooRecordSchema,
PooResponse,
PooUpdateRequest,
PublicIPHistorySchema,
PublicIPResponse,
PublicIPStateSchema,
)
from app.services.auth import AuthenticatedSession
from app.services.location import delete_location, update_location
from app.services.poo import delete_poo_record, update_poo_record
router = APIRouter(prefix="/api", tags=["api-data"])
@router.get("/locations", response_model=LocationsResponse)
def get_locations(
limit: int = Query(default=1000, ge=1, le=5000),
offset: int = Query(default=0, ge=0),
start: str | None = Query(default=None),
end: str | None = Query(default=None),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> LocationsResponse:
"""
Return location records with optional time-window filtering and pagination.
- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds.
- Results are ordered by ``datetime`` ascending.
- ``limit`` is capped at 5000 to prevent full-table exports.
"""
stmt = select(Location)
if start is not None:
stmt = stmt.where(Location.datetime >= start)
if end is not None:
stmt = stmt.where(Location.datetime <= end)
stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit)
rows = db.execute(stmt).scalars().all()
items = [
LocationRecord(
person=row.person,
datetime=row.datetime,
latitude=row.latitude,
longitude=row.longitude,
altitude=row.altitude,
)
for row in rows
]
return LocationsResponse(items=items, limit=limit, offset=offset)
@router.get("/poo", response_model=PooResponse)
def get_poo(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> PooResponse:
"""
Return poo records ordered by timestamp descending (most recent first).
``limit`` is capped at 1000 to prevent full-table exports.
"""
stmt = (
select(PooRecord)
.order_by(desc(PooRecord.timestamp))
.offset(offset)
.limit(limit)
)
rows = db.execute(stmt).scalars().all()
items = [
PooRecordSchema(
timestamp=row.timestamp,
status=row.status,
latitude=row.latitude,
longitude=row.longitude,
)
for row in rows
]
return PooResponse(items=items, limit=limit, offset=offset)
@router.get("/public-ip", response_model=PublicIPResponse)
def get_public_ip(
limit: int = Query(default=100, ge=1, le=1000),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> PublicIPResponse:
"""
Return the current public IP state and recent history.
- ``state`` is ``null`` if no IP check has been performed yet.
- ``history`` is ordered by ``observed_at`` descending (most recent first).
- ``limit`` applies to the history list and is capped at 1000.
"""
state_row = db.execute(
select(PublicIPState).where(PublicIPState.id == 1).limit(1)
).scalar_one_or_none()
history_rows = db.execute(
select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit)
).scalars().all()
state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None
history = [PublicIPHistorySchema.model_validate(row) for row in history_rows]
return PublicIPResponse(state=state, history=history)
# ---------------------------------------------------------------------------
# PATCH /api/locations/{person}/{datetime}
# ---------------------------------------------------------------------------
@router.patch("/locations/{person}/{datetime}", response_model=LocationRecord)
def patch_location(
person: str,
datetime: str,
body: LocationUpdateRequest = Body(default=LocationUpdateRequest()),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> LocationRecord:
"""
Update the non-PK fields of a single location record.
- ``person`` and ``datetime`` identify the row (composite PK) and are immutable.
- Only ``latitude``, ``longitude``, and ``altitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.
"""
row = update_location(
db,
person,
datetime,
latitude=body.latitude,
longitude=body.longitude,
altitude=body.altitude,
)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="location record not found",
)
return LocationRecord(
person=row.person,
datetime=row.datetime,
latitude=row.latitude,
longitude=row.longitude,
altitude=row.altitude,
)
# ---------------------------------------------------------------------------
# DELETE /api/locations/{person}/{datetime}
# ---------------------------------------------------------------------------
@router.delete(
"/locations/{person}/{datetime}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_location_record(
person: str,
datetime: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
"""
Delete the single location record identified by its composite PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.
"""
deleted = delete_location(db, person, datetime)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="location record not found",
)
# ---------------------------------------------------------------------------
# PATCH /api/poo/{timestamp}
# ---------------------------------------------------------------------------
@router.patch("/poo/{timestamp}", response_model=PooRecordSchema)
def patch_poo(
timestamp: str,
body: PooUpdateRequest = Body(default=PooUpdateRequest()),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> PooRecordSchema:
"""
Update the non-PK fields of a single poo record.
- ``timestamp`` is the PK and is immutable.
- Only ``status``, ``latitude``, and ``longitude`` may be updated.
- Omitted body fields are left unchanged.
- Returns **404** if the PK does not exist.
"""
row = update_poo_record(
db,
timestamp,
status=body.status,
latitude=body.latitude,
longitude=body.longitude,
)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="poo record not found",
)
return PooRecordSchema(
timestamp=row.timestamp,
status=row.status,
latitude=row.latitude,
longitude=row.longitude,
)
# ---------------------------------------------------------------------------
# DELETE /api/poo/{timestamp}
# ---------------------------------------------------------------------------
@router.delete(
"/poo/{timestamp}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_poo(
timestamp: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
"""
Delete the single poo record identified by its PK.
- Exactly one row is deleted; **404** if the PK does not exist.
- No batch delete / truncate path is available.
"""
deleted = delete_poo_record(db, timestamp)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="poo record not found",
)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from fastapi import Depends, Header, HTTPException, status
from app.dependencies import get_current_auth_session
from app.services.auth import AuthenticatedSession
def require_session(
auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> AuthenticatedSession:
if auth is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="authentication required",
)
return auth
def require_csrf(
_auth: AuthenticatedSession = Depends(require_session),
x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"),
) -> None:
if not x_csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="missing CSRF token",
)
+584
View File
@@ -0,0 +1,584 @@
"""Energy data API: prices, costs, summary, DSMR, recompute, Tibber test (M6-T09).
All endpoints are under /api/energy, require an authenticated session, and
write endpoints (POST) additionally require a non-empty X-CSRF-Token header.
Route prefix note
-----------------
This router shares the ``/api/energy`` prefix with ``energy_contracts.py``
(which handles contract CRUD at /contracts/* and /profiles). The sub-paths
used here (/prices, /costs, /costs/summary, /costs/recompute, /dsmr/latest,
/tibber/test) are disjoint from the contract router's paths, so there is no
conflict.
Tibber token security
---------------------
``POST /api/energy/tibber/test`` calls the Tibber API but **never** echoes the
token in the response body or in log messages. Three-state logic mirrors the
MQTT test endpoint (M5-T10, app/api/routes/api/config.py::post_mqtt_test):
200 { result: "success", message: ..., price: {...} }
400 { result: "config-error", message: ... }
502 { result: "failed", message: ... }
Recompute safety
----------------
``POST /api/energy/costs/recompute`` is idempotent: it calls
``energy_cost.recompute_range`` which upserts existing rows without deleting
anything. The endpoint enforces a maximum time-window of 366 days to avoid
unbounded recomputation triggered by erroneous client requests.
Prices endpoint behaviour
-------------------------
``GET /api/energy/prices`` queries the ``tibber_price`` table for tibber
contracts, or derives the effective fixed-tariff prices for manual contracts
using the same formula as the billing engine (_manual_strategy in strategies.py):
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
sell_dal = energy.sell.dal (no tax added to sell price)
sell_normal = energy.sell.normal
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_app_settings, get_db
from app.config import Settings
from app.integrations.tibber.client import (
TibberAuthError,
TibberError,
fetch_current_price,
)
from app.models.energy import DsmrReading, EnergyCostPeriod, TibberPrice
from app.schemas.energy import (
CostPeriodSchema,
CostsResponse,
DsmrLatestResponse,
ManualTariffSchema,
PricePointSchema,
PricesResponse,
RecomputeResponse,
SummaryResponse,
TibberTestPriceSchema,
TibberTestResponse,
)
from app.services.auth import AuthenticatedSession
from app.services.contracts import active_contract_version_at
from app.services.energy_cost import recompute_range, summarize
from app.services.timezone import local_midnight_utc, local_now
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/energy", tags=["api-energy"])
# Maximum number of cost periods returned per request (mirrors modbus readings cap).
_COSTS_LIMIT_MAX = 5000
# Maximum allowed time-window for recompute to prevent unbounded computation.
_RECOMPUTE_MAX_DAYS = 366
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _as_utc(dt: datetime) -> datetime:
"""Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
def _manual_tariff_from_values(values: dict[str, Any]) -> ManualTariffSchema:
"""Derive the effective fixed tariff from a manual contract version's values dict.
Mirrors the _manual_strategy formula (strategies.py):
buy_dal = energy.buy.dal + energy.energy_tax + energy.ode
buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
sell_dal = energy.sell.dal (no tax added)
sell_normal = energy.sell.normal
"""
from decimal import Decimal
def _d(v: Any) -> Decimal:
return Decimal(str(v or 0))
energy = values.get("energy", {})
buy = energy.get("buy", {})
sell = energy.get("sell", {})
energy_tax = _d(energy.get("energy_tax", 0))
ode = _d(energy.get("ode", 0))
buy_dal = _d(buy.get("dal", 0)) + energy_tax + ode
buy_normal = _d(buy.get("normal", 0)) + energy_tax + ode
sell_dal = _d(sell.get("dal", 0))
sell_normal = _d(sell.get("normal", 0))
return ManualTariffSchema(
buy_dal=float(buy_dal),
buy_normal=float(buy_normal),
sell_dal=float(sell_dal),
sell_normal=float(sell_normal),
)
# ---------------------------------------------------------------------------
# GET /api/energy/prices
# ---------------------------------------------------------------------------
@router.get("/prices", response_model=PricesResponse)
def get_prices(
start: datetime | None = Query(
default=None,
description="Inclusive start of the time window (ISO 8601). "
"Defaults to the start of today UTC when omitted.",
),
end: datetime | None = Query(
default=None,
description="Inclusive end of the time window (ISO 8601). "
"Defaults to the end of tomorrow UTC when omitted.",
),
limit: int = Query(
default=500,
ge=1,
le=_COSTS_LIMIT_MAX,
description="Maximum number of Tibber price points to return.",
),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> PricesResponse:
"""Return the price curve for the active contract.
**Tibber contracts** (kind="tibber"):
Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending
by ``starts_at``. At most ``limit`` rows are returned (most recent first
within the window, then reversed to ascending order — identical to the
modbus readings pattern).
Response ``points`` carries per-slot:
- ``buy = total`` (Tibber all-inclusive price)
- ``sell = total energy_tax sell_adjust`` (from active version values)
- ``level`` (Tibber price level, may be null)
``tariff`` is null.
**Manual contracts** (kind="manual"):
``points`` is empty. ``tariff`` carries the four effective prices
derived using the billing engine formula:
- ``buy_dal = energy.buy.dal + energy_tax + ode``
- ``buy_normal = energy.buy.normal + energy_tax + ode``
- ``sell_dal = energy.sell.dal``
- ``sell_normal = energy.sell.normal``
**No active contract**: returns kind=null, currency="EUR", points=[], tariff=null (200).
"""
now = datetime.now(UTC)
# Default window: today + tomorrow.
if start is None:
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if end is None:
end = (start + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
start_utc = _as_utc(start)
end_utc = _as_utc(end)
# Resolve the active contract version at the start of the window.
version = active_contract_version_at(db, start_utc)
if version is None:
return PricesResponse(
kind=None,
currency="EUR",
points=[],
tariff=None,
)
contract = version.contract
currency = contract.currency
if contract.kind == "tibber":
# Fetch tibber_price rows in the window.
stmt = (
select(TibberPrice)
.where(
TibberPrice.starts_at >= start_utc,
TibberPrice.starts_at <= end_utc,
)
.order_by(TibberPrice.starts_at.desc())
.limit(limit)
)
rows = list(reversed(db.execute(stmt).scalars().all()))
# Derive sell price per-point using version values (energy_tax + sell_adjust).
from decimal import Decimal
def _d(v: Any) -> Decimal:
return Decimal(str(v or 0))
energy = version.values.get("energy", {}) if version.values else {}
energy_tax = _d(energy.get("energy_tax", 0))
sell_adjust = _d(energy.get("sell_adjust", 0))
points = []
for row in rows:
total = _d(row.total)
sell = float(total - energy_tax - sell_adjust)
points.append(
PricePointSchema(
starts_at=_as_utc(row.starts_at),
buy=row.total,
sell=sell,
level=row.level,
)
)
return PricesResponse(
kind="tibber",
currency=currency,
points=points,
tariff=None,
)
elif contract.kind == "manual":
tariff = _manual_tariff_from_values(version.values or {})
return PricesResponse(
kind="manual",
currency=currency,
points=[],
tariff=tariff,
)
else:
# Unknown kind — return empty response gracefully.
return PricesResponse(
kind=contract.kind,
currency=currency,
points=[],
tariff=None,
)
# ---------------------------------------------------------------------------
# GET /api/energy/costs
# ---------------------------------------------------------------------------
@router.get("/costs", response_model=CostsResponse)
def get_costs(
start: datetime | None = Query(
default=None,
description="Inclusive lower bound for period_start (ISO 8601).",
),
end: datetime | None = Query(
default=None,
description="Inclusive upper bound for period_start (ISO 8601).",
),
limit: int = Query(
default=500,
ge=1,
le=_COSTS_LIMIT_MAX,
description=f"Maximum number of cost periods to return (default 500, max {_COSTS_LIMIT_MAX}).",
),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> CostsResponse:
"""Return energy_cost_period rows within a time window.
Rows are ordered by ``period_start`` ascending. When the window contains
more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),
then reversed to ascending order — identical to the modbus readings pattern.
Query parameters:
- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).
- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).
- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).
"""
stmt = (
select(EnergyCostPeriod)
.order_by(EnergyCostPeriod.period_start.desc())
.limit(limit)
)
if start is not None:
stmt = stmt.where(EnergyCostPeriod.period_start >= _as_utc(start))
if end is not None:
stmt = stmt.where(EnergyCostPeriod.period_start <= _as_utc(end))
rows = list(reversed(db.execute(stmt).scalars().all()))
items = [CostPeriodSchema.model_validate(r) for r in rows]
return CostsResponse(items=items, total=len(items))
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary
# ---------------------------------------------------------------------------
@router.get("/costs/summary", response_model=SummaryResponse)
def get_costs_summary(
start: datetime | None = Query(
default=None,
description=(
"Inclusive start of the summary interval (ISO 8601). "
"Defaults to the start of the current UTC day."
),
),
end: datetime | None = Query(
default=None,
description=(
"Exclusive end of the summary interval (ISO 8601). "
"Defaults to the start of the next UTC day (i.e. today's full data)."
),
),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> SummaryResponse:
"""Aggregate billing for a time interval.
Calls ``energy_cost.summarize(session, start, end)`` which computes:
total_payable = Σ(net_cost) + fixed_costs credits
where ``fixed_costs`` is (network_fee + management_fee) apportioned to the
interval length in days (÷30 per month), and ``credits`` is heffingskorting
apportioned similarly (÷365 per year).
Both ``fixed_costs`` and ``credits`` are derived from the **currently active
contract version at ``end``**. When no active contract exists they are 0.
"""
if start is None or end is None:
# Default to the server's local today: [local_midnight, next_local_midnight).
# This ensures "today" aligns with the local calendar day (NL time) rather
# than UTC midnight.
local_today = local_now().date()
if start is None:
start = local_midnight_utc(local_today)
if end is None:
end = local_midnight_utc(local_today + timedelta(days=1))
result = summarize(db, _as_utc(start), _as_utc(end))
return SummaryResponse(**result)
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest
# ---------------------------------------------------------------------------
@router.get("/dsmr/latest", response_model=DsmrLatestResponse)
def get_dsmr_latest(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> DsmrLatestResponse:
"""Return the most recent dsmr_reading row.
Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not
404) when no rows exist yet, so the front-end can distinguish "no data" from
a server error.
"""
row = db.execute(
select(DsmrReading)
.order_by(DsmrReading.recorded_at.desc())
.limit(1)
).scalar_one_or_none()
if row is None:
return DsmrLatestResponse(found=False)
return DsmrLatestResponse(
found=True,
recorded_at=_as_utc(row.recorded_at),
payload=row.payload,
)
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute
# ---------------------------------------------------------------------------
@router.post(
"/costs/recompute",
responses={
200: {"model": RecomputeResponse},
422: {"description": "Validation error (missing window or range too large)"},
},
)
def post_recompute(
start: datetime = Query(
...,
description="Inclusive start of the recompute window (ISO 8601). Required.",
),
end: datetime = Query(
...,
description="Exclusive end of the recompute window (ISO 8601). Required.",
),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> RecomputeResponse:
"""Idempotently recompute billing records in a time window.
Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites
existing rows (including successful ones) for every UTC quarter-hour boundary
in ``[start, end)``.
**Idempotency**: repeated calls with the same window produce the same
outcome. No rows are deleted; only upserted.
**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.
Requests exceeding this return 422.
Returns the number of periods for which a billing record was written.
Periods skipped due to missing contract or missing Tibber price are not counted.
"""
start_utc = _as_utc(start)
end_utc = _as_utc(end)
if end_utc <= start_utc:
from fastapi import HTTPException
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="'end' must be strictly after 'start'.",
)
span_days = (end_utc - start_utc).total_seconds() / 86400
if span_days > _RECOMPUTE_MAX_DAYS:
from fastapi import HTTPException
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Time window is {span_days:.1f} days which exceeds the maximum of "
f"{_RECOMPUTE_MAX_DAYS} days. Use a smaller window."
),
)
n = recompute_range(db, start_utc, end_utc)
logger.info(
"POST /api/energy/costs/recompute [%s, %s): wrote %d period(s).",
start_utc.isoformat(),
end_utc.isoformat(),
n,
)
return RecomputeResponse(recomputed=n)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test
# ---------------------------------------------------------------------------
@router.post(
"/tibber/test",
responses={
200: {"model": TibberTestResponse},
400: {"model": TibberTestResponse},
502: {"model": TibberTestResponse},
},
)
def post_tibber_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""Test Tibber API connectivity by fetching the current price point.
Three possible outcomes:
- **200** ``{ result: "success", message: ..., price: {...} }``
The Tibber API responded with a valid current price. ``price`` contains
starts_at, total, energy, tax, currency, and level.
- **400** ``{ result: "config-error", message: ... }``
The Tibber API token is empty or not configured.
- **502** ``{ result: "failed", message: ... }``
The API call failed (authentication rejected, network error, timeout,
unexpected response, etc.).
The API token is **never** included in the response body or logged.
"""
token = settings.tibber_api_token
home_id = settings.tibber_home_id or None # treat empty string as None
if not token:
logger.info("POST /api/energy/tibber/test: no token configured.")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content=TibberTestResponse(
result="config-error",
message=(
"Tibber API token is not configured. "
"Set TIBBER_API_TOKEN in the Config page."
),
price=None,
).model_dump(mode="json"),
)
try:
price_point = fetch_current_price(token, home_id)
except TibberAuthError:
logger.warning("POST /api/energy/tibber/test: authentication failed.")
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content=TibberTestResponse(
result="failed",
message=(
"Tibber API authentication failed. "
"Check that your API token is correct."
),
price=None,
).model_dump(mode="json"),
)
except TibberError as exc:
logger.warning("POST /api/energy/tibber/test: API call failed — %s", exc)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content=TibberTestResponse(
result="failed",
message=f"Tibber API call failed: {exc}",
price=None,
).model_dump(mode="json"),
)
price_schema = TibberTestPriceSchema(
starts_at=price_point.starts_at,
total=price_point.total,
energy=price_point.energy,
tax=price_point.tax,
currency=price_point.currency,
level=price_point.level,
)
logger.info(
"POST /api/energy/tibber/test: success (starts_at=%s, total=%s %s).",
price_point.starts_at.isoformat(),
price_point.total,
price_point.currency,
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content=TibberTestResponse(
result="success",
message=(
f"Tibber API connected. Current price: "
f"{price_point.total} {price_point.currency}/kWh "
f"(starts {price_point.starts_at.isoformat()})."
),
price=price_schema,
).model_dump(mode="json"),
)
+331
View File
@@ -0,0 +1,331 @@
"""EnergyContract CRUD, versioning, and pricing-profile API (M6-T04).
All endpoints are under /api/energy, require an authenticated session, and
write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
header.
There is deliberately no DELETE endpoint for contracts: the FK RESTRICT
constraint on energy_contract_version.contract_id and
energy_cost_period.contract_version_id prevents accidental deletion of
contracts that have billing history. Users should instead deactivate a
contract (PATCH active=false) to stop it from being used.
Route ordering note
-------------------
GET /api/energy/profiles and GET /api/energy/contracts/{id} are on separate
path prefixes (/energy/profiles vs /energy/contracts/{id}) so there is no
ambiguity even without extra ordering gymnastics.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.integrations.pricing.profiles import (
ProfileNotFoundError,
ProfileValidationError,
list_profiles,
)
from app.schemas.energy_contract import (
ContractCreate,
ContractDetailResponse,
ContractListResponse,
ContractPatch,
ContractResponse,
ContractVersionResponse,
ProfilesResponse,
VersionCreate,
)
from app.services.auth import AuthenticatedSession
from app.services import timezone as _tz_mod
from app.services.contracts import (
ContractVersionError,
activate_contract,
add_version,
create_contract,
deactivate_contract,
get_contract_or_none,
list_contracts,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/energy", tags=["api-energy-contracts"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_contract_or_404(db: Session, contract_id: int):
"""Return the contract with the given id or raise 404."""
contract = get_contract_or_none(db, contract_id)
if contract is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Energy contract {contract_id!r} not found.",
)
return contract
def _contract_detail(db: Session, contract) -> ContractDetailResponse:
"""Build a ContractDetailResponse for *contract*, loading versions."""
# Eager-load versions ordered by effective_from for consistent display.
from sqlalchemy import select
from app.models.energy import EnergyContractVersion
versions = list(
db.execute(
select(EnergyContractVersion)
.where(EnergyContractVersion.contract_id == contract.id)
.order_by(EnergyContractVersion.effective_from)
)
.scalars()
.all()
)
version_schemas = [ContractVersionResponse.model_validate(v) for v in versions]
return ContractDetailResponse(
id=contract.id,
name=contract.name,
kind=contract.kind,
active=contract.active,
currency=contract.currency,
created_at=contract.created_at,
updated_at=contract.updated_at,
versions=version_schemas,
)
def _raise_422_for_profile_error(exc: Exception) -> Any:
"""Convert a ProfileValidationError or ProfileNotFoundError into HTTP 422."""
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
def _localize_effective_from(dt: datetime | None) -> datetime:
"""Resolve *dt* to an aware UTC datetime for storage.
Rules (Principle A):
- If *dt* is None → use ``datetime.now(UTC)`` (unchanged from before).
- If *dt* is timezone-aware → convert to UTC as-is.
- If *dt* is timezone-naive → interpret as server local wall-clock time,
localize with ``local_tz()``, then convert to UTC.
This means a front-end that sends ``"2026-06-25T00:00:00"`` (no Z) has it
interpreted as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
not as UTC midnight.
"""
if dt is None:
return datetime.now(UTC)
if dt.tzinfo is not None:
# Already aware: convert to UTC.
return dt.astimezone(UTC)
# Naive: assume server local wall-clock.
tz = _tz_mod.local_tz()
local_dt = dt.replace(tzinfo=tz)
return local_dt.astimezone(UTC)
# ---------------------------------------------------------------------------
# GET /api/energy/profiles
# ---------------------------------------------------------------------------
@router.get("/profiles", response_model=ProfilesResponse)
def get_profiles(
_auth: AuthenticatedSession = Depends(require_session),
) -> ProfilesResponse:
"""List all available pricing profile structures.
Returns the full profile structure for each supported contract kind
(``manual`` and ``tibber``). The front-end uses this to dynamically
render the correct fields and labels for the contract creation/editing form.
"""
return ProfilesResponse(profiles=list_profiles())
# ---------------------------------------------------------------------------
# GET /api/energy/contracts
# ---------------------------------------------------------------------------
@router.get("/contracts", response_model=ContractListResponse)
def list_energy_contracts(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ContractListResponse:
"""List all energy contracts with their active status.
Returns a flat list (no embedded version history); use
GET /api/energy/contracts/{id} to fetch the full version history for a
specific contract.
"""
contracts = list_contracts(db)
items = [ContractResponse.model_validate(c) for c in contracts]
return ContractListResponse(items=items, total=len(items))
# ---------------------------------------------------------------------------
# POST /api/energy/contracts
# ---------------------------------------------------------------------------
@router.post(
"/contracts",
response_model=ContractDetailResponse,
status_code=status.HTTP_201_CREATED,
)
def create_energy_contract(
body: ContractCreate,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ContractDetailResponse:
"""Create a new energy contract with an initial pricing version.
The ``values`` dict is validated against the YAML profile for the given
``kind``; non-conforming values result in 422 Unprocessable Entity.
The new contract is created with ``active=False``; use
PATCH /api/energy/contracts/{id} with ``active=true`` to activate it.
"""
effective_from = _localize_effective_from(body.effective_from)
try:
contract = create_contract(
db,
name=body.name,
kind=body.kind,
currency=body.currency,
values=body.values,
effective_from=effective_from,
)
except (ProfileNotFoundError, ProfileValidationError) as exc:
_raise_422_for_profile_error(exc)
db.commit()
db.refresh(contract)
logger.info("Created energy contract id=%d name=%r kind=%s", contract.id, contract.name, contract.kind)
return _contract_detail(db, contract)
# ---------------------------------------------------------------------------
# GET /api/energy/contracts/{id}
# ---------------------------------------------------------------------------
@router.get("/contracts/{contract_id}", response_model=ContractDetailResponse)
def get_energy_contract(
contract_id: int,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ContractDetailResponse:
"""Return a single energy contract with its full version history.
Versions are ordered by ``effective_from`` ascending so the caller can
easily inspect the pricing timeline.
"""
contract = _get_contract_or_404(db, contract_id)
return _contract_detail(db, contract)
# ---------------------------------------------------------------------------
# PATCH /api/energy/contracts/{id}
# ---------------------------------------------------------------------------
@router.patch("/contracts/{contract_id}", response_model=ContractDetailResponse)
def patch_energy_contract(
contract_id: int,
body: ContractPatch,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ContractDetailResponse:
"""Partially update a contract: rename or change activation status.
- ``name``: updates the human-readable label.
- ``active=true``: activates this contract (all others are deactivated).
- ``active=false``: deactivates this contract (no effect on others).
At most one contract may be active at any time; the service layer enforces
mutual exclusion.
"""
contract = _get_contract_or_404(db, contract_id)
if body.name is not None:
contract.name = body.name
contract.updated_at = datetime.now(UTC)
if body.active is True:
activate_contract(db, contract)
elif body.active is False:
deactivate_contract(db, contract)
db.commit()
db.refresh(contract)
return _contract_detail(db, contract)
# ---------------------------------------------------------------------------
# POST /api/energy/contracts/{id}/versions
# ---------------------------------------------------------------------------
@router.post(
"/contracts/{contract_id}/versions",
response_model=ContractDetailResponse,
status_code=status.HTTP_201_CREATED,
)
def add_contract_version(
contract_id: int,
body: VersionCreate,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ContractDetailResponse:
"""Add a new pricing version to an existing contract.
This is how price changes are recorded: the current open version is
automatically closed (its ``effective_to`` is set to ``body.effective_from``)
and a new version is created starting at ``body.effective_from``.
The ``values`` dict must conform to the contract's pricing profile.
Non-conforming values return 422. If ``effective_from`` is not strictly
after the previous version's ``effective_from``, 422 is returned without
writing any rows.
Historical versions are never modified; this endpoint is append-only.
"""
contract = _get_contract_or_404(db, contract_id)
effective_from = _localize_effective_from(body.effective_from)
try:
add_version(
db,
contract,
effective_from=effective_from,
values=body.values,
)
except ContractVersionError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
except (ProfileNotFoundError, ProfileValidationError) as exc:
_raise_422_for_profile_error(exc)
db.commit()
db.refresh(contract)
return _contract_detail(db, contract)
+221
View File
@@ -0,0 +1,221 @@
"""Expose API routes (M5-T12).
Three endpoints:
GET /api/expose — Return catalog of exposable entities, per-key
toggle state, and MQTT/Discovery connection status.
PUT /api/expose — Set per-key toggle state (map key → bool);
triggers a HA Discovery re-publish on success.
POST /api/expose/republish — Manually trigger a full HA Discovery re-publish.
Auth model:
- GET: session required (no CSRF — read-only).
- PUT, POST: session + CSRF required.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import get_settings
from app.dependencies import get_db
from app.services.config_page import build_runtime_settings
from app.integrations.expose import CatalogEntry, build_catalog
from app.integrations.mqtt import mqtt_manager
from app.models.expose import ExposedEntityToggle
from app.schemas.expose import (
CatalogEntrySchema,
DeviceInfoSchema,
ExposeResponse,
ExposeUpdateRequest,
ExposeUpdateResponse,
ExposableEntitySchema,
MqttStatusSchema,
RepublishResponse,
)
from app.services.auth import AuthenticatedSession
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-expose"])
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _get_mqtt_status(db: Session) -> MqttStatusSchema:
"""Build MQTT/Discovery status from live runtime state (DB-merged settings)."""
settings = build_runtime_settings(db, get_settings())
return MqttStatusSchema(
mqtt_configured=mqtt_manager.is_configured(settings),
mqtt_connected=mqtt_manager.is_connected,
discovery_enabled=bool(settings.ha_discovery_enabled),
)
def _entry_to_schema(entry: CatalogEntry) -> CatalogEntrySchema:
"""Convert a CatalogEntry to its Pydantic schema form.
Excludes ``value_getter`` because it is a non-serialisable callable.
"""
entity = entry.entity
return CatalogEntrySchema(
entity=ExposableEntitySchema(
key=entity.key,
component=entity.component,
device=DeviceInfoSchema(
identifiers=list(entity.device.identifiers),
name=entity.device.name,
),
device_class=entity.device_class,
unit=entity.unit,
name=entity.name,
state_class=entity.state_class,
),
enabled=entry.enabled,
)
def _build_response_data(
session: Session,
) -> tuple[list[CatalogEntrySchema], MqttStatusSchema]:
"""Return (catalog_entries, mqtt_status) for building GET / PUT responses."""
catalog = build_catalog(session)
catalog_schema = [_entry_to_schema(e) for e in catalog]
mqtt_status = _get_mqtt_status(session)
return catalog_schema, mqtt_status
# ---------------------------------------------------------------------------
# GET /api/expose
# ---------------------------------------------------------------------------
@router.get("/expose", response_model=ExposeResponse)
def get_expose(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ExposeResponse:
"""Return the full exposable-entity catalog with toggle states and MQTT status.
The catalog is computed dynamically from registered providers (e.g. the
Modbus provider enumerates all enabled devices and their metric entities).
Toggle states come from the ``exposed_entity_toggle`` table; entities with
no row default to ``enabled=False``.
"""
catalog_schema, mqtt_status = _build_response_data(db)
return ExposeResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
# ---------------------------------------------------------------------------
# PUT /api/expose
# ---------------------------------------------------------------------------
@router.put("/expose", response_model=ExposeUpdateResponse)
def put_expose(
body: ExposeUpdateRequest,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ExposeUpdateResponse:
"""Set per-entity toggle state.
Accepts a map of ``{key: bool}`` and upserts rows in the
``exposed_entity_toggle`` table. Only keys present in ``body.toggles``
are touched; other entities' toggles are left unchanged.
After writing the toggles, triggers a HA Discovery re-publish so any
changes (enabled ↔ disabled) are reflected in Home Assistant immediately.
"""
now = datetime.now(UTC)
for key, enabled in body.toggles.items():
existing = (
db.query(ExposedEntityToggle)
.filter(ExposedEntityToggle.key == key)
.first()
)
if existing is not None:
existing.enabled = enabled
existing.updated_at = now
else:
db.add(
ExposedEntityToggle(
key=key,
enabled=enabled,
updated_at=now,
)
)
db.commit()
# Trigger discovery re-publish after toggle change.
_trigger_republish(db)
catalog_schema, mqtt_status = _build_response_data(db)
return ExposeUpdateResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
# ---------------------------------------------------------------------------
# POST /api/expose/republish
# ---------------------------------------------------------------------------
@router.post("/expose/republish", response_model=RepublishResponse)
def post_expose_republish(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> RepublishResponse:
"""Manually trigger a full HA Discovery re-publish.
Calls ``publish_discovery(session)`` from the HA discovery service (M5-T11).
Returns a status indicating whether the publish was attempted (or skipped
because MQTT / discovery is not enabled / connected).
"""
settings = build_runtime_settings(db, get_settings())
if not (settings.mqtt_enabled and settings.ha_discovery_enabled and mqtt_manager.is_connected):
return RepublishResponse(
ok=False,
message="MQTT / HA Discovery not enabled or broker not connected.",
)
try:
_trigger_republish(db)
return RepublishResponse(
ok=True,
message="HA Discovery re-published successfully.",
)
except Exception as exc:
logger.exception("post_expose_republish: unexpected error during publish")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Discovery re-publish failed.",
) from exc
# ---------------------------------------------------------------------------
# Internal: trigger discovery re-publish
# ---------------------------------------------------------------------------
def _trigger_republish(session: Session) -> None:
"""Call publish_discovery from the HA discovery service.
No-op if MQTT / discovery is not enabled or broker is not connected
(publish_discovery guards internally). All errors are swallowed to avoid
breaking the API response.
"""
try:
from app.services.ha_discovery import publish_discovery
publish_discovery(session)
except Exception:
logger.exception("_trigger_republish: publish_discovery raised an error")
+331
View File
@@ -0,0 +1,331 @@
"""Meter CRUD, swap declaration, and retroactive recompute API (M7-T05).
All endpoints are under /api/energy/meters, require an authenticated session,
and write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
header.
Route semantics
---------------
GET /api/energy/meters — list all meter epochs (ascending started_at)
POST /api/energy/meters — declare a meter swap / initial meter epoch
PATCH /api/energy/meters/{id} — edit label / note, or correct started_at (retroactive)
Retroactive recompute
---------------------
Whenever a write operation changes a meter's ``started_at`` (new declaration
or PATCH correction), the affected billing window is re-judged via
``recompute_range``:
- **POST** (new meter, possibly retroactive):
window = [new_meter.started_at, now)
Rationale: the new meter's ``started_at`` closes the previous meter at that
point; all periods from that boundary forward may have a different meter
attribution. Using ``now`` as the upper bound is safe because
``recompute_range`` only processes closed quarters and the operation is
idempotent.
- **PATCH started_at** (retroactive correction):
window = [min(old_started_at, new_started_at), now)
Rationale: shifting the boundary in either direction affects all periods
between the old and new boundary (and potentially beyond if re-attribution
cascades). Using the minimum of the two timestamps guarantees the entire
affected range is covered; using ``now`` as the upper bound is safe and
idempotent.
``started_at`` localisation (Principle A, FU10 convention)
----------------------------------------------------------
If the client sends a timezone-naive ``started_at`` value, it is interpreted as
the **server's local wall-clock time** and converted to UTC before storage.
Timezone-aware values are converted to UTC as-is. This is identical to the
``_localize_effective_from`` convention used in ``energy_contracts.py``.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.energy import Meter
from app.schemas.meter import (
MeterDeclareRequest,
MeterListResponse,
MeterPatchRequest,
MeterResponse,
)
from app.services import timezone as _tz_mod
from app.services.auth import AuthenticatedSession
from app.services.energy_cost import recompute_range
from app.services.meters import (
MeterIntervalError,
MeterOverlapError,
declare_meter,
list_meters,
update_meter,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _trigger_discovery_republish(session: Session) -> None:
"""Call publish_discovery after a meter write operation (best-effort).
No-op if MQTT / discovery is not enabled or the broker is not connected
(publish_discovery guards internally). All errors are swallowed so that a
discovery failure never breaks the API response.
Must be called **after** db.commit() so that publish_discovery sees the
final committed state of the meter table when it rebuilds the catalog.
"""
try:
from app.services.ha_discovery import publish_discovery
publish_discovery(session)
except Exception:
logger.exception("_trigger_discovery_republish: publish_discovery raised an error")
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
"""Return the meter with the given id or raise 404."""
meter: Optional[Meter] = db.get(Meter, meter_id)
if meter is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Meter {meter_id!r} not found.",
)
return meter
def _localize_started_at(dt: datetime) -> datetime:
"""Resolve *dt* to an aware UTC datetime for storage.
Follows the same Principle-A convention as ``_localize_effective_from``
in ``energy_contracts.py`` (FU10):
- Timezone-aware → convert to UTC as-is.
- Timezone-naive → interpret as server local wall-clock time, localize
with ``local_tz()``, then convert to UTC.
A front-end sending ``"2026-06-25T00:00:00"`` (no Z) has it interpreted
as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
not as UTC midnight.
"""
if dt.tzinfo is not None:
return dt.astimezone(UTC)
tz = _tz_mod.local_tz()
local_dt = dt.replace(tzinfo=tz)
return local_dt.astimezone(UTC)
def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
"""Trigger recompute_range from *start* to now (UTC).
This is the standard "retroactive window" call: everything from the
affected boundary up to the current moment needs re-attribution.
Using ``now`` as the upper bound is safe because ``recompute_range``
only touches closed quarter-hour periods and the operation is idempotent.
"""
end = datetime.now(UTC)
if start >= end:
# started_at is in the future — nothing to recompute.
logger.info("%s: started_at (%s) is in the future, skipping recompute.", label, start)
return 0
n = recompute_range(db, start, end)
logger.info(
"%s: recomputed %d period(s) in window [%s, %s).",
label,
n,
start.isoformat(),
end.isoformat(),
)
return n
# ---------------------------------------------------------------------------
# GET /api/energy/meters
# ---------------------------------------------------------------------------
@router.get("/meters", response_model=MeterListResponse)
def list_energy_meters(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> MeterListResponse:
"""List all meter epochs in ascending ``started_at`` order.
Returns the full historical sequence of meter installations across all
commodities. The active meter (``ended_at=null``) appears last because it
has the latest ``started_at``.
"""
meters = list_meters(db)
items = [MeterResponse.model_validate(m) for m in meters]
return MeterListResponse(items=items, total=len(items))
# ---------------------------------------------------------------------------
# POST /api/energy/meters
# ---------------------------------------------------------------------------
@router.post(
"/meters",
response_model=MeterResponse,
status_code=status.HTTP_201_CREATED,
)
def declare_energy_meter(
body: MeterDeclareRequest,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> MeterResponse:
"""Declare a new meter epoch (swap, home move, or initial declaration).
Closes the current active meter for the given commodity at ``started_at``
and opens a new active meter. If no active meter exists, the new meter is
simply created without closing anything.
**Validation**: ``started_at`` must be **≥** the current active meter's
own ``started_at`` (no chronological backdate below the active epoch's
start). Equal timestamps are allowed (replaces the current meter at the
same logical moment). Violation → 422.
**Retroactive recompute**: if ``started_at`` is in the past, billing
records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The recompute is transparent — the
response body is the created meter (``MeterResponse``) only and does **not**
include a recompute count; callers should re-fetch costs if they need the
updated totals.
"""
started_at_utc = _localize_started_at(body.started_at)
try:
new_meter = declare_meter(
db,
label=body.label,
started_at=started_at_utc,
reason=body.reason.value,
commodity=body.commodity,
note=body.note,
)
except MeterOverlapError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
db.flush() # assign PK before recompute (recompute uses session, needs meter in DB)
# Retroactive recompute: re-judge attribution from the new boundary onward.
now = datetime.now(UTC)
if started_at_utc < now:
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
db.commit()
db.refresh(new_meter)
# Trigger HA discovery re-publish so the new active meter's energy-cost
# device/sensor configuration is pushed to Home Assistant. Best-effort:
# failures are logged and swallowed; the API response is not affected.
_trigger_discovery_republish(db)
logger.info(
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
body.commodity,
new_meter.id,
new_meter.label,
started_at_utc.isoformat(),
)
return MeterResponse.model_validate(new_meter)
# ---------------------------------------------------------------------------
# PATCH /api/energy/meters/{id}
# ---------------------------------------------------------------------------
@router.patch("/meters/{meter_id}", response_model=MeterResponse)
def patch_energy_meter(
meter_id: int,
body: MeterPatchRequest,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> MeterResponse:
"""Partially update a meter epoch: rename, edit note, or correct started_at.
- ``label``: updates the human-readable label.
- ``note``: updates the free-form note.
- ``started_at``: **retroactive correction** — shifts this meter's start
boundary. The service layer maintains timeline continuity by also
updating the preceding meter's ``ended_at``. Validation:
* Must be strictly after the previous meter's own ``started_at``.
* Must be strictly before this meter's ``ended_at`` (if closed).
Violation → 422.
**Retroactive recompute when ``started_at`` changes**: billing records in
the window ``[min(old, new), now)`` are re-judged to reflect the corrected
meter attribution.
Not found → 404.
"""
meter = _get_meter_or_404(db, meter_id)
# Capture old started_at before mutation (needed for recompute window).
old_started_at: Optional[datetime] = meter.started_at
# Localise started_at if provided.
new_started_at_utc: Optional[datetime] = None
if body.started_at is not None:
new_started_at_utc = _localize_started_at(body.started_at)
try:
update_meter(
db,
meter,
label=body.label,
note=body.note,
started_at=new_started_at_utc,
)
except MeterIntervalError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
# Retroactive recompute if started_at was changed.
if new_started_at_utc is not None and old_started_at is not None:
# Normalise old_started_at to UTC-aware for comparison.
if old_started_at.tzinfo is None:
old_started_at = old_started_at.replace(tzinfo=UTC)
# Window = [min(old, new), now) — covers all periods whose attribution
# may have changed due to the boundary shift in either direction.
window_start = min(old_started_at, new_started_at_utc)
_trigger_recompute(db, window_start, f"PATCH /api/energy/meters/{meter_id}")
db.commit()
db.refresh(meter)
# Trigger HA discovery re-publish so label renames on the active meter
# propagate to the HA device name. Best-effort: failures are logged and
# swallowed; the API response is not affected.
_trigger_discovery_republish(db)
logger.info(
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
meter_id,
meter.label,
meter.started_at,
)
return MeterResponse.model_validate(meter)
+511
View File
@@ -0,0 +1,511 @@
"""Modbus device CRUD, readings, metrics, and test-read API (M5-T05).
All endpoints are under /api/modbus, require an authenticated session, and
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
non-empty X-CSRF-Token header.
Deletion safety:
DELETE /api/modbus/devices/{uuid} queries the application layer for
existing modbus_reading rows before deleting. Without ``cascade`` it
returns 409 and suggests disabling the device instead — a friendly guard
rather than letting the DB raise. With ``cascade=true`` it removes the
readings (and expose toggles) first, then the device. SQLite FK RESTRICT
*is* enforced at runtime here (the app sets PRAGMA foreign_keys=ON; see
app/db.py), so the cascade delete order matters.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete as sa_delete, func, select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.integrations.modbus import driver as modbus_driver
from app.integrations.modbus.driver import ModbusDriverError
from app.integrations.modbus.profiles import (
ProfileNotFoundError,
decode as decode_profile,
list_profiles,
load_profile,
)
from app.models.expose import ExposedEntityToggle
from app.models.modbus import ModbusDevice, ModbusReading
from app.schemas.modbus import (
MetricInfo,
ModbusDeleteResponse,
ModbusDeviceCreate,
ModbusDeviceListResponse,
ModbusDeviceResponse,
ModbusDeviceUpdate,
ModbusLatestResponse,
ModbusMetricsResponse,
ModbusProfilesResponse,
ModbusReadingResponse,
ModbusReadingsResponse,
ModbusTestReadResponse,
ProfileSummary,
)
from app.services.auth import AuthenticatedSession
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/modbus", tags=["api-modbus"])
# Maximum number of readings that can be returned per request.
_READINGS_LIMIT_MAX = 5000
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_device_or_404(db: Session, uuid: str) -> ModbusDevice:
"""Return the device with the given UUID or raise 404."""
device = db.execute(
select(ModbusDevice).where(ModbusDevice.uuid == uuid)
).scalar_one_or_none()
if device is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Device '{uuid}' not found.",
)
return device
def _label_from_key(key: str) -> str:
"""Derive a human-readable label from a metric key.
Example: ``"active_power"`` → ``"Active Power"``
"""
return key.replace("_", " ").title()
# ---------------------------------------------------------------------------
# GET /api/modbus/profiles
# ---------------------------------------------------------------------------
@router.get("/profiles", response_model=ModbusProfilesResponse)
def get_profiles(
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusProfilesResponse:
"""List all available Modbus YAML profiles (name + description).
Intended for the front-end's device-creation profile drop-down.
"""
pairs = list_profiles()
return ModbusProfilesResponse(
profiles=[ProfileSummary(name=name, description=desc) for name, desc in pairs]
)
# ---------------------------------------------------------------------------
# Device CRUD
# ---------------------------------------------------------------------------
@router.get("/devices", response_model=ModbusDeviceListResponse)
def list_devices(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusDeviceListResponse:
"""Return all Modbus devices (no pagination — device counts stay small)."""
devices = db.execute(select(ModbusDevice)).scalars().all()
items = [ModbusDeviceResponse.model_validate(d) for d in devices]
return ModbusDeviceListResponse(items=items, total=len(items))
@router.post(
"/devices",
response_model=ModbusDeviceResponse,
status_code=status.HTTP_201_CREATED,
)
def create_device(
body: ModbusDeviceCreate,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ModbusDeviceResponse:
"""Create a new Modbus device.
- Validates that the referenced ``profile`` exists; returns 422 if not.
- Returns 201 with the created device on success.
"""
# Validate profile exists (422 if not)
try:
load_profile(body.profile)
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Profile '{body.profile}' does not exist.",
)
now = datetime.now(UTC)
device = ModbusDevice(
friendly_name=body.friendly_name,
transport=body.transport,
host=body.host,
port=body.port,
unit_id=body.unit_id,
profile=body.profile,
poll_interval_s=body.poll_interval_s,
enabled=body.enabled,
created_at=now,
updated_at=now,
)
db.add(device)
db.commit()
db.refresh(device)
logger.info("Created Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
return ModbusDeviceResponse.model_validate(device)
@router.get("/devices/{uuid}", response_model=ModbusDeviceResponse)
def get_device(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusDeviceResponse:
"""Return a single Modbus device by UUID."""
device = _get_device_or_404(db, uuid)
return ModbusDeviceResponse.model_validate(device)
@router.patch("/devices/{uuid}", response_model=ModbusDeviceResponse)
def patch_device(
uuid: str,
body: ModbusDeviceUpdate,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ModbusDeviceResponse:
"""Partially update a Modbus device (including enable/disable).
Only fields explicitly provided in the request body are updated.
Providing ``profile`` triggers a profile-existence check (422 if unknown).
"""
device = _get_device_or_404(db, uuid)
update_data = body.model_dump(exclude_none=True)
# If profile is being changed, validate it exists.
if "profile" in update_data:
try:
load_profile(update_data["profile"])
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Profile '{update_data['profile']}' does not exist.",
)
for field, value in update_data.items():
setattr(device, field, value)
device.updated_at = datetime.now(UTC)
db.commit()
db.refresh(device)
logger.info("Updated Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
return ModbusDeviceResponse.model_validate(device)
@router.delete(
"/devices/{uuid}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def delete_device(
uuid: str,
cascade: bool = Query(default=False),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None | ModbusDeleteResponse:
"""Delete a Modbus device.
**Default behaviour (cascade=false)**:
Returns 409 Conflict if the device has any associated readings; use
``enabled=false`` to disable it instead.
**Cascade delete (cascade=true)**:
Permanently deletes the device together with all its readings and any
``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.
Also makes a best-effort attempt to clear the device's HA Discovery
config topics from MQTT (empty retained payload) before the DB rows
are removed. MQTT failures are swallowed — the DB deletion proceeds
regardless.
Returns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.
**Application-layer safety**: the 409 guard uses an explicit SELECT COUNT
query to return a friendly message. FK RESTRICT is enforced at runtime
(the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes
readings before the device.
"""
from app.services.ha_discovery import clear_device_discovery
device = _get_device_or_404(db, uuid)
# Application-layer guard: refuse deletion if any readings exist.
reading_count: int = db.execute(
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
).scalar_one()
if reading_count > 0 and not cascade:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Device '{uuid}' has {reading_count} associated reading(s) and cannot be "
"deleted. Disable the device (set enabled=false) instead to stop polling "
"without losing historical data."
),
)
if cascade:
# --- Cascade deletion path ---
logger.info(
"Cascade-deleting Modbus device %r (uuid=%s): %d reading(s)",
device.friendly_name,
uuid,
reading_count,
)
# 1. Best-effort HA Discovery cleanup (before DB rows are gone).
try:
clear_device_discovery(db, uuid)
except Exception: # noqa: BLE001
logger.exception(
"delete_device(cascade): HA discovery cleanup raised for uuid=%s; continuing",
uuid,
)
# 2. Delete all readings for this device (must happen before device row).
deleted_readings_result = db.execute(
sa_delete(ModbusReading).where(ModbusReading.device_id == device.id)
)
readings_deleted: int = deleted_readings_result.rowcount
# 3. Delete all ExposedEntityToggle rows for this device.
# Keys follow the pattern "modbus.<uuid>.<metric>".
toggle_prefix = f"modbus.{uuid}.%"
deleted_toggles_result = db.execute(
sa_delete(ExposedEntityToggle).where(ExposedEntityToggle.key.like(toggle_prefix))
)
toggles_deleted: int = deleted_toggles_result.rowcount
# 4. Delete the device itself.
db.delete(device)
db.commit()
logger.info(
"Cascade-delete complete for device uuid=%s: %d reading(s), %d toggle(s) removed",
uuid,
readings_deleted,
toggles_deleted,
)
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"deleted": True,
"readings_deleted": readings_deleted,
"toggles_deleted": toggles_deleted,
},
)
# --- Non-cascade path (no readings exist at this point) ---
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
db.delete(device)
db.commit()
# ---------------------------------------------------------------------------
# Readings endpoints
# ---------------------------------------------------------------------------
@router.get("/devices/{uuid}/latest", response_model=ModbusLatestResponse)
def get_latest_reading(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusLatestResponse:
"""Return the most recent reading for a device.
If no readings exist yet, returns ``{"found": false, "recorded_at": null,
"payload": null}`` (200, not 404) so the front-end can distinguish
"device exists but has no data" from "device not found".
"""
device = _get_device_or_404(db, uuid)
row = db.execute(
select(ModbusReading)
.where(ModbusReading.device_id == device.id)
.order_by(ModbusReading.recorded_at.desc())
.limit(1)
).scalar_one_or_none()
if row is None:
return ModbusLatestResponse(found=False, recorded_at=None, payload=None)
return ModbusLatestResponse(
found=True,
recorded_at=row.recorded_at,
payload=row.payload,
)
@router.get("/devices/{uuid}/readings", response_model=ModbusReadingsResponse)
def get_readings(
uuid: str,
start: datetime | None = Query(default=None),
end: datetime | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=_READINGS_LIMIT_MAX),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusReadingsResponse:
"""Return time-range readings for a device.
When the window contains more rows than ``limit``, the **most recent** N rows
are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to
ascending order before being sent to the client. This ensures that for long
time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data
rather than the oldest segment of the window.
When the window has fewer rows than ``limit`` the full window is returned in
ascending order — behaviour is identical to a plain ascending query.
The response schema is unchanged: items are always ``recorded_at`` ascending.
The query uses the ``(device_id, recorded_at)`` composite index for
efficient time-window scans.
Query parameters:
- ``start``: inclusive lower bound (ISO8601 datetime)
- ``end``: inclusive upper bound (ISO8601 datetime)
- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
"""
device = _get_device_or_404(db, uuid)
stmt = (
select(ModbusReading)
.where(ModbusReading.device_id == device.id)
.order_by(ModbusReading.recorded_at.desc())
.limit(limit)
)
if start is not None:
stmt = stmt.where(ModbusReading.recorded_at >= start)
if end is not None:
stmt = stmt.where(ModbusReading.recorded_at <= end)
# Fetch most-recent N rows, then reverse to restore ascending order for the response.
rows = list(reversed(db.execute(stmt).scalars().all()))
items = [ModbusReadingResponse(recorded_at=r.recorded_at, payload=r.payload) for r in rows]
return ModbusReadingsResponse(items=items)
# ---------------------------------------------------------------------------
# Metrics endpoint
# ---------------------------------------------------------------------------
@router.get("/devices/{uuid}/metrics", response_model=ModbusMetricsResponse)
def get_metrics(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> ModbusMetricsResponse:
"""Return the metric catalogue for a device's profile.
Each entry has ``key``, ``label`` (derived from key if the profile has
none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
This is the authoritative metadata source for front-end card labels and
chart axis labels.
"""
device = _get_device_or_404(db, uuid)
try:
profile = load_profile(device.profile)
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Device profile '{device.profile}' could not be loaded.",
)
metrics = [
MetricInfo(
key=m.key,
label=_label_from_key(m.key),
unit=m.unit,
device_class=m.device_class,
)
for m in profile.metrics
]
return ModbusMetricsResponse(profile=profile.name, metrics=metrics)
# ---------------------------------------------------------------------------
# Test-read endpoint
# ---------------------------------------------------------------------------
@router.post("/devices/{uuid}/test", response_model=ModbusTestReadResponse)
def test_read(
uuid: str,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> ModbusTestReadResponse:
"""Immediately read and decode the device once without persisting any data.
Useful for validating gateway connectivity and Modbus address settings
before relying on the background polling job.
This is a write-class endpoint (it initiates network I/O on demand) and
therefore requires a CSRF token. The response payload is never stored.
"""
device = _get_device_or_404(db, uuid)
try:
profile = load_profile(device.profile)
except ProfileNotFoundError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Device profile '{device.profile}' could not be loaded.",
)
try:
registers = modbus_driver.read_blocks(
device.host,
device.port,
device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
)
payload: dict[str, Any] = decode_profile(profile, registers)
return ModbusTestReadResponse(ok=True, payload=payload)
except ModbusDriverError as exc:
logger.warning(
"Test read failed for device %r (uuid=%s): %s",
device.friendly_name,
uuid,
exc,
)
return ModbusTestReadResponse(ok=False, error=str(exc))
except Exception as exc: # noqa: BLE001
logger.warning(
"Unexpected error during test read for device %r (uuid=%s): %s",
device.friendly_name,
uuid,
exc,
)
return ModbusTestReadResponse(ok=False, error=f"Unexpected error: {exc}")
+324
View File
@@ -0,0 +1,324 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings
from app.dependencies import get_app_settings, get_db
from app.schemas.session import (
LoginRequest,
PasswordChangeRequest,
SessionResponse,
SessionUser,
)
from app.schemas.totp import (
TotpDisableRequest,
TotpEnableRequest,
TotpSetupResponse,
TotpStatusResponse,
)
from app.services.auth import (
AuthPasswordChangeError,
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
revoke_session,
)
from app.services import login_throttle
from app.services import totp as totp_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-session"])
def _build_session_response(auth: AuthenticatedSession) -> SessionResponse:
return SessionResponse(
user=SessionUser(
username=auth.user.username,
force_password_change=auth.user.force_password_change,
),
csrf_token=auth.session.csrf_token,
)
@router.get("/session", response_model=SessionResponse)
def get_session(
auth: AuthenticatedSession = Depends(require_session),
) -> SessionResponse:
"""Return the current session user and CSRF token. Returns 401 if not authenticated."""
return _build_session_response(auth)
def _get_client_ip(request: Request, *, trust_forwarded_for: bool) -> str:
"""Extract the client IP address from the request.
When ``trust_forwarded_for`` is True (reverse-proxy deployments) the
left-most value from the ``X-Forwarded-For`` header is used. Otherwise the
direct socket IP (``request.client.host``) is used.
"""
if trust_forwarded_for:
xff = request.headers.get("X-Forwarded-For", "")
if xff:
return xff.split(",")[0].strip()
if request.client is not None:
return request.client.host
return "unknown"
@router.post("/auth/login", response_model=SessionResponse)
def post_login(
body: LoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> SessionResponse:
"""
Authenticate with username and password.
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
On failure, returns 401 with no cookie set.
Repeated failures trigger exponential back-off (429 + Retry-After).
No X-CSRF-Token required (unauthenticated endpoint).
"""
client_ip = _get_client_ip(request, trust_forwarded_for=settings.auth_trust_forwarded_for)
# --- Throttle check (before any password verification) ---
if settings.auth_login_throttle_enabled:
wait_seconds = login_throttle.check_and_get_wait(
db, ip=client_ip, username=body.username
)
if wait_seconds > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="too many failed login attempts; please try again later",
headers={"Retry-After": str(wait_seconds)},
)
# --- Password verification ---
user = authenticate_user(db, username=body.username, password=body.password)
if user is None:
if settings.auth_login_throttle_enabled:
login_throttle.register_failure(db, ip=client_ip, username=body.username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
# --- TOTP second-factor check (only when TOTP is enabled for the user) ---
if user.totp_enabled:
if not body.totp_code:
# Password correct but no TOTP code supplied: signal the front-end to
# prompt for the second factor. Do NOT issue a session.
# Deliberate: this is a normal two-step protocol step from a legitimate
# user — we do NOT register a throttle failure here. The attacker must
# already have the correct password to reach this branch, and counting
# each normal first-step as a failure would risk locking out the real
# admin on every login attempt.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"totp_required": True},
)
# TOTP code supplied — verify against time-based code or a recovery code.
totp_ok = totp_service.verify_totp_code(user, body.totp_code)
if not totp_ok:
totp_ok = totp_service.verify_recovery_code(db, user=user, code=body.totp_code)
if not totp_ok:
# Second-factor failure is an active attack signal — register failure
# so that repeated wrong TOTP/recovery-code guesses trigger back-off.
if settings.auth_login_throttle_enabled:
login_throttle.register_failure(db, ip=client_ip, username=body.username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
# --- Success: clear back-off state and issue session ---
if settings.auth_login_throttle_enabled:
login_throttle.clear(db, ip=client_ip, username=body.username)
auth_session, raw_token = create_session(db, user=user, settings=settings)
logger.info("Created API authenticated session for user '%s'", user.username)
response.set_cookie(
key=settings.auth_session_cookie_name,
value=raw_token,
max_age=settings.auth_session_ttl_hours * 3600,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
auth = AuthenticatedSession(user=user, session=auth_session)
return _build_session_response(auth)
@router.post("/auth/logout")
def post_logout(
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Revoke the current session and clear the session cookie.
Requires authentication and X-CSRF-Token header.
Returns 204 No Content.
"""
revoke_session(db, auth_session=auth.session)
logger.info("Revoked API authenticated session for user '%s'", auth.user.username)
no_content = Response(status_code=status.HTTP_204_NO_CONTENT)
no_content.delete_cookie(settings.auth_session_cookie_name, path="/")
return no_content
@router.post("/auth/password")
def post_change_password(
body: PasswordChangeRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Change the current user's password.
Requires authentication and X-CSRF-Token header.
On AuthPasswordChangeError returns 400 with a generic message.
On success, force_password_change becomes False (handled by the service).
Returns 204 No Content.
"""
try:
change_password(
db,
user=auth.user,
current_password=body.current_password,
new_password=body.new_password,
confirm_password=body.confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
auth.user.username,
exc,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="password change failed",
) from exc
logger.info("Password updated for user '%s'", auth.user.username)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ---------------------------------------------------------------------------
# TOTP endpoints (M4-T05)
# ---------------------------------------------------------------------------
@router.post("/auth/totp/setup", response_model=TotpSetupResponse)
def post_totp_setup(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> TotpSetupResponse:
"""
Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
False until the user confirms with POST /api/auth/totp/enable).
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
are persisted immediately so enable only needs to flip the enabled flag.
Repeating this call replaces any prior pending secret and regenerates codes.
Requires: session cookie + X-CSRF-Token.
"""
secret, otpauth_uri, recovery_codes = totp_service.setup(
db,
user=auth.user,
issuer=settings.effective_totp_issuer,
)
return TotpSetupResponse(
secret=secret,
otpauth_uri=otpauth_uri,
recovery_codes=recovery_codes,
)
@router.post("/auth/totp/enable", status_code=status.HTTP_204_NO_CONTENT)
def post_totp_enable(
body: TotpEnableRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Enable TOTP by confirming with the current 6-digit code from the authenticator app.
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
exists). On success, totp_enabled becomes True.
Returns 400 if the code is wrong or there is no pending secret.
Requires: session cookie + X-CSRF-Token.
"""
ok = totp_service.enable(db, user=auth.user, code=body.code)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid TOTP code or no pending setup",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/auth/totp/disable", status_code=status.HTTP_204_NO_CONTENT)
def post_totp_disable(
body: TotpDisableRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Disable TOTP. The caller must provide exactly one of:
- ``password``: the user's current login password, OR
- ``code``: the current 6-digit TOTP code.
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
Returns 400 if neither credential matches or neither is provided.
Requires: session cookie + X-CSRF-Token.
"""
ok = totp_service.disable(
db,
user=auth.user,
password=body.password,
code=body.code,
)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid credential; provide a valid password or TOTP code",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/auth/totp", response_model=TotpStatusResponse)
def get_totp_status(
auth: AuthenticatedSession = Depends(require_session),
) -> TotpStatusResponse:
"""
Return the current TOTP status for the authenticated user.
Response contains only ``{"enabled": bool}``.
Secret and recovery codes are NEVER returned here.
Requires: session cookie only (no CSRF — read-only).
"""
return TotpStatusResponse(enabled=auth.user.totp_enabled)
-234
View File
@@ -1,234 +0,0 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
from app.services.auth import (
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
AuthPasswordChangeError,
issue_login_csrf_token,
revoke_session,
validate_csrf_token,
)
from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["auth"])
LOGIN_CSRF_COOKIE_NAME = "login_csrf"
@router.get("/login", response_class=HTMLResponse)
def login_page(
request: Request,
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is not None:
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": None,
},
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
@router.post("/login", response_class=HTMLResponse)
def login_submit(
request: Request,
username: str = Form(),
password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
) -> Response:
cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
logger.warning("Rejected login attempt due to CSRF validation failure")
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_400_BAD_REQUEST,
error_message="invalid login request",
)
user = authenticate_user(session, username=username, password=password)
if user is None:
return _render_login_error(
request,
settings=settings,
status_code=status.HTTP_401_UNAUTHORIZED,
error_message="invalid username or password",
)
auth_session, raw_token = create_session(session, user=user, settings=settings)
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
response.set_cookie(
key=settings.auth_session_cookie_name,
value=raw_token,
max_age=settings.auth_session_ttl_hours * 3600,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
logger.info("Created authenticated session for user '%s'", user.username)
return response
@router.post("/config/change-password", response_class=HTMLResponse)
def change_password_submit(
request: Request,
current_password: str = Form(),
new_password: str = Form(),
confirm_password: str = Form(),
csrf_token: str = Form(),
session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
logger.warning("Rejected password change attempt due to CSRF validation failure")
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="invalid password change request",
)
try:
change_password(
session,
user=current_auth.user,
current_password=current_password,
new_password=new_password,
confirm_password=confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
current_auth.user.username,
exc,
)
return _render_config_page(
request,
settings=settings,
auth_db_session=session,
current_auth=current_auth,
status_code=status.HTTP_400_BAD_REQUEST,
password_change_error="password change failed",
)
logger.info("Password updated for user '%s'", current_auth.user.username)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
def logout(
request: Request,
csrf_token: str = Form(),
session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is not None and validate_csrf_token(
expected=current_auth.session.csrf_token, actual=csrf_token
):
revoke_session(session, auth_session=current_auth.session)
logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
else:
logger.warning("Rejected logout request due to missing session or invalid CSRF token")
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(settings.auth_session_cookie_name, path="/")
return response
def _render_login_error(
request: Request,
*,
settings: Settings,
status_code: int,
error_message: str,
) -> HTMLResponse:
csrf_token = issue_login_csrf_token()
response = templates.TemplateResponse(
request,
"login.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"csrf_token": csrf_token,
"error_message": error_message,
},
status_code=status_code,
)
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
return response
def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
response.set_cookie(
key=LOGIN_CSRF_COOKIE_NAME,
value=token,
max_age=1800,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/login",
)
def _render_config_page(
request: Request,
*,
settings: Settings,
auth_db_session: Session,
current_auth: AuthenticatedSession,
status_code: int,
password_change_error: str | None,
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"config.html",
{
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": password_change_error,
"config_error": None,
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
},
status_code=status_code,
)
+30 -4
View File
@@ -6,7 +6,18 @@ from fastapi.responses import PlainTextResponse, Response
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.dependencies import get_db, get_ticktick_client
from app.config import Settings
from app.dependencies import (
get_app_settings,
get_db,
get_homeassistant_client,
get_ticktick_client,
)
from app.integrations.homeassistant import (
HomeAssistantClient,
HomeAssistantConfigError,
HomeAssistantRequestError,
)
from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError
from app.schemas.homeassistant import HomeAssistantPublishEnvelope
from app.services.homeassistant_inbound import (
@@ -24,13 +35,22 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
async def publish_from_homeassistant(
request: Request,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response:
try:
raw_payload = await request.body()
data = json.loads(raw_payload)
envelope = HomeAssistantPublishEnvelope.model_validate(data)
handle_homeassistant_message(db, envelope, ticktick_client)
handle_homeassistant_message(
db,
envelope,
ticktick_client=ticktick_client,
poo_session=db,
settings=settings,
homeassistant_client=homeassistant_client,
)
except json.JSONDecodeError as exc:
logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
@@ -45,8 +65,14 @@ async def publish_from_homeassistant(
INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except (TickTickConfigError, TickTickRequestError, RuntimeError) as exc:
logger.warning("Home Assistant publish request failed during TickTick handling: %s", exc)
except (
TickTickConfigError,
TickTickRequestError,
HomeAssistantConfigError,
HomeAssistantRequestError,
RuntimeError,
) as exc:
logger.warning("Home Assistant publish request failed during integration handling: %s", exc)
return PlainTextResponse(
INTERNAL_SERVER_ERROR_MESSAGE,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-151
View File
@@ -1,151 +0,0 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
from app.services.auth import AuthenticatedSession
from app.services.config_page import (
ConfigSaveError,
build_config_sections,
is_ticktick_oauth_ready,
save_config_updates,
)
from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"])
logger = logging.getLogger(__name__)
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
if status_value == "success":
return "TickTick authorization completed successfully.", None
if status_value == "invalid-state":
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
if status_value == "invalid-callback":
return None, "TickTick authorization callback was missing required parameters."
if status_value == "failed":
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
return None, None
@router.get("/", response_class=HTMLResponse)
def home(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin", response_class=HTMLResponse)
def admin_redirect(
request: Request,
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> RedirectResponse:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/config", response_class=HTMLResponse)
def config_page(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
request.query_params.get("ticktick_oauth")
)
context = {
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": None,
"config_saved": request.query_params.get("saved") == "1",
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": ticktick_oauth_notice,
"ticktick_oauth_error": ticktick_oauth_error,
}
return templates.TemplateResponse(request, "config.html", context)
@router.post("/config", response_class=HTMLResponse)
async def config_submit(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
settings: Settings = Depends(get_app_settings),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> Response:
if current_auth is None:
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
form = await request.form()
csrf_token = form.get("csrf_token")
if csrf_token != current_auth.session.csrf_token:
logger.warning("Rejected config update due to CSRF validation failure")
context = {
"app_name": settings.app_name,
"app_env": settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": "invalid config update request",
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
}
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
save_config_updates(auth_db_session, dict(form), settings)
except ConfigSaveError:
logger.warning("Rejected config update due to invalid submitted values")
refreshed_settings = get_settings()
context = {
"app_name": refreshed_settings.app_name,
"app_env": refreshed_settings.app_env,
"current_username": current_auth.user.username,
"csrf_token": current_auth.session.csrf_token,
"force_password_change": current_auth.user.force_password_change,
"password_change_error": None,
"config_error": "invalid config submission",
"config_saved": False,
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
"ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings),
"ticktick_redirect_uri": refreshed_settings.ticktick_redirect_uri,
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
}
return templates.TemplateResponse(
request,
"config.html",
context,
status_code=status.HTTP_400_BAD_REQUEST,
)
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+3 -3
View File
@@ -7,7 +7,7 @@ from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import get_app_settings, get_homeassistant_client, get_poo_db
from app.dependencies import get_app_settings, get_homeassistant_client, get_db
from app.integrations.homeassistant import HomeAssistantClient
from app.schemas.poo import PooRecordRequest
from app.services.poo import publish_latest_poo_status, record_poo
@@ -21,7 +21,7 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
@router.post("/poo/record")
async def create_poo_record(
request: Request,
db: Session = Depends(get_poo_db),
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
) -> Response:
@@ -56,7 +56,7 @@ async def create_poo_record(
@router.get("/poo/latest")
def notify_latest_poo(
db: Session = Depends(get_poo_db),
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
) -> Response:
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.dependencies import get_db, get_current_auth_session
from app.schemas.public_ip import PublicIPCheckResponse
from app.config import get_settings
from app.services.auth import AuthenticatedSession
from app.services.public_ip import check_public_ipv4_and_notify
router = APIRouter(tags=["public-ip"])
@router.get("/public-ip/check", response_model=PublicIPCheckResponse)
def run_public_ip_check(
session: Session = Depends(get_db),
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
) -> PublicIPCheckResponse:
if current_auth is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication required")
result = check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
return PublicIPCheckResponse(
status=result.status,
checked_at=result.checked_at,
changed=result.changed,
)
+2 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.dependencies import (
get_app_settings,
get_auth_db,
get_db,
get_current_auth_session,
get_ticktick_client,
)
@@ -39,7 +39,7 @@ def start_ticktick_auth(
@router.get("/ticktick/auth/code")
def handle_ticktick_auth_code(
request: Request,
auth_db_session: Session = Depends(get_auth_db),
auth_db_session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
ticktick_client: TickTickClient = Depends(get_ticktick_client),
) -> Response:
-53
View File
@@ -1,53 +0,0 @@
from collections.abc import Generator
from functools import lru_cache
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class AuthBase(DeclarativeBase):
pass
def _build_connect_args(database_url: str) -> dict[str, object]:
connect_args: dict[str, object] = {}
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
return connect_args
@lru_cache
def _get_auth_engine(database_url: str):
return create_engine(database_url, connect_args=_build_connect_args(database_url))
@lru_cache
def _get_auth_session_local(database_url: str):
engine = _get_auth_engine(database_url)
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
def get_auth_engine():
settings = get_settings()
return _get_auth_engine(settings.app_database_url)
def get_auth_session_local():
settings = get_settings()
return _get_auth_session_local(settings.app_database_url)
def reset_auth_db_caches() -> None:
_get_auth_session_local.cache_clear()
_get_auth_engine.cache_clear()
def get_auth_db_session() -> Generator[Session, None, None]:
session_local = get_auth_session_local()
session = session_local()
try:
yield session
finally:
session.close()
+51 -13
View File
@@ -12,9 +12,6 @@ class Settings(BaseSettings):
app_hostname: str = "localhost:8000"
app_database_url: str = "sqlite:///./data/app.db"
location_database_url: str = "sqlite:///./data/locationRecorder.db"
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
ticktick_client_id: str = ""
ticktick_client_secret: str = ""
ticktick_token: str = ""
@@ -23,6 +20,15 @@ class Settings(BaseSettings):
home_assistant_auth_token: str = ""
home_assistant_timeout_seconds: float = 1.0
home_assistant_action_task_project_id: str = ""
smtp_enabled: bool = False
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_from_name: str = ""
smtp_from_address: str = ""
smtp_to_address: str = ""
smtp_use_starttls: bool = True
poo_webhook_id: str = ""
poo_sensor_entity_name: str = "sensor.test_poo_status"
poo_sensor_friendly_name: str = "Poo Status"
@@ -31,6 +37,42 @@ class Settings(BaseSettings):
auth_session_cookie_name: str = "home_automation_session"
auth_session_ttl_hours: int = 12
auth_cookie_secure_override: bool | None = True
auth_login_throttle_enabled: bool = True
auth_trust_forwarded_for: bool = False
auth_totp_issuer: str = "" # defaults to app_name when empty
# Modbus polling — global kill-switch (CONFIG_FIELDS registered in T08).
# Off by default: polling is opt-in so a fresh deploy writes no modbus_reading
# rows until the admin explicitly enables it (matches mqtt/discovery defaults).
modbus_polling_enabled: bool = False
# MQTT broker connection (T08 wires into CONFIG_FIELDS/UI; T10 builds the client).
mqtt_enabled: bool = False
mqtt_broker_host: str = ""
mqtt_broker_port: int = 1883
mqtt_username: str = ""
mqtt_password: str = ""
mqtt_tls_enabled: bool = False
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
ha_discovery_enabled: bool = False
ha_discovery_prefix: str = "homeassistant"
# State/availability topics use a separate prefix so they live outside the HA discovery
# namespace. HA still receives state via the state_topic declared in the discovery config.
ha_state_topic_prefix: str = "home_automation"
# DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
# Subscribe to dsmr_mqtt_topic when dsmr_ingest_enabled=True.
dsmr_ingest_enabled: bool = False
dsmr_mqtt_topic: str = "dsmr/json"
dsmr_sample_interval_s: int = 10
# DSMR dual-tariff topic: publishes "1" (dal/off-peak) or "2" (normal/peak).
# Empty string = do not subscribe (tariff-aware pricing disabled).
dsmr_tariff_topic: str = "dsmr/meter-stats/electricity_tariff"
# Tibber dynamic pricing credentials (M6; only used when active contract kind=tibber).
tibber_api_token: str = ""
tibber_home_id: str = ""
model_config = SettingsConfigDict(
env_file=".env",
@@ -68,21 +110,11 @@ class Settings(BaseSettings):
raw_path = database_url[len(prefix) :]
return Path(raw_path)
@computed_field
@property
def location_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.location_database_url)
@computed_field
@property
def app_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.app_database_url)
@computed_field
@property
def poo_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.poo_database_url)
@computed_field
@property
def auth_cookie_secure(self) -> bool:
@@ -90,6 +122,12 @@ class Settings(BaseSettings):
return self.auth_cookie_secure_override
return not self.is_development
@computed_field
@property
def effective_totp_issuer(self) -> str:
"""The issuer label shown in Authenticator apps. Falls back to app_name."""
return self.auth_totp_issuer.strip() or self.app_name
@lru_cache
def get_settings() -> Settings:
+42 -8
View File
@@ -1,6 +1,8 @@
from collections.abc import Generator
from functools import lru_cache
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
@@ -10,18 +12,50 @@ class Base(DeclarativeBase):
pass
settings = get_settings()
def _build_connect_args(database_url: str) -> dict[str, object]:
connect_args: dict[str, object] = {}
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
return connect_args
connect_args: dict[str, object] = {}
if settings.location_database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(settings.location_database_url, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
@lru_cache
def _get_engine(database_url: str) -> Engine:
engine = create_engine(database_url, connect_args=_build_connect_args(database_url))
if database_url.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _enable_sqlite_wal(dbapi_connection, _connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
@lru_cache
def _get_session_local(database_url: str) -> sessionmaker:
engine = _get_engine(database_url)
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
def get_engine() -> Engine:
return _get_engine(get_settings().app_database_url)
def get_session_local() -> sessionmaker:
return _get_session_local(get_settings().app_database_url)
def reset_db_caches() -> None:
_get_session_local.cache_clear()
_get_engine.cache_clear()
def get_db_session() -> Generator[Session, None, None]:
session = SessionLocal()
session_local = get_session_local()
session = session_local()
try:
yield session
finally:
+3 -13
View File
@@ -3,30 +3,20 @@ from collections.abc import Generator
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.auth_db import get_auth_db_session
from app.config import Settings, get_settings
from app.db import get_db_session
from app.integrations.homeassistant import HomeAssistantClient
from app.integrations.ticktick import TickTickClient
from app.poo_db import get_poo_db_session
from app.services.auth import AuthenticatedSession, get_authenticated_session
from app.services.config_page import build_runtime_settings
def get_auth_db() -> Generator[Session, None, None]:
yield from get_auth_db_session()
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
return build_runtime_settings(session, get_settings())
def get_db() -> Generator[Session, None, None]:
yield from get_db_session()
def get_poo_db() -> Generator[Session, None, None]:
yield from get_poo_db_session()
def get_app_settings(session: Session = Depends(get_db)) -> Settings:
return build_runtime_settings(session, get_settings())
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
@@ -39,7 +29,7 @@ def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickT
def get_current_auth_session(
request: Request,
session: Session = Depends(get_auth_db),
session: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> AuthenticatedSession | None:
raw_token = request.cookies.get(settings.auth_session_cookie_name)
+879
View File
@@ -0,0 +1,879 @@
"""Expose framework: ExposableEntity, provider protocol, and build_catalog.
This module defines:
- ``ExposableEntity`` — a dataclass describing one publishable MQTT/HA entity.
- Provider protocol — a simple callable ``(session) -> list[ExposableEntity]``.
- A provider registry and registration helper.
- ``build_catalog(session)`` — merges all registered providers' entities with
per-key toggle state from the ``exposed_entity_toggle`` table.
Design notes
------------
- **No over-engineering**: providers are plain callables, not abstract base
classes. This project is personal / single-user; keep it flat.
- **Key stability**: entity keys MUST be derived from device uuid + metric key
(e.g. ``"modbus.<uuid>.voltage"``), never from auto-increment IDs. Stable
keys mean the toggle table survives device removal/re-addition without drift.
- **Metadata from profile**: the modbus provider reads ``device_class``,
``unit``, and ``component`` directly from the YAML profile's ``metrics[]``
rather than maintaining a separate mapping.
- **value_getter**: a callable ``() -> object | None`` or ``None`` if not yet
implemented (T11 will wire real getters). Presence of the field lets T11
call it without changing the dataclass interface.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, Callable, Optional, Protocol
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# ExposableEntity
# ---------------------------------------------------------------------------
@dataclass
class DeviceInfo:
"""Grouping metadata that maps an entity to a logical HA device.
``identifiers`` corresponds to ``device.identifiers`` in HA Discovery
config — a stable tuple used to group entities under one device card.
``name`` is the human-readable device name (friendly_name).
"""
identifiers: tuple[str, ...]
"""Stable identifiers for HA device grouping (e.g. ``("modbus", "<uuid>")``).
These must not change over the device lifetime; they anchor the HA device
card even when friendly_name changes.
"""
name: str
"""Human-readable device name (may change; triggers HA discovery re-publish)."""
provides_availability: bool = True
"""Whether this device publishes an availability ("online"/"offline") heartbeat.
When True (the default — e.g. Modbus devices, which have an ``online``
binary_sensor driving availability), the HA Discovery config for each entity
declares an ``availability`` topic and HA marks the entity unavailable until
"online" is published.
When False (e.g. the energy-cost device, which has only sensors and no
heartbeat source), the config omits ``availability`` so HA treats the entity
as *always available* and shows its state as soon as one arrives. Without
this, such entities would stay perpetually ``unavailable`` in HA even though
their state is being published.
"""
@dataclass
class ExposableEntity:
"""One publishable entity in the MQTT / HA Discovery expose framework.
Fields
------
key
Stable unique identifier for this entity. Used as the toggle table
key and as part of the HA Discovery ``unique_id`` derivation.
Convention: ``"<provider>.<device_uuid>.<metric_key>"``,
e.g. ``"modbus.abc123.voltage"`` or ``"modbus.abc123.online"``.
component
HA MQTT component type: ``"sensor"``, ``"binary_sensor"``, ``"switch"``, etc.
device
Grouping info (DeviceInfo) that determines which HA device card this
entity belongs to.
device_class
HA ``device_class`` string (e.g. ``"voltage"``, ``"power"``, ``"None"``).
Empty string or ``None`` means no device_class (e.g. for the online sensor).
unit
Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``).
Empty string for dimensionless.
name
Human-readable entity label (friendly display name, may include device
context, e.g. ``"SDM120 Voltage"``).
value_getter
Optional callable ``(session) -> object | None`` that returns the current
entity value given an open SQLAlchemy session. ``None`` means "not yet
wired". T11 calls this to obtain the current state before publishing.
Signature: ``Callable[[Session], Any]``.
state_class
HA ``state_class`` string (e.g. ``"measurement"``, ``"total_increasing"``).
``None`` means not set.
"""
key: str
component: str
device: DeviceInfo
device_class: Optional[str]
unit: str
name: str
value_getter: Optional[Callable[["Session"], Any]] = field(default=None, repr=False)
state_class: Optional[str] = None
# ---------------------------------------------------------------------------
# Provider protocol
# ---------------------------------------------------------------------------
class EntityProvider(Protocol):
"""Protocol for entity provider callables.
A provider is any callable that accepts a ``Session`` and returns a list
of ``ExposableEntity`` objects. Using a Protocol (not ABC) keeps the
implementation lightweight — any matching callable qualifies.
"""
def __call__(self, session: Session) -> list[ExposableEntity]: ...
# ---------------------------------------------------------------------------
# Provider registry
# ---------------------------------------------------------------------------
_REGISTRY: list[EntityProvider] = []
def register_provider(provider: EntityProvider) -> EntityProvider:
"""Register an entity provider.
Can be used as a decorator::
@register_provider
def my_provider(session: Session) -> list[ExposableEntity]:
...
Or called directly::
register_provider(my_provider)
Returns the provider unchanged (for decorator compatibility).
"""
_REGISTRY.append(provider)
return provider
def get_providers() -> list[EntityProvider]:
"""Return a snapshot of the currently registered providers."""
return list(_REGISTRY)
# ---------------------------------------------------------------------------
# Catalog builder
# ---------------------------------------------------------------------------
@dataclass
class CatalogEntry:
"""An entity from the catalog, enriched with its current toggle state."""
entity: ExposableEntity
enabled: bool
"""True if this entity is currently enabled in the toggle table."""
def build_catalog(session: Session) -> list[CatalogEntry]:
"""Enumerate all entities from registered providers and attach toggle state.
For each entity key, the toggle state is looked up in ``exposed_entity_toggle``.
Entities with no toggle row default to ``enabled=False``.
Parameters
----------
session:
Active SQLAlchemy session for DB access.
Returns
-------
list[CatalogEntry]
All entities from all registered providers, each paired with its
current ``enabled`` state.
"""
from app.models.expose import ExposedEntityToggle # local import to avoid circular
# Collect all entities from all providers.
all_entities: list[ExposableEntity] = []
for provider in _REGISTRY:
try:
entities = provider(session)
all_entities.extend(entities)
except Exception:
logger.exception("Provider %r raised an error; skipping its entities", provider)
if not all_entities:
return []
# Load all toggle rows in one query for efficiency.
keys = [e.key for e in all_entities]
toggle_rows = session.query(ExposedEntityToggle).filter(
ExposedEntityToggle.key.in_(keys)
).all()
toggle_map: dict[str, bool] = {row.key: row.enabled for row in toggle_rows}
return [
CatalogEntry(entity=entity, enabled=toggle_map.get(entity.key, False))
for entity in all_entities
]
# ---------------------------------------------------------------------------
# Modbus provider
# ---------------------------------------------------------------------------
def _modbus_provider(session: Session) -> list[ExposableEntity]:
"""Enumerate ExposableEntity objects for all enabled Modbus devices.
For each enabled ``ModbusDevice``:
- Loads its YAML profile.
- Produces one ``sensor`` entity per metric in ``profile.metrics``
(device_class / unit / component taken from the metric spec).
- Produces one ``binary_sensor`` entity named "online" (derived from
``device.last_poll_ok``).
Entity keys follow the pattern ``"modbus.<uuid>.<metric_key>"``, which is
stable across device renames and DB rebuilds.
"""
from app.models.modbus import ModbusDevice # local import
from app.integrations.modbus.profiles import (
load_profile,
ProfileNotFoundError,
ProfileValidationError,
)
devices: list[ModbusDevice] = session.query(ModbusDevice).filter(
ModbusDevice.enabled.is_(True)
).all()
entities: list[ExposableEntity] = []
for device in devices:
device_info = DeviceInfo(
identifiers=("modbus", device.uuid),
name=device.friendly_name,
)
# Load the profile to get metric metadata.
try:
profile = load_profile(device.profile)
except (ProfileNotFoundError, ProfileValidationError) as exc:
logger.warning(
"Skipping device %r (uuid=%s): cannot load profile %r: %s",
device.friendly_name,
device.uuid,
device.profile,
exc,
)
continue
# One sensor entity per profile metric.
for metric in profile.metrics:
entity_key = f"modbus.{device.uuid}.{metric.key}"
# Capture device id and metric key for the value_getter closure.
# The getter queries the most recent ModbusReading for this device
# and extracts the metric value from its payload.
_device_id = device.id
_metric_key = metric.key
def _make_value_getter(
dev_id: int, m_key: str
) -> Callable[["Session"], Any]:
"""Return a getter that fetches the latest reading value from DB."""
def _getter(sess: "Session") -> Any:
from app.models.modbus import ModbusReading
from sqlalchemy import desc
reading = (
sess.query(ModbusReading)
.filter(ModbusReading.device_id == dev_id)
.order_by(desc(ModbusReading.recorded_at))
.first()
)
if reading is None:
return None
return reading.payload.get(m_key)
return _getter
entities.append(
ExposableEntity(
key=entity_key,
component=metric.ha_component,
device=device_info,
device_class=metric.device_class or None,
unit=metric.unit,
name=f"{device.friendly_name} {metric.key.replace('_', ' ').title()}",
value_getter=_make_value_getter(_device_id, _metric_key),
state_class=metric.state_class,
)
)
# One binary_sensor entity for device "online" status.
online_key = f"modbus.{device.uuid}.online"
_dev_id_online = device.id
def _make_online_getter(dev_id: int) -> Callable[["Session"], Any]:
"""Return a getter that reports the device online status from DB."""
def _getter(sess: "Session") -> Any:
from app.models.modbus import ModbusDevice
dev = sess.query(ModbusDevice).filter(
ModbusDevice.id == dev_id
).first()
if dev is None:
return None
# Map last_poll_ok to HA binary_sensor payload strings.
if dev.last_poll_ok is True:
return "ON"
return "OFF"
return _getter
entities.append(
ExposableEntity(
key=online_key,
component="binary_sensor",
device=device_info,
device_class="connectivity",
unit="",
name=f"{device.friendly_name} Online",
value_getter=_make_online_getter(_dev_id_online),
state_class=None,
)
)
return entities
# Register the modbus provider at module load time.
register_provider(_modbus_provider)
# ---------------------------------------------------------------------------
# Energy Cost provider
# ---------------------------------------------------------------------------
# *_today 当日窗口翻天的宽限:本地午夜后 5 秒才切到新的一天,避免在 00:00:0x 把
# 归零后的值发出去、被慢几秒的 HA 钟记成前一天的 23:59:59(归错小时桶)。
_TODAY_RESET_GRACE = timedelta(seconds=5)
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
"""Enumerate ExposableEntity objects for the energy cost subsystem.
Produces 6 sensor entities grouped under a single HA device whose identity
is anchored to the **current active electricity meter**:
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
- ``import_cost_total`` — cumulative import cost (total, monetary).
- ``export_revenue_total`` — cumulative export revenue (total, monetary).
- ``import_cost_today`` — today's import cost (total_increasing, monetary).
- ``export_revenue_today`` — today's export revenue (total_increasing, monetary).
Active meter requirement
------------------------
**If no active electricity meter exists, the provider returns ``[]``.**
No energy-cost entities are exposed to HA until a meter has been declared.
This prevents spurious sensor creation with an undefined device identity.
HA device identity (换表 → 新 sensor)
--------------------------------------
``identifiers[1]`` is set to the active meter's **uuid** (not the fixed
string ``"energy-cost"``). ``ha_discovery.py`` uses ``identifiers[1]`` as
the MQTT node_id and as part of the ``unique_id`` for every entity.
Declaring a new active electricity meter produces a new uuid → new node_id /
unique_id → HA creates a brand-new sensor, cleanly isolating post-swap data.
Entity key stability
--------------------
Entity keys remain the fixed stable strings (``"energy.buy_price_now"`` etc.),
**not** derived from the meter uuid. The ``exposed_entity_toggle`` table uses
keys as its primary handle; keeping them stable means toggled-on entities stay
enabled after a meter swap without requiring the user to re-tick them.
Current-price algorithm (source-agnostic, with fallback)
---------------------------------------------------------
Strategy A: read the ``pricing`` snapshot from the most recent non-degraded
``energy_cost_period`` row.
- For ``kind="tibber"``: the snapshot contains ``"buy"`` and ``"sell"`` keys
(per-unit prices in the contract currency).
- For ``kind="manual"``: ``"buy_normal"`` and ``"sell_normal"`` are used as
representative effective per-unit prices. (Both tariff-slot prices differ
only by the base rate; energy_tax and ODE are the same for both, so
buy_normal is the higher/conservative single representative value.)
When no non-degraded period exists or the pricing snapshot lacks the
expected keys, ``value_getter`` returns ``None`` (``publish_states``
skips None values automatically).
Cumulative totals
-----------------
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
``energy_cost_period`` rows within the current meter's window. Degraded rows
carry 0 costs and are excluded to avoid double-counting when they are later
overwritten by real values.
Currency
--------
Taken from the most recent non-degraded period's ``currency`` column.
Falls back to ``"EUR"`` when no such row exists.
Key convention
--------------
Fixed stable string keys (not derived from any mutable field or DB id):
- ``"energy.buy_price_now"``
- ``"energy.sell_price_now"``
- ``"energy.import_cost_total"``
- ``"energy.export_revenue_total"``
- ``"energy.import_cost_today"``
- ``"energy.export_revenue_today"``
DeviceInfo identifiers
----------------------
**Two-element tuple** ``("energy-cost", meter.uuid)`` so that
``ha_discovery.py``'s ``entity.device.identifiers[1]`` resolves to the
meter uuid (used as the MQTT node_id and unique_id seed throughout).
"""
from app.models.energy import EnergyCostPeriod, Meter # local import to avoid circular
from sqlalchemy import select
# --- Require an active electricity meter; return [] if none exists ---
# Using an inline query (ended_at IS NULL) rather than a service-layer helper
# to avoid a new public dependency and remain consistent with the value_getter
# implementations below (which use the same inline pattern).
active_meter: Meter | None = session.execute(
select(Meter)
.where(
Meter.commodity == "electricity",
Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
if active_meter is None:
# No active electricity meter → do not expose any energy-cost entities.
# HA will not see these sensors until a meter is declared.
return []
# --- Determine currency from the latest non-degraded row ---
latest_period: EnergyCostPeriod | None = (
session.query(EnergyCostPeriod)
.filter(EnergyCostPeriod.degraded.is_(False))
.order_by(EnergyCostPeriod.period_start.desc())
.first()
)
currency: str = "EUR"
if latest_period is not None and latest_period.currency:
currency = latest_period.currency
# --- Shared DeviceInfo anchored to the active meter's uuid ---
# identifiers[1] = meter.uuid drives the MQTT node_id and unique_id in
# ha_discovery.py. Swapping the meter produces a new uuid → new HA sensor.
# provides_availability=False: the energy-cost device has only sensors and no
# online/offline heartbeat, so its entities must be "always available" in HA.
# (Otherwise HA shows them unavailable despite state being published.)
device_info = DeviceInfo(
identifiers=("energy-cost", active_meter.uuid),
name=active_meter.label,
provides_availability=False,
)
# --- value_getter: current buy price ---
def _make_buy_price_getter() -> Callable[["Session"], Any]:
"""Return a getter for the current buy price per kWh.
For ``kind="manual"`` contracts the getter selects the price slot that
matches the current DSMR electricity tariff:
- tariff == 1 (dal / off-peak) → ``buy_dal``
- tariff == 2 (normal / peak) → ``buy_normal``
- tariff is None / unknown → ``buy_normal`` (safe fallback = current status quo)
For ``kind="tibber"`` (hourly dynamic pricing) the tariff slot is not
applicable; the getter always uses the ``buy`` key from the snapshot.
"""
def _getter(sess: "Session") -> Any:
from app.models.energy import EnergyCostPeriod as _ECP
from app.services.dsmr_ingest import get_current_tariff
period = (
sess.query(_ECP)
.filter(_ECP.degraded.is_(False))
.order_by(_ECP.period_start.desc())
.first()
)
if period is None or not period.pricing:
return None
snap = period.pricing
kind = snap.get("kind")
if kind == "tibber":
raw = snap.get("buy")
elif kind == "manual":
tariff = get_current_tariff()
if tariff == 1:
raw = snap.get("buy_dal")
else:
# tariff == 2, None, or any unexpected value → normal (peak) rate.
raw = snap.get("buy_normal")
else:
# Unknown kind — attempt common keys gracefully.
raw = snap.get("buy") or snap.get("buy_normal")
if raw is None:
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
return _getter
# --- value_getter: current sell price ---
def _make_sell_price_getter() -> Callable[["Session"], Any]:
"""Return a getter for the current sell price per kWh.
For ``kind="manual"`` contracts the getter selects the price slot that
matches the current DSMR electricity tariff:
- tariff == 1 (dal / off-peak) → ``sell_dal``
- tariff == 2 (normal / peak) → ``sell_normal``
- tariff is None / unknown → ``sell_normal`` (safe fallback = current status quo)
For ``kind="tibber"`` the tariff slot is not applicable; always uses ``sell``.
"""
def _getter(sess: "Session") -> Any:
from app.models.energy import EnergyCostPeriod as _ECP
from app.services.dsmr_ingest import get_current_tariff
period = (
sess.query(_ECP)
.filter(_ECP.degraded.is_(False))
.order_by(_ECP.period_start.desc())
.first()
)
if period is None or not period.pricing:
return None
snap = period.pricing
kind = snap.get("kind")
if kind == "tibber":
raw = snap.get("sell")
elif kind == "manual":
tariff = get_current_tariff()
if tariff == 1:
raw = snap.get("sell_dal")
else:
# tariff == 2, None, or any unexpected value → normal (peak) rate.
raw = snap.get("sell_normal")
else:
raw = snap.get("sell") or snap.get("sell_normal")
if raw is None:
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
return _getter
# --- value_getter: cumulative import cost (incl. standing charges) ---
def _make_import_cost_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative import cost plus prorated standing charges.
D2 (M7): anchor = current active electricity meter's ``started_at``.
After a meter swap the cumulative resets to zero for the new meter —
old-meter periods have ``period_start < new_meter.started_at`` and fall
outside the [anchor, now) window, so they are naturally excluded.
Cross-meter boundary periods are already degraded and also excluded.
No active electricity meter → returns None (cannot anchor; safer than
returning a stale or wrong value). The check is done via an inline
query (``ended_at IS NULL``) rather than ``meter_at(now)`` to be robust
against the edge case where the active meter's ``started_at`` is in the
future (``meter_at(now)`` would return None in that scenario).
No non-degraded periods at all → returns None (has_data guard).
No active contract → ``summarize`` still runs but fixed_costs = 0;
the return value is the pure metered sum within the current meter window.
This is consistent with D2: the anchor is the meter, not the contract.
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
"""
def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt
from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.energy_cost import summarize as _summarize
from sqlalchemy import func as _func, select as _select
# Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
_ECP.degraded.is_(False)
).scalar()
if has_data is None:
return None
# D2: anchor = active electricity meter's started_at.
# Inline query (ended_at IS NULL) is more robust than meter_at(now)
# because it avoids the edge case where started_at is in the future.
active_meter = sess.execute(
_select(_Meter)
.where(
_Meter.commodity == "electricity",
_Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
if active_meter is None:
# No active electricity meter — cannot anchor; return None.
return None
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc)
return result["metered_import"] + result["fixed_costs"]
return _getter
# --- value_getter: cumulative export revenue (incl. tax credit) ---
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative export revenue plus prorated tax credit.
D2 (M7): anchor = current active electricity meter's ``started_at``.
Same reasoning as the import cost getter — see its docstring.
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
Returns None when no non-degraded period exists or no active electricity meter.
"""
def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt
from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.energy_cost import summarize as _summarize
from sqlalchemy import func as _func, select as _select
# Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
_ECP.degraded.is_(False)
).scalar()
if has_data is None:
return None
# D2: anchor = active electricity meter's started_at.
active_meter = sess.execute(
_select(_Meter)
.where(
_Meter.commodity == "electricity",
_Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
if active_meter is None:
# No active electricity meter — cannot anchor; return None.
return None
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc)
return result["metered_export"] + result["credits"]
return _getter
# --- value_getter: today's import cost (local-day window, resets at local midnight) ---
def _make_import_cost_today_getter() -> Callable[["Session"], Any]:
"""Return a getter for today's import cost (metered + standing for today).
Window: [local today 00:00, local tomorrow 00:00) in UTC.
After local midnight the window rolls to the new day → value resets to that
day's running cost, implementing "resets at local midnight" semantics for HA.
Uses timezone module via module-attribute access to preserve monkeypatch safety.
Returns None when no non-degraded period exists or no active contract.
"""
def _getter(sess: "Session") -> Any:
from app.services.contracts import active_contract_versions
from app.models.energy import EnergyCostPeriod as _ECP
from app.services.energy_cost import summarize as _summarize
from app.services import timezone as _tz_mod
from sqlalchemy import func as _func
from datetime import timedelta as _td
# Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
_ECP.degraded.is_(False)
).scalar()
if has_data is None:
return None
versions = active_contract_versions(sess)
if not versions:
return None
# Today's window in UTC, using monkeypatch-safe module attribute calls.
# Grace: subtract _TODAY_RESET_GRACE so that in the first 5 seconds after
# local midnight the getter still returns yesterday's window. This prevents
# a "归零后的值" from being published while HA's clock (which may lag a few
# seconds) would stamp it as 23:59:59 of the previous day.
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
tomorrow_local = today_local + _td(days=1)
today_start_utc = _tz_mod.local_midnight_utc(today_local)
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
return result["metered_import"] + result["fixed_costs"]
return _getter
# --- value_getter: today's export revenue (local-day window, resets at local midnight) ---
def _make_export_revenue_today_getter() -> Callable[["Session"], Any]:
"""Return a getter for today's export revenue (metered + tax credit for today).
Same window semantics as import_cost_today.
Returns None when no non-degraded period exists or no active contract.
"""
def _getter(sess: "Session") -> Any:
from app.services.contracts import active_contract_versions
from app.models.energy import EnergyCostPeriod as _ECP
from app.services.energy_cost import summarize as _summarize
from app.services import timezone as _tz_mod
from sqlalchemy import func as _func
from datetime import timedelta as _td
# Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
_ECP.degraded.is_(False)
).scalar()
if has_data is None:
return None
versions = active_contract_versions(sess)
if not versions:
return None
# Grace: same logic as import_cost_today — see that getter's comment.
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
tomorrow_local = today_local + _td(days=1)
today_start_utc = _tz_mod.local_midnight_utc(today_local)
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
return result["metered_export"] + result["credits"]
return _getter
# Price unit string: "<currency>/kWh"
price_unit = f"{currency}/kWh"
entities: list[ExposableEntity] = [
ExposableEntity(
key="energy.buy_price_now",
component="sensor",
device=device_info,
device_class=None,
unit=price_unit,
name="Energy Buy Price Now",
value_getter=_make_buy_price_getter(),
state_class="measurement",
),
ExposableEntity(
key="energy.sell_price_now",
component="sensor",
device=device_info,
device_class=None,
unit=price_unit,
name="Energy Sell Price Now",
value_getter=_make_sell_price_getter(),
state_class="measurement",
),
ExposableEntity(
key="energy.import_cost_total",
component="sensor",
device=device_info,
device_class="monetary",
unit=currency,
name="Energy Import Cost (incl. standing)",
value_getter=_make_import_cost_getter(),
state_class="total",
),
ExposableEntity(
key="energy.export_revenue_total",
component="sensor",
device=device_info,
device_class="monetary",
unit=currency,
name="Energy Export Revenue (incl. tax credit)",
value_getter=_make_export_revenue_getter(),
state_class="total",
),
# Daily entities: reset at local midnight, state_class=total_increasing.
#
# device_class=monetary is correct here. The HA developer docs (long-term
# statistics section) prohibit MONETARY from being combined with
# state_class=measurement — they do NOT prohibit it with total or
# total_increasing. monetary+total_increasing is valid (the cumulative
# entities above use monetary+total with no issue).
#
# total_increasing is appropriate because these daily quantities are
# non-negative (import/export metered cost ≥ 0, fixed standing fee/tax
# credit ≥ 0) and monotonically non-decreasing within any given local day.
# When the local midnight rolls over the value drops back to ~1 day's fixed
# fee; HA interprets that decrease as a new cycle, giving correct daily
# aggregation without any explicit last_reset pipeline.
ExposableEntity(
key="energy.import_cost_today",
component="sensor",
device=device_info,
device_class="monetary",
unit=currency,
name="Energy Import Cost Today (incl. standing)",
value_getter=_make_import_cost_today_getter(),
state_class="total_increasing",
),
ExposableEntity(
key="energy.export_revenue_today",
component="sensor",
device=device_info,
device_class="monetary",
unit=currency,
name="Energy Export Revenue Today (incl. tax credit)",
value_getter=_make_export_revenue_today_getter(),
state_class="total_increasing",
),
]
return entities
# Register the energy cost provider at module load time.
register_provider(_energy_cost_provider)
+1
View File
@@ -0,0 +1 @@
"""Modbus integration package — driver, profile loader, and decoder."""
+230
View File
@@ -0,0 +1,230 @@
"""Modbus TCP driver — thin wrapper around pymodbus.
This module provides a single public function ``read_blocks`` that performs
one or more block reads against a Modbus TCP gateway — using either FC04
(Read Input Registers) or FC03 (Read Holding Registers), selected per call
via the ``function_code`` argument — and returns a flat
``dict[register_address -> 16-bit_value]`` map. The function code comes from
the device profile (e.g. SDM120 uses FC04, DDSU666 uses FC03).
Design decisions
----------------
- **Only read function codes** (FC03/FC04) are exposed. There is no write path.
- float32 decoding uses ``struct.unpack('>f', ...)`` directly rather than the
pymodbus ``BinaryPayloadDecoder`` helper, which has had API churn across 3.x
releases. Big-endian word order + big-endian byte order means the 4 raw bytes
are already in standard network (big-endian) order.
- ``ModbusTcpClient`` is used in synchronous mode (``connect()`` / ``close()``).
The client is created fresh per call; this keeps the driver stateless and
avoids threading concerns at the cost of one TCP handshake per read cycle.
APScheduler jobs call this from a thread pool, so stateless is safer.
- pymodbus 3.13.x uses ``device_id=`` as the slave-address keyword argument
(renamed from ``slave=`` in earlier 3.x releases).
Exceptions
----------
``ModbusDriverError``
Base exception for all driver-level errors.
``ModbusConnectionError``
Raised when the TCP connection to the gateway cannot be established or is
lost during the read.
``ModbusResponseError``
Raised when the gateway responds with a Modbus exception frame or when the
returned register count does not match the requested count.
"""
from __future__ import annotations
import logging
import struct
from typing import TYPE_CHECKING
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException, ModbusException
if TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class ModbusDriverError(RuntimeError):
"""Base class for all Modbus driver errors."""
class ModbusConnectionError(ModbusDriverError):
"""Cannot connect to (or lost connection with) the Modbus TCP gateway."""
class ModbusResponseError(ModbusDriverError):
"""Modbus gateway returned an exception frame or an unexpected response."""
# ---------------------------------------------------------------------------
# Block definition type alias
# ---------------------------------------------------------------------------
#: A single read block: ``{"start": int, "count": int}``.
Block = dict[str, int]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def read_blocks(
host: str,
port: int,
unit_id: int,
blocks: Sequence[Block],
*,
function_code: int = 4,
timeout: float = 3.0,
) -> dict[int, int]:
"""Read one or more contiguous register blocks via FC03 or FC04.
Parameters
----------
host:
Hostname or IP address of the Modbus TCP gateway.
port:
TCP port (typically 502).
unit_id:
Modbus slave / unit address (the device's Meter ID for SDM120).
blocks:
Sequence of ``{"start": int, "count": int}`` dicts describing the
contiguous register ranges to read. ``count`` is the number of
16-bit registers (not bytes).
function_code:
Modbus read function code: ``4`` for FC04 (Read Input Registers,
default — SDM120) or ``3`` for FC03 (Read Holding Registers —
DDSU666 and other devices that expose measurements as holding
registers). Comes from the device profile's ``function_code`` field.
timeout:
TCP connect/read timeout in seconds (default 3 s).
Returns
-------
dict[int, int]
Flat mapping of ``{register_address: 16-bit_register_value}`` for
every register returned across all blocks.
Raises
------
ModbusDriverError
If ``function_code`` is neither 3 nor 4 (validated before any
connection is attempted).
ModbusConnectionError
If the TCP connection to the gateway fails.
ModbusResponseError
If the gateway returns a Modbus exception frame or an unexpected
number of registers.
"""
if function_code not in (3, 4):
raise ModbusDriverError(
f"Unsupported read function code FC{function_code:02d} "
f"(only FC03 holding-register and FC04 input-register reads are supported)"
)
client = ModbusTcpClient(host, port=port, timeout=timeout)
try:
connected = client.connect()
if not connected:
raise ModbusConnectionError(
f"Could not connect to Modbus gateway at {host}:{port}"
)
registers: dict[int, int] = {}
for block in blocks:
start: int = block["start"]
count: int = block["count"]
_read_block(client, unit_id, start, count, registers, function_code=function_code)
return registers
except ConnectionException as exc:
raise ModbusConnectionError(
f"Connection to Modbus gateway {host}:{port} failed: {exc}"
) from exc
except ModbusException as exc:
raise ModbusResponseError(f"Modbus protocol error: {exc}") from exc
finally:
client.close()
def _read_block(
client: ModbusTcpClient,
unit_id: int,
start: int,
count: int,
result: dict[int, int],
*,
function_code: int,
) -> None:
"""Read one block and merge into *result*. Raises on any error.
``function_code`` is assumed already validated to be 3 or 4 by the caller
(``read_blocks``); 3 dispatches FC03 (holding) and 4 dispatches FC04 (input).
"""
try:
if function_code == 3:
response = client.read_holding_registers(start, count=count, device_id=unit_id)
else: # function_code == 4 (input registers)
response = client.read_input_registers(start, count=count, device_id=unit_id)
except ConnectionException as exc:
raise ModbusConnectionError(
f"Lost connection while reading registers 0x{start:04X}+{count}: {exc}"
) from exc
if response.isError():
raise ModbusResponseError(
f"Modbus exception reading registers 0x{start:04X}+{count}: {response}"
)
regs = response.registers
if len(regs) != count:
raise ModbusResponseError(
f"Expected {count} registers from 0x{start:04X}, got {len(regs)}"
)
for offset, value in enumerate(regs):
result[start + offset] = value
# ---------------------------------------------------------------------------
# Decoding helpers
# ---------------------------------------------------------------------------
def registers_to_float(hi_reg: int, lo_reg: int) -> float:
"""Decode two 16-bit registers to a big-endian IEEE-754 float32.
The SDM120 (and most Modbus float devices) use big-endian word order:
the high 16-bit register comes first, then the low register. Combined
with big-endian byte order within each register, the raw 4-byte sequence
is standard network-byte-order (big-endian) float32.
Parameters
----------
hi_reg:
The first (higher-address-range, most-significant) 16-bit register.
lo_reg:
The second (lower-address-range, least-significant) 16-bit register.
Returns
-------
float
The decoded IEEE-754 single-precision floating-point value.
Example
-------
>>> registers_to_float(0x4366, 0x3334)
230.20001220703125
"""
raw = struct.pack(">HH", hi_reg, lo_reg)
return struct.unpack(">f", raw)[0]
+231
View File
@@ -0,0 +1,231 @@
"""Modbus profile loader, validator, and decoder.
A *profile* is a YAML file that describes the protocol-level knowledge for
a particular Modbus device model:
- Which registers to read (``blocks`` for efficient bulk reads).
- How to decode each quantity (``metrics``: register address, type, unit, …).
- Home Assistant metadata per metric (``device_class``, ``ha_component``, …).
Profiles live in ``app/integrations/modbus/profiles/<name>.yaml`` and are
located at runtime relative to *this file* (not CWD), so they work whether
the package is installed as a wheel, run in-process, or invoked via
``python -m scripts.modbus_cli``.
Design notes
------------
- **Purely data + functions** — no abstract base classes or inheritance.
- ``ModbusProfile`` and ``MetricSpec`` are Pydantic models: they validate on
construction and raise ``pydantic.ValidationError`` for malformed YAML.
- ``decode`` uses ``driver.registers_to_float`` for float32 quantities.
Unsupported types are silently skipped with a warning (future-proofing for
int16/uint16 etc.).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
import yaml
from pydantic import BaseModel, ValidationError
from app.integrations.modbus.driver import registers_to_float
logger = logging.getLogger(__name__)
# Directory containing the YAML profiles, located relative to *this* file.
_PROFILES_DIR = Path(__file__).parent / "profiles"
# ---------------------------------------------------------------------------
# Pydantic models for profile validation
# ---------------------------------------------------------------------------
class MetricSpec(BaseModel):
"""Specification for a single measurable quantity within a profile."""
key: str
"""Stable identifier used as the key in decoded payloads (e.g. ``"voltage"``)."""
address: int
"""Starting Modbus register address (0-based, e.g. ``0x0000`` for voltage)."""
type: str
"""Encoding type — currently only ``"float32"`` is supported."""
unit: str
"""Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``). Empty string for dimensionless."""
device_class: str
"""Home Assistant device class (e.g. ``"voltage"``, ``"power"``, ``"energy"``)."""
ha_component: str
"""Home Assistant component type (e.g. ``"sensor"``)."""
state_class: Optional[str] = None
"""Home Assistant state class, if applicable (e.g. ``"total_increasing"``)."""
class BlockSpec(BaseModel):
"""A contiguous register block for bulk reading."""
start: int
"""First register address in the block."""
count: int
"""Number of 16-bit registers to read."""
class ModbusProfile(BaseModel):
"""Complete description of a Modbus device's protocol-level knowledge."""
name: str
"""Short identifier matching the YAML file name (e.g. ``"sdm120"``)."""
description: str
"""Human-readable description of the device."""
function_code: int
"""Modbus function code for reading measurements (4 = FC04 input registers)."""
word_order: str
"""Word (register) order: ``"big"`` means high register first."""
byte_order: str
"""Byte order within each register: ``"big"`` means standard network order."""
blocks: list[BlockSpec]
"""Register blocks to read in bulk, reducing Modbus transaction count."""
metrics: list[MetricSpec]
"""List of individual measurable quantities and their decoding rules."""
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
class ProfileNotFoundError(FileNotFoundError):
"""Raised when the requested profile YAML file does not exist."""
class ProfileValidationError(ValueError):
"""Raised when a profile YAML file fails Pydantic validation."""
def load_profile(name: str) -> ModbusProfile:
"""Load and validate a Modbus device profile by name.
The profile file is expected at
``app/integrations/modbus/profiles/<name>.yaml``.
Parameters
----------
name:
Profile name without extension (e.g. ``"sdm120"``).
Returns
-------
ModbusProfile
Validated profile model.
Raises
------
ProfileNotFoundError
If ``profiles/<name>.yaml`` does not exist.
ProfileValidationError
If the YAML is syntactically valid but the content fails schema
validation (missing required fields, wrong types, etc.).
"""
path = _PROFILES_DIR / f"{name}.yaml"
if not path.exists():
raise ProfileNotFoundError(
f"Modbus profile '{name}' not found (looked for {path})"
)
with path.open("r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh)
try:
profile = ModbusProfile.model_validate(raw)
except ValidationError as exc:
raise ProfileValidationError(
f"Profile '{name}' failed validation: {exc}"
) from exc
return profile
def list_profiles() -> list[tuple[str, str]]:
"""Return ``(name, description)`` pairs for all available profiles.
Scans ``app/integrations/modbus/profiles/*.yaml``, loads each, and
returns a sorted list of ``(name, description)`` tuples. Profiles that
fail to load are skipped with a warning.
"""
results: list[tuple[str, str]] = []
if not _PROFILES_DIR.exists():
return results
for path in sorted(_PROFILES_DIR.glob("*.yaml")):
name = path.stem
try:
profile = load_profile(name)
results.append((profile.name, profile.description))
except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
logger.warning("Skipping malformed profile '%s': %s", name, exc)
return results
def decode(profile: ModbusProfile, registers: dict[int, int]) -> dict[str, float]:
"""Decode raw register values into a mapping of ``{key: float_value}``.
For each metric in *profile*, the function looks up the two registers at
``address`` and ``address + 1`` in *registers* and decodes them as a
big-endian float32 (high register first).
Metrics whose registers are absent from *registers* are skipped (not
an error — this can happen if a block was not requested or a device does
not populate that register). Metrics with unsupported ``type`` values
are also skipped.
Parameters
----------
profile:
Validated ``ModbusProfile`` describing the device.
registers:
Mapping of ``{register_address: 16-bit_value}`` as returned by
``driver.read_blocks``.
Returns
-------
dict[str, float]
Decoded engineering values keyed by each metric's ``key`` field.
"""
result: dict[str, float] = {}
for metric in profile.metrics:
if metric.type == "float32":
hi_addr = metric.address
lo_addr = metric.address + 1
if hi_addr not in registers or lo_addr not in registers:
logger.debug(
"Skipping metric '%s': registers 0x%04X/0x%04X not available",
metric.key,
hi_addr,
lo_addr,
)
continue
value = registers_to_float(registers[hi_addr], registers[lo_addr])
result[metric.key] = value
else:
logger.warning(
"Metric '%s' has unsupported type '%s', skipping",
metric.key,
metric.type,
)
return result
@@ -0,0 +1,82 @@
name: ddsu666
description: CHINT DDSU666 single-phase smart meter
function_code: 3 # holding registers (FC03) — DDSU666 has NO input registers (no FC04)
word_order: big # high register first — ASSUMED; verify with a known voltage reading
byte_order: big # high byte first within each register (confirmed by manual CRC example)
# NOTE: the manual gives no worked float-decode example, so word_order is a best-guess
# (standard big-endian, high register first, matching sdm120). After wiring the meter,
# read 0x2000 (voltage) — it should decode to ~230 V. If it decodes to garbage, the
# device uses the opposite word order and this profile (and the decoder) need adjusting.
# Byte order is confirmed big-endian from the manual (Appendix A, Table A.4: 0x1388 -> 13 88).
blocks:
# Instantaneous quantities 0x20000x200F: voltage, current, P, Q, (rsv), PF, (rsv), Freq.
# 16 contiguous registers — single bulk read. (DDSU666 manual Table 9.)
- { start: 0x2000, count: 0x0010 }
# Active energy — import (0x4000) and export (0x400A) read as two small blocks rather
# than one span, to avoid touching the undocumented/reserved 0x40020x4009 gap.
- { start: 0x4000, count: 0x0002 }
- { start: 0x400A, count: 0x0002 }
metrics:
# Addresses are the raw Modbus protocol addresses (hex) from DDSU666 manual Table 9,
# read via FC03. Each float32 occupies two consecutive 16-bit registers.
- key: voltage
address: 0x2000 # U — Voltage (V)
type: float32
unit: "V"
device_class: voltage
ha_component: sensor
- key: current
address: 0x2002 # I — Current (A)
type: float32
unit: "A"
device_class: current
ha_component: sensor
- key: active_power
address: 0x2004 # P — Active power. Manual unit is kW (NOT W like sdm120).
type: float32
unit: "kW"
device_class: power
ha_component: sensor
- key: reactive_power
address: 0x2006 # Q — Reactive power (kvar)
type: float32
unit: "kvar"
device_class: reactive_power
ha_component: sensor
- key: power_factor
address: 0x200A # PF — Power factor (dimensionless)
type: float32
unit: ""
device_class: power_factor
ha_component: sensor
- key: frequency
address: 0x200E # Freq — Frequency (Hz)
type: float32
unit: "Hz"
device_class: frequency
ha_component: sensor
- key: import_energy
address: 0x4000 # Ep — positive/forward active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
- key: export_energy
address: 0x400A # -Ep — reverse active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
@@ -0,0 +1,74 @@
name: sdm120
description: Eastron SDM120 single-phase energy meter
function_code: 4 # input registers (FC04)
word_order: big # high register first (big-endian word order)
byte_order: big # high byte first within each register
blocks:
# Registers 3000130095 (0x00000x005F): voltage through max export demand
- { start: 0x0000, count: 0x0060 }
# Registers 3034330346 (0x01560x0159): total active/reactive energy
- { start: 0x0156, count: 0x0004 }
metrics:
# Core measurements — addresses from SDM120 Modbus Protocol §4 (FC04 input registers)
# Each float32 occupies two consecutive 16-bit registers; address = Hex start in §4.
- key: voltage
address: 0x0000 # register 30001 — Voltage (V)
type: float32
unit: "V"
device_class: voltage
ha_component: sensor
- key: current
address: 0x0006 # register 30007 — Current (A)
type: float32
unit: "A"
device_class: current
ha_component: sensor
- key: active_power
address: 0x000C # register 30013 — Active power (W)
type: float32
unit: "W"
device_class: power
ha_component: sensor
- key: power_factor
address: 0x001E # register 30031 — Power factor (dimensionless)
type: float32
unit: ""
device_class: power_factor
ha_component: sensor
- key: frequency
address: 0x0046 # register 30071 — Frequency (Hz)
type: float32
unit: "Hz"
device_class: frequency
ha_component: sensor
- key: import_energy
address: 0x0048 # register 30073 — Import active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
- key: export_energy
address: 0x004A # register 30075 — Export active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
- key: total_energy
address: 0x0156 # register 30343 — Total active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
+341
View File
@@ -0,0 +1,341 @@
"""MQTT client integration (paho-mqtt 2.x).
Provides :class:`MqttManager`: a long-lived MQTT client that runs paho's
loop in a background thread. Designed to be started in FastAPI's lifespan
and shared across the application as a module-level singleton (``mqtt_manager``).
Key design decisions
--------------------
- **paho 2.x API**: ``Client`` requires ``CallbackAPIVersion.VERSION2`` as the
first positional argument. VERSION2 on_connect callback receives
``(client, userdata, connect_flags, reason_code, properties)`` — not the
older RC int.
- **Background thread**: ``loop_start()`` spawns a daemon thread; ``loop_stop()``
joins it cleanly on shutdown.
- **Never crash the process**: all paho operations are wrapped in try/except;
connection failure is logged but does not propagate to the caller.
- **Password safety**: the MQTT password is never passed to ``logger`` calls.
- **Reconnect**: ``reconnect(settings)`` tears down the old client and
establishes a fresh connection with the new settings. Callers (e.g. PUT
/api/config) call this after saving MQTT-related config values.
- **Subscribe**: ``subscribe(topic, handler)`` registers a topic → handler
mapping. Subscriptions are re-established automatically on reconnect.
The ``on_message`` callback dispatches incoming payloads to the registered
handler; handler exceptions are caught and logged so they never crash the
paho loop thread or the network connection.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING
import paho.mqtt.client as mqtt
if TYPE_CHECKING:
from app.config import Settings
logger = logging.getLogger(__name__)
# MQTT settings keys that, when changed, should trigger a reconnect.
MQTT_SETTINGS_KEYS = {
"mqtt_enabled",
"mqtt_broker_host",
"mqtt_broker_port",
"mqtt_username",
"mqtt_password",
"mqtt_tls_enabled",
}
def _is_configured(settings: Settings) -> bool:
"""Return True if MQTT is enabled *and* the broker host is set."""
return bool(settings.mqtt_enabled and settings.mqtt_broker_host)
class MqttManager:
"""Long-lived MQTT client wrapper.
Lifecycle
---------
1. ``connect(settings)`` — start background loop + connect to broker.
2. ``publish(...)`` — publish messages while connected.
3. ``disconnect()`` — graceful teardown (called on app shutdown).
4. ``reconnect(settings)`` — disconnect then re-connect with new settings
(called after saving updated MQTT configuration).
When MQTT is not enabled or not configured, all methods are no-ops.
Connection failures are caught and logged; they do not raise.
"""
def __init__(self) -> None:
self._client: mqtt.Client | None = None
self._lock = threading.Lock()
self._connected = False
# topic → handler registry; persists across reconnects so subscriptions
# are automatically re-established when the client reconnects.
self._subscriptions: dict[str, Callable[[bytes], None]] = {}
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
"""True if the underlying paho client is currently connected."""
return self._connected
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
def is_configured(self, settings: Settings) -> bool:
"""Return True if MQTT is enabled and broker host is configured."""
return _is_configured(settings)
def connect(self, settings: Settings) -> None:
"""Connect to the MQTT broker and start the background loop thread.
No-op if MQTT is not enabled or broker host is not set.
Connection errors are logged but do not raise.
"""
if not _is_configured(settings):
logger.debug("MQTT not configured or not enabled — skipping connect.")
return
with self._lock:
self._start_client(settings)
def disconnect(self) -> None:
"""Disconnect from the broker and stop the background loop thread.
No-op if no client is active.
"""
with self._lock:
self._stop_client()
def reconnect(self, settings: Settings) -> None:
"""Disconnect the current client (if any) and reconnect with *settings*.
Call this after saving updated MQTT configuration values.
No-op if MQTT is not enabled or broker host is not set.
"""
with self._lock:
self._stop_client()
self.connect(settings)
def publish(
self,
topic: str,
payload: str | bytes | None,
*,
retain: bool = False,
qos: int = 0,
) -> None:
"""Publish a message to *topic*.
If the client is not connected the call is silently skipped.
Errors are logged but do not raise.
"""
with self._lock:
client = self._client
if client is None or not self._connected:
logger.debug("MQTT publish skipped — not connected (topic=%s).", topic)
return
try:
client.publish(topic, payload=payload, qos=qos, retain=retain)
except Exception:
logger.exception("MQTT publish error (topic=%s).", topic)
def subscribe(self, topic: str, handler: Callable[[bytes], None]) -> None:
"""Register *handler* to be called when a message arrives on *topic*.
If the client is already connected, the subscription is sent to the
broker immediately. Otherwise it is queued and will be established
the next time ``_on_connect`` fires (including after a reconnect).
Handler exceptions are swallowed by the ``on_message`` dispatcher so
that a buggy handler can never crash the paho loop thread.
"""
self._subscriptions[topic] = handler
# Grab the client reference outside the lock to avoid holding the lock
# while calling back into paho (which may itself acquire internal locks).
with self._lock:
client = self._client
connected = self._connected
if client is not None and connected:
try:
client.subscribe(topic)
logger.debug("MQTT subscribed to topic=%s (immediate).", topic)
except Exception:
logger.exception("MQTT subscribe error (topic=%s).", topic)
def unsubscribe(self, topic: str) -> None:
"""Remove the handler for *topic* and unsubscribe from the broker.
Idempotent: unknown topics are ignored. Used to apply config changes
without a restart (e.g. when DSMR ingest is turned off or its topic
changes). If the client is connected, the broker unsubscribe is sent
immediately; either way the handler is removed from the registry so it
will not be re-subscribed on the next ``_on_connect``.
"""
self._subscriptions.pop(topic, None)
with self._lock:
client = self._client
connected = self._connected
if client is not None and connected:
try:
client.unsubscribe(topic)
logger.debug("MQTT unsubscribed from topic=%s.", topic)
except Exception:
logger.exception("MQTT unsubscribe error (topic=%s).", topic)
# ------------------------------------------------------------------
# Internal helpers — must be called with self._lock held
# ------------------------------------------------------------------
def _start_client(self, settings: Settings) -> None:
"""Build a fresh paho Client, configure it, and call loop_start + connect."""
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id="home-automation",
)
# Callbacks — VERSION2 on_connect signature:
# (client, userdata, connect_flags, reason_code, properties)
def _on_connect(
_client: mqtt.Client,
_userdata: object,
_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
if reason_code.is_failure:
logger.warning("MQTT connection refused: %s", reason_code)
self._connected = False
else:
logger.info("MQTT connected (reason_code=%s).", reason_code)
self._connected = True
# Re-establish all registered subscriptions after (re-)connect.
for sub_topic in self._subscriptions:
try:
_client.subscribe(sub_topic)
logger.debug("MQTT re-subscribed to topic=%s.", sub_topic)
except Exception:
logger.exception(
"MQTT re-subscribe failed (topic=%s).", sub_topic
)
# VERSION2 on_disconnect signature:
# (client, userdata, disconnect_flags, reason_code, properties)
def _on_disconnect(
_client: mqtt.Client,
_userdata: object,
_disconnect_flags: mqtt.DisconnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None,
) -> None:
self._connected = False
if reason_code.is_failure:
logger.warning("MQTT disconnected unexpectedly (reason_code=%s).", reason_code)
else:
logger.info("MQTT disconnected cleanly.")
# VERSION2 on_message signature: (client, userdata, message)
def _on_message(
_client: mqtt.Client,
_userdata: object,
message: mqtt.MQTTMessage,
) -> None:
handler = self._subscriptions.get(message.topic)
if handler is None:
logger.debug(
"MQTT on_message: no handler for topic=%s.", message.topic
)
return
try:
handler(message.payload)
except Exception:
logger.exception(
"MQTT message handler raised for topic=%s (swallowed).",
message.topic,
)
client.on_connect = _on_connect
client.on_disconnect = _on_disconnect
client.on_message = _on_message
# TLS
if settings.mqtt_tls_enabled:
try:
client.tls_set()
except Exception:
logger.exception("MQTT TLS setup failed.")
return
# Credentials — never log the password
if settings.mqtt_username:
client.username_pw_set(
username=settings.mqtt_username,
password=settings.mqtt_password or None,
)
# Start background loop thread *before* connect so paho can handle
# the connection handshake asynchronously.
client.loop_start()
try:
client.connect(
host=settings.mqtt_broker_host,
port=settings.mqtt_broker_port,
keepalive=60,
)
except Exception:
# Log without leaking the password
logger.exception(
"MQTT connect failed (host=%s, port=%s). Stopping loop.",
settings.mqtt_broker_host,
settings.mqtt_broker_port,
)
try:
client.loop_stop()
except Exception:
pass
return
self._client = client
logger.info(
"MQTT client started (host=%s, port=%s).",
settings.mqtt_broker_host,
settings.mqtt_broker_port,
)
def _stop_client(self) -> None:
"""Disconnect and stop the background loop. Idempotent."""
client = self._client
if client is None:
return
self._client = None
self._connected = False
try:
client.disconnect()
except Exception:
logger.debug("MQTT disconnect raised (ignoring).", exc_info=True)
try:
client.loop_stop()
except Exception:
logger.debug("MQTT loop_stop raised (ignoring).", exc_info=True)
logger.info("MQTT client stopped.")
# ---------------------------------------------------------------------------
# Module-level singleton — shared across lifespan and route handlers
# ---------------------------------------------------------------------------
mqtt_manager = MqttManager()
+11
View File
@@ -0,0 +1,11 @@
"""Pricing profile framework and strategy registry.
This package provides:
- ``profiles``: Pydantic models describing the structure of pricing profiles
(``ManualProfile`` / ``TibberProfile``), YAML loaders, and contract-values
validation (``load_profile`` / ``list_profiles`` / ``validate_values``).
- ``strategies``: A lightweight registry of price-calculation strategies
(``register_strategy`` / ``get_strategy``), with built-in implementations for
the ``manual`` (fixed dual-tariff) and ``tibber`` (dynamic API) kinds.
"""
+393
View File
@@ -0,0 +1,393 @@
"""Pricing profile loader, validator, and contract-values checker.
A *pricing profile* is a YAML file that describes the **structure** of an energy
contract — which fields exist, their units, and which have defaults. Actual
pricing values (the numbers the user fills in via the UI) are stored in the DB
as ``EnergyContractVersion.values`` (a JSON blob) and must conform to the
corresponding profile's structure.
Profiles live in ``app/integrations/pricing/profiles/<kind>.yaml`` and are
located at runtime relative to *this file* (not CWD), matching the pattern
established by the Modbus profile loader.
Design notes
------------
- **Purely data + functions** — no abstract base classes or inheritance.
- Two Pydantic models — ``ManualProfile`` and ``TibberProfile`` — capture the
different structures of the two supported kinds. A thin union dispatcher in
``load_profile`` picks the right one.
- ``validate_values(kind, values)`` fills in fields that carry a ``default`` and
raises ``ProfileValidationError`` for missing required fields or wrong types.
- All errors are subclasses of built-ins so callers need not import this module
just to catch them.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Optional
import yaml
from pydantic import BaseModel, ValidationError, model_validator
logger = logging.getLogger(__name__)
# Directory containing the YAML profiles, located relative to *this* file.
_PROFILES_DIR = Path(__file__).parent / "profiles"
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class ProfileNotFoundError(FileNotFoundError):
"""Raised when the requested profile YAML file does not exist."""
class ProfileValidationError(ValueError):
"""Raised when a profile YAML fails Pydantic validation or when contract
values do not conform to the profile structure."""
# ---------------------------------------------------------------------------
# Leaf-node models (a single pricing field: unit + optional default)
# ---------------------------------------------------------------------------
class FieldSpec(BaseModel):
"""Specification for a single numeric pricing field."""
unit: str
"""Physical / monetary unit string (e.g. ``"EUR/kWh"``, ``"EUR/month"``)."""
default: Optional[float] = None
"""Default value used when the field is absent from contract values.
``None`` means the field is required (no default)."""
# ---------------------------------------------------------------------------
# ManualProfile — fixed / variable dual-tariff contract structure
# ---------------------------------------------------------------------------
class ManualBuySpec(BaseModel):
normal: FieldSpec # high-tariff buy price (delivered_2 registers)
dal: FieldSpec # low-tariff buy price (delivered_1 registers)
class ManualSellSpec(BaseModel):
normal: FieldSpec # high-tariff sell / return price
dal: FieldSpec # low-tariff sell / return price
class ManualEnergySpec(BaseModel):
dual_tariff: bool # always True for manual profiles
buy: ManualBuySpec
sell: ManualSellSpec
energy_tax: FieldSpec # added to buy price; includes VAT
ode: FieldSpec # currently merged into energy_tax; default 0
class ManualStandingSpec(BaseModel):
network_fee: FieldSpec # EUR/month
management_fee: FieldSpec # EUR/month
class ManualCreditsSpec(BaseModel):
heffingskorting: FieldSpec # EUR/year — energy-tax credit deducted at summary
class ManualProfile(BaseModel):
"""Complete structure description for a ``manual`` pricing contract."""
kind: str
label: str
energy: ManualEnergySpec
standing: ManualStandingSpec
credits: ManualCreditsSpec
@model_validator(mode="after")
def _check_kind(self) -> "ManualProfile":
if self.kind != "manual":
raise ValueError(f"ManualProfile requires kind='manual', got {self.kind!r}")
return self
# ---------------------------------------------------------------------------
# TibberProfile — dynamic Tibber API contract structure
# ---------------------------------------------------------------------------
class TibberEnergySpec(BaseModel):
source: str # must be "tibber_api"
energy_tax: FieldSpec # subtracted from total to derive sell price
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
class TibberStandingSpec(BaseModel):
management_fee: FieldSpec # EUR/month; has a default
network_fee: FieldSpec # EUR/month
class TibberCreditsSpec(BaseModel):
heffingskorting: FieldSpec # EUR/year
class TibberProfile(BaseModel):
"""Complete structure description for a ``tibber`` pricing contract."""
kind: str
label: str
energy: TibberEnergySpec
standing: TibberStandingSpec
credits: TibberCreditsSpec
@model_validator(mode="after")
def _check_kind(self) -> "TibberProfile":
if self.kind != "tibber":
raise ValueError(f"TibberProfile requires kind='tibber', got {self.kind!r}")
return self
# A union type for type hints where either profile is acceptable.
AnyProfile = ManualProfile | TibberProfile
# Map kind → Pydantic model class used for validation.
_PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile]] = {
"manual": ManualProfile,
"tibber": TibberProfile,
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def load_profile(kind: str) -> AnyProfile:
"""Load and validate a pricing profile by kind name.
The profile file is expected at
``app/integrations/pricing/profiles/<kind>.yaml``.
Parameters
----------
kind:
Profile kind without extension (``"manual"`` or ``"tibber"``).
Returns
-------
ManualProfile | TibberProfile
Validated profile model.
Raises
------
ProfileNotFoundError
If ``profiles/<kind>.yaml`` does not exist.
ProfileValidationError
If the YAML is syntactically valid but fails schema validation.
"""
path = _PROFILES_DIR / f"{kind}.yaml"
if not path.exists():
raise ProfileNotFoundError(
f"Pricing profile '{kind}' not found (looked for {path})"
)
with path.open("r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh)
if not isinstance(raw, dict):
raise ProfileValidationError(
f"Profile '{kind}': expected a YAML mapping, got {type(raw).__name__}"
)
# Choose the right Pydantic model based on the ``kind`` field in the YAML.
yaml_kind = raw.get("kind", kind)
model_cls = _PROFILE_MODELS.get(yaml_kind)
if model_cls is None:
raise ProfileValidationError(
f"Profile '{kind}' has unknown kind={yaml_kind!r}; "
f"supported: {list(_PROFILE_MODELS)}"
)
try:
return model_cls.model_validate(raw)
except ValidationError as exc:
raise ProfileValidationError(
f"Profile '{kind}' failed validation: {exc}"
) from exc
def list_profiles() -> list[dict[str, Any]]:
"""Return structural data for all available pricing profiles.
Scans ``app/integrations/pricing/profiles/*.yaml``, loads each, and returns
a list of dicts (Pydantic model dumps). Profiles that fail to load are
skipped with a warning.
Returns
-------
list[dict[str, Any]]
One entry per valid profile, suitable for JSON serialisation and
front-end form rendering.
"""
results: list[dict[str, Any]] = []
if not _PROFILES_DIR.exists():
return results
for path in sorted(_PROFILES_DIR.glob("*.yaml")):
kind = path.stem
try:
profile = load_profile(kind)
results.append(profile.model_dump())
except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
logger.warning("Skipping malformed pricing profile '%s': %s", kind, exc)
return results
# ---------------------------------------------------------------------------
# Contract-values validation
# ---------------------------------------------------------------------------
def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
"""Return a copy of *values* with any ``ode`` default applied if missing."""
filled = dict(values)
energy = dict(filled.get("energy", {}))
# Apply default for ode (default=0) if absent.
if "ode" not in energy and profile.energy.ode.default is not None:
energy["ode"] = profile.energy.ode.default
filled["energy"] = energy
return filled
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
filled = dict(values)
energy = dict(filled.get("energy", {}))
# Apply default for sell_adjust (default=0) if absent.
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
energy["sell_adjust"] = profile.energy.sell_adjust.default
filled["energy"] = energy
standing = dict(filled.get("standing", {}))
# Apply default for management_fee if absent.
if (
"management_fee" not in standing
and profile.standing.management_fee.default is not None
):
standing["management_fee"] = profile.standing.management_fee.default
filled["standing"] = standing
return filled
def _require_numeric(section: str, key: str, container: dict[str, Any]) -> None:
"""Assert that *container[key]* exists and is a number; raise ProfileValidationError."""
if key not in container:
raise ProfileValidationError(
f"Contract values missing required field '{section}.{key}'"
)
val = container[key]
if not isinstance(val, (int, float)):
raise ProfileValidationError(
f"Contract values field '{section}.{key}' must be a number, "
f"got {type(val).__name__!r}"
)
def _validate_manual_values(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
"""Validate and fill-defaults for manual contract values.
Returns the filled values dict on success. Raises ProfileValidationError
on missing required fields or wrong types.
"""
filled = _fill_defaults_manual(values, profile)
energy = filled.get("energy", {})
buy = energy.get("buy", {})
sell = energy.get("sell", {})
standing = filled.get("standing", {})
credits = filled.get("credits", {})
# Required energy.buy fields.
_require_numeric("energy.buy", "normal", buy)
_require_numeric("energy.buy", "dal", buy)
# Required energy.sell fields.
_require_numeric("energy.sell", "normal", sell)
_require_numeric("energy.sell", "dal", sell)
# Required energy fields.
_require_numeric("energy", "energy_tax", energy)
_require_numeric("energy", "ode", energy)
# Required standing fields.
_require_numeric("standing", "network_fee", standing)
_require_numeric("standing", "management_fee", standing)
# Required credits fields.
_require_numeric("credits", "heffingskorting", credits)
return filled
def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
"""Validate and fill-defaults for tibber contract values.
Returns the filled values dict on success. Raises ProfileValidationError
on missing required fields or wrong types.
"""
filled = _fill_defaults_tibber(values, profile)
energy = filled.get("energy", {})
standing = filled.get("standing", {})
credits = filled.get("credits", {})
# Required energy fields.
_require_numeric("energy", "energy_tax", energy)
_require_numeric("energy", "sell_adjust", energy)
# Required standing fields.
_require_numeric("standing", "management_fee", standing)
_require_numeric("standing", "network_fee", standing)
# Required credits fields.
_require_numeric("credits", "heffingskorting", credits)
return filled
def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
"""Validate a contract-values dict against the named profile structure.
Fields that carry a ``default`` in the profile (e.g. ``ode``,
``sell_adjust``, tibber ``management_fee``) are silently filled in when
absent from *values*. Fields with no default that are absent, or fields
whose value is not a number, cause a ``ProfileValidationError``.
Parameters
----------
kind:
Profile kind (``"manual"`` or ``"tibber"``).
values:
Contract values dict as stored in ``EnergyContractVersion.values``.
Returns
-------
dict[str, Any]
A (possibly mutated) copy of *values* with defaults applied.
Raises
------
ProfileNotFoundError
If the profile YAML for *kind* does not exist.
ProfileValidationError
If required fields are missing or have wrong types.
"""
profile = load_profile(kind)
if isinstance(profile, ManualProfile):
return _validate_manual_values(values, profile)
if isinstance(profile, TibberProfile):
return _validate_tibber_values(values, profile)
# Unreachable with current kinds, but guard for future extensions.
raise ProfileValidationError(f"No validator implemented for kind={kind!r}")
@@ -0,0 +1,20 @@
kind: manual
label: 固定 / 可变费率(NL,双费率)
energy:
dual_tariff: true # use delivered_1/2, returned_1/2 for low/high tariffs
buy:
normal: { unit: EUR/kWh } # high-tariff buy price (delivered_2 register)
dal: { unit: EUR/kWh } # low-tariff buy price (delivered_1 register)
sell:
normal: { unit: EUR/kWh } # high-tariff sell / return price
dal: { unit: EUR/kWh } # low-tariff sell / return price (currently equal to normal)
energy_tax: { unit: EUR/kWh } # energy tax added to buy price (incl. VAT)
ode: { unit: EUR/kWh, default: 0 } # currently merged into energy_tax
standing: # fixed charges; UI fills per month, engine prorates to days
network_fee: { unit: EUR/month }
management_fee: { unit: EUR/month }
credits:
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
@@ -0,0 +1,14 @@
kind: tibber
label: Tibber 动态电价(15 分钟)
energy:
source: tibber_api # buy = total (from API); sell = total energy_tax sell_adjust
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
standing: # fixed charges; UI fills per month, engine prorates to days
management_fee: { unit: EUR/month, default: 5.99 }
network_fee: { unit: EUR/month }
credits:
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
+288
View File
@@ -0,0 +1,288 @@
"""Price-calculation strategy registry and built-in implementations.
A *strategy* is a plain function registered under a ``kind`` string. Given
per-register kWh deltas, the period start time, the active contract-version
values, and a DB session, it returns a result dict with:
{
"import_cost": Decimal, # total cost of electricity drawn from grid
"export_revenue": Decimal, # total revenue from electricity fed to grid
"net_cost": Decimal, # import_cost export_revenue
"pricing": dict, # snapshot of the price inputs used (for auditing)
}
**Import and export are kept separate throughout** — net_cost is only derived
at the end. This supports the Dutch no-netting rule (saldering afgebouwd).
**All monetary arithmetic uses Decimal** converted via ``Decimal(str(x))`` to
avoid float binary rounding errors.
Design notes
------------
- **Purely data + functions**: no ABC, no class hierarchy. A ``dict``-based
registry is the simplest structure that supports the two current kinds and
leaves the door open for future additions (e.g. ``octopus``, ``frank``).
- ``deltas`` is a plain dataclass ``PeriodDeltas`` — typed, but no ORM.
- Tibber strategy queries ``TibberPrice`` directly from the session; it does not
call the Tibber API (that is T05's job).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any, Callable
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Shared data types
# ---------------------------------------------------------------------------
@dataclass
class PeriodDeltas:
"""Per-register kWh deltas for one 15-minute billing period.
All values are ``Decimal`` and represent ``end_reading start_reading``
for the corresponding cumulative energy register.
Attributes
----------
d1: Decimal
Delivered (consumed) kWh on the low/dal tariff register (delivered_1).
d2: Decimal
Delivered (consumed) kWh on the normal/high tariff register (delivered_2).
r1: Decimal
Returned (fed-to-grid) kWh on the low/dal tariff register (returned_1).
r2: Decimal
Returned (fed-to-grid) kWh on the normal/high tariff register (returned_2).
"""
d1: Decimal # delivered low-tariff
d2: Decimal # delivered high-tariff
r1: Decimal # returned low-tariff
r2: Decimal # returned high-tariff
# Strategy callable signature.
StrategyFn = Callable[
[PeriodDeltas, datetime, dict[str, Any], Session],
dict[str, Any],
]
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, StrategyFn] = {}
def register_strategy(kind: str, fn: StrategyFn) -> None:
"""Register *fn* as the price-calculation strategy for *kind*.
Overwrites any previously registered strategy for the same kind (allows
monkey-patching in tests).
"""
_REGISTRY[kind] = fn
def get_strategy(kind: str) -> StrategyFn:
"""Return the registered strategy for *kind*.
Raises
------
KeyError
If no strategy is registered for *kind*.
"""
if kind not in _REGISTRY:
raise KeyError(
f"No price strategy registered for kind={kind!r}. "
f"Available: {sorted(_REGISTRY)}"
)
return _REGISTRY[kind]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _to_decimal(value: Any) -> Decimal:
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
return Decimal(str(value))
# ---------------------------------------------------------------------------
# Manual strategy — fixed dual-tariff
# ---------------------------------------------------------------------------
def _manual_strategy(
deltas: PeriodDeltas,
t0: datetime,
values: dict[str, Any],
session: Session,
) -> dict[str, Any]:
"""Calculate billing costs for one period using fixed dual-tariff rates.
Formula (§3.4):
- ``buy_dal = energy.buy.dal + energy.energy_tax + energy.ode``
- ``buy_normal = energy.buy.normal + energy.energy_tax + energy.ode``
- ``import_cost = Δd1 × buy_dal + Δd2 × buy_normal``
- ``sell_dal = energy.sell.dal`` (no tax on return price)
- ``sell_normal = energy.sell.normal``
- ``export_revenue = Δr1 × sell_dal + Δr2 × sell_normal``
- ``net_cost = import_cost export_revenue``
The ``pricing`` snapshot contains all per-unit prices used so that the
result is fully auditable without re-querying the contract version.
"""
energy = values.get("energy", {})
buy = energy.get("buy", {})
sell = energy.get("sell", {})
energy_tax = _to_decimal(energy.get("energy_tax", 0))
ode = _to_decimal(energy.get("ode", 0))
buy_dal_base = _to_decimal(buy.get("dal", 0))
buy_normal_base = _to_decimal(buy.get("normal", 0))
sell_dal = _to_decimal(sell.get("dal", 0))
sell_normal = _to_decimal(sell.get("normal", 0))
# Effective buy prices including all taxes.
buy_dal = buy_dal_base + energy_tax + ode
buy_normal = buy_normal_base + energy_tax + ode
import_cost = deltas.d1 * buy_dal + deltas.d2 * buy_normal
export_revenue = deltas.r1 * sell_dal + deltas.r2 * sell_normal
net_cost = import_cost - export_revenue
pricing_snapshot = {
"kind": "manual",
"buy_dal": str(buy_dal),
"buy_normal": str(buy_normal),
"sell_dal": str(sell_dal),
"sell_normal": str(sell_normal),
"energy_tax": str(energy_tax),
"ode": str(ode),
}
return {
"import_cost": import_cost,
"export_revenue": export_revenue,
"net_cost": net_cost,
"pricing": pricing_snapshot,
}
# ---------------------------------------------------------------------------
# Tibber strategy — dynamic 15-minute spot price
# ---------------------------------------------------------------------------
class TibberPriceNotFoundError(LookupError):
"""Raised when no TibberPrice row covers the requested period start.
The billing engine (T07) catches this to mark the period as ``degraded``
or skip it until a price becomes available.
"""
def _tibber_strategy(
deltas: PeriodDeltas,
t0: datetime,
values: dict[str, Any],
session: Session,
) -> dict[str, Any]:
"""Calculate billing costs for one period using Tibber dynamic pricing.
Price lookup:
Takes the most recent ``TibberPrice`` row where ``starts_at ≤ t0``
(i.e. the slot that was in effect at *t0*). This is a DESC LIMIT 1
query on ``starts_at``.
Formula (§3.4):
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
- ``sell = total energy_tax sell_adjust``
- ``import_cost = (Δd1 + Δd2) × buy``
- ``export_revenue = (Δr1 + Δr2) × sell``
- ``net_cost = import_cost export_revenue``
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
API price applies to the full delivered/returned volume.
Negative ``total`` (extreme negative spot prices):
When ``total`` is negative the ``sell`` price will also be negative,
meaning ``export_revenue`` becomes negative (feeding to grid *costs*
money). This is the mathematically correct outcome and is left as-is.
Raises
------
TibberPriceNotFoundError
If no ``TibberPrice`` row exists with ``starts_at ≤ t0``. The caller
(T07 billing engine) should catch this and mark the period as
``degraded`` or skip it for later recomputation.
"""
from app.models.energy import TibberPrice # local import to avoid circular
from sqlalchemy import desc
price_row: TibberPrice | None = (
session.query(TibberPrice)
.filter(TibberPrice.starts_at <= t0)
.order_by(desc(TibberPrice.starts_at))
.first()
)
if price_row is None:
raise TibberPriceNotFoundError(
f"No TibberPrice found covering t0={t0.isoformat()!r}; "
"period cannot be billed until price data is available."
)
energy = values.get("energy", {})
energy_tax = _to_decimal(energy.get("energy_tax", 0))
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
total = _to_decimal(price_row.total)
buy = total
sell = total - energy_tax - sell_adjust
total_delivered = deltas.d1 + deltas.d2
total_returned = deltas.r1 + deltas.r2
import_cost = total_delivered * buy
export_revenue = total_returned * sell
net_cost = import_cost - export_revenue
pricing_snapshot = {
"kind": "tibber",
"tibber_price_starts_at": price_row.starts_at.isoformat(),
"tibber_price_id": price_row.id,
"total": str(total),
"buy": str(buy),
"sell": str(sell),
"energy_tax": str(energy_tax),
"sell_adjust": str(sell_adjust),
}
return {
"import_cost": import_cost,
"export_revenue": export_revenue,
"net_cost": net_cost,
"pricing": pricing_snapshot,
}
# ---------------------------------------------------------------------------
# Register built-in strategies at module import time
# ---------------------------------------------------------------------------
register_strategy("manual", _manual_strategy)
register_strategy("tibber", _tibber_strategy)
+10
View File
@@ -0,0 +1,10 @@
"""Tibber dynamic electricity pricing integration.
This package provides:
- ``client``: HTTP client for the Tibber GraphQL API (price fetching).
- Custom exceptions: ``TibberError`` (base) and ``TibberAuthError`` (auth failure).
Usage::
from app.integrations.tibber.client import fetch_price_range, TibberAuthError
"""
+329
View File
@@ -0,0 +1,329 @@
"""Tibber GraphQL API client for fetching electricity price data.
Design decisions
----------------
- Uses ``httpx`` (already a project dependency) for all HTTP requests.
- Token is **never** written to log messages or exception strings to prevent
credential leakage into log aggregators.
- ``fetch_price_range`` returns a list of ``PricePoint`` objects with UTC-aware
``starts_at`` datetimes; the number of nodes is not assumed — all returned
nodes are parsed regardless of count.
- ``fetch_current_price`` uses a separate, simpler query that asks for the
*current* price point only; used by the connection-test endpoint (T09) to
produce a fast three-state result (success / auth-error / network-error).
- Authentication failures (HTTP 401/403) are raised as ``TibberAuthError`` so
that callers (the test endpoint) can distinguish them from generic network or
API errors.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
import httpx
logger = logging.getLogger(__name__)
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
_DEFAULT_TIMEOUT = 15.0
# GraphQL query to fetch a range of 15-minute price nodes.
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max,
# but the Tibber API typically starts from the current slot and returns at
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
# "today + tomorrow").
_PRICE_RANGE_QUERY = """
{
viewer {
homes {
id
currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
nodes {
startsAt
total
energy
tax
currency
level
}
}
}
}
}
}
"""
# GraphQL query to fetch the single *current* price point.
_CURRENT_PRICE_QUERY = """
{
viewer {
homes {
id
currentSubscription {
priceInfo {
current {
startsAt
total
energy
tax
currency
level
}
}
}
}
}
}
"""
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class TibberError(Exception):
"""Base class for all Tibber client errors.
Raised for network failures, timeouts, unexpected HTTP status codes, and
malformed API responses. The exception message will **never** contain the
API token.
"""
class TibberAuthError(TibberError):
"""Raised when the Tibber API rejects the provided token (HTTP 401/403).
Callers that implement a three-state connection test should catch this
exception separately from ``TibberError`` to distinguish auth problems
from network / API problems.
"""
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class PricePoint:
"""One 15-minute price slot returned by the Tibber API.
All monetary values are in ``currency`` and include VAT (user-facing).
``starts_at`` is always a timezone-aware UTC datetime regardless of the
timezone offset that Tibber returns in ``startsAt``.
"""
starts_at: datetime # UTC-aware
total: float # full all-in price (energy + tax)
energy: float # spot energy component
tax: float # tax component
currency: str # ISO 4217 (typically "EUR")
level: str | None # e.g. "CHEAP", "NORMAL", "EXPENSIVE"; None if absent
resolution: str # label for the slot resolution (e.g. "QUARTER_HOURLY")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _parse_starts_at(raw: str) -> datetime:
"""Parse an ISO 8601 timestamp with timezone offset and convert to UTC.
Tibber returns timestamps like ``2026-06-23T00:00:00.000+02:00``.
``datetime.fromisoformat`` handles this format in Python 3.11+; the result
is then converted to UTC via ``.astimezone(UTC)``.
"""
return datetime.fromisoformat(raw).astimezone(UTC)
def _parse_node(node: dict[str, Any], resolution: str) -> PricePoint:
"""Parse a single price node dict into a ``PricePoint``."""
return PricePoint(
starts_at=_parse_starts_at(node["startsAt"]),
total=float(node["total"]),
energy=float(node["energy"]),
tax=float(node["tax"]),
currency=str(node["currency"]),
level=node.get("level") or None,
resolution=resolution,
)
def _pick_home(homes: list[dict[str, Any]], home_id: str | None) -> dict[str, Any]:
"""Return the home dict matching *home_id*, or the first home if None."""
if not homes:
raise TibberError("Tibber API returned no homes")
if home_id is not None:
for h in homes:
if h.get("id") == home_id:
return h
raise TibberError("Tibber home id not found in API response")
return homes[0]
def _post_graphql(token: str, query: str, timeout: float) -> dict[str, Any]:
"""POST the GraphQL *query* and return the parsed ``data`` dict.
Raises
------
TibberAuthError
On HTTP 401 or 403.
TibberError
On timeouts, network errors, non-2xx responses, or unexpected body shape.
"""
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
try:
response = httpx.post(
_TIBBER_API_URL,
json={"query": query},
headers=headers,
timeout=timeout,
)
except httpx.TimeoutException as exc:
raise TibberError("Tibber API request timed out") from exc
except httpx.HTTPError as exc:
raise TibberError("Tibber API request failed") from exc
if response.status_code in (401, 403):
raise TibberAuthError("Tibber API authentication failed")
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise TibberError(f"Tibber API returned unexpected status {response.status_code}") from exc
try:
body = response.json()
except Exception as exc:
raise TibberError("Tibber API returned non-JSON response") from exc
if "errors" in body:
# GraphQL errors are not HTTP errors; surface them as TibberError.
# Do not include token in the message.
raise TibberError("Tibber GraphQL returned errors")
data = body.get("data")
if data is None:
raise TibberError("Tibber API response missing 'data' field")
return data
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def fetch_price_range(
token: str,
home_id: str | None = None,
*,
timeout: float = _DEFAULT_TIMEOUT,
) -> list[PricePoint]:
"""Fetch a range of 15-minute price nodes from the Tibber API.
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
and parses every returned node into a ``PricePoint``. The number of nodes
is not assumed — all returned nodes are parsed regardless of count.
Parameters
----------
token:
Tibber API token. **Never** logged or included in exception messages.
home_id:
If given, the home with this Tibber home ID is selected. If ``None``,
the first home in the account is used.
timeout:
HTTP request timeout in seconds (default 15 s).
Returns
-------
list[PricePoint]
List of price points with UTC-aware ``starts_at`` values.
Raises
------
TibberAuthError
If the token is rejected (HTTP 401/403).
TibberError
For network failures, timeouts, or unexpected API responses.
"""
data = _post_graphql(token, _PRICE_RANGE_QUERY, timeout)
try:
homes = data["viewer"]["homes"]
except (KeyError, TypeError) as exc:
raise TibberError("Tibber API response has unexpected shape") from exc
home = _pick_home(homes, home_id)
try:
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
except (KeyError, TypeError) as exc:
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
def fetch_current_price(
token: str,
home_id: str | None = None,
*,
timeout: float = _DEFAULT_TIMEOUT,
) -> PricePoint:
"""Fetch the *current* price point from the Tibber API.
Uses the lighter ``priceInfo { current { ... } }`` query rather than the
full range query, making it suitable for a fast connection test.
Parameters
----------
token:
Tibber API token. **Never** logged or included in exception messages.
home_id:
If given, the home with this Tibber home ID is selected. If ``None``,
the first home in the account is used.
timeout:
HTTP request timeout in seconds (default 15 s).
Returns
-------
PricePoint
The current price point with a UTC-aware ``starts_at``.
Raises
------
TibberAuthError
If the token is rejected (HTTP 401/403).
TibberError
For network failures, timeouts, missing current price, or unexpected
API responses.
"""
data = _post_graphql(token, _CURRENT_PRICE_QUERY, timeout)
try:
homes = data["viewer"]["homes"]
except (KeyError, TypeError) as exc:
raise TibberError("Tibber API response has unexpected shape") from exc
home = _pick_home(homes, home_id)
try:
current = home["currentSubscription"]["priceInfo"]["current"]
except (KeyError, TypeError) as exc:
raise TibberError("Tibber API response missing current price") from exc
if current is None:
raise TibberError("Tibber API returned null for current price")
return _parse_node(current, "QUARTER_HOURLY")
+289 -37
View File
@@ -1,28 +1,182 @@
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session
from app import models # noqa: F401
from app.api.routes.auth import router as auth_router
from app.api.routes import pages, status
import app.auth_db as auth_db
from app.api.routes.api.config import router as api_config_router
from app.api.routes.api.data import router as api_data_router
from app.api.routes.api.energy import router as api_energy_router
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
from app.api.routes.api.expose import router as api_expose_router
from app.api.routes.api.meters import router as api_meters_router
from app.api.routes.api.modbus import router as api_modbus_router
from app.api.routes.api.session import router as api_session_router
from app.api.routes import status
from app.db import get_session_local
from app.api.routes.homeassistant import router as homeassistant_router
from app.api.routes.location import router as location_router
from app.api.routes.poo import router as poo_router
from app.api.routes.public_ip import router as public_ip_router
from app.api.routes.ticktick import router as ticktick_router
from app.config import get_settings
from app.integrations.mqtt import mqtt_manager
from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
from app.services.config_page import build_runtime_settings, seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
from app.services.dsmr_ingest import apply_dsmr_subscription
from app.services.public_ip import check_public_ipv4_and_notify
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
from app.services.ha_discovery import publish_discovery, publish_states
from app.services.tibber_prices import refresh_prices
from app.services.energy_cost import compute_closed_periods
from app.services.timezone import local_tz
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
logger = logging.getLogger(__name__)
_REPO_ROOT = Path(__file__).resolve().parents[1]
def _get_spa_dist_dir() -> Path:
env_val = os.environ.get("SPA_DIST_DIR")
if env_val:
return Path(env_val)
return _REPO_ROOT / "frontend" / "dist"
def _run_scheduled_public_ip_check() -> None:
session_local = get_session_local()
session: Session = session_local()
try:
check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
finally:
session.close()
def _run_scheduled_modbus_poll() -> None:
session_local = get_session_local()
session: Session = session_local()
try:
poll_all_enabled_devices(session, bootstrap_settings=get_settings())
finally:
session.close()
def _run_scheduled_tibber_refresh() -> None:
"""Scheduled job: fetch Tibber 15-minute prices and upsert into tibber_price.
Runs every hour so that:
- Today's prices are available from startup.
- Tomorrow's prices (published by Tibber around 13:00 CET / 11:00 UTC) are
picked up within an hour of publication without requiring a server restart.
The job is a no-op when:
- No active energy contract with kind="tibber" exists.
- The Tibber API token is empty in the runtime settings.
Any client exceptions (auth failures, network errors) are caught and logged
so that a single failed fetch does not crash the scheduler or affect the
other background jobs.
"""
session_local = get_session_local()
session: Session = session_local()
try:
runtime_settings = build_runtime_settings(session, get_settings())
refresh_prices(session, runtime_settings)
except Exception:
logger.exception("_run_scheduled_tibber_refresh: unexpected error")
finally:
session.close()
def _run_scheduled_energy_cost() -> None:
"""Scheduled job: compute billing records for all uncalculated closed 15-minute periods.
Runs every minute so that a new period is picked up within 1 minute of
closing. The job is a no-op when:
- No active energy contract with a version covering the period exists.
- DSMR data has not yet arrived for the period boundaries.
- The period's billing record already exists and is not degraded.
Any unexpected exceptions are caught and logged so that a single failure
does not crash the scheduler or affect the other background jobs.
"""
session_local = get_session_local()
session: Session = session_local()
try:
compute_closed_periods(session)
# After billing periods are computed, push fresh energy-cost state values
# to MQTT/HA. publish_states is internally guarded by _should_publish
# (MQTT disabled / not connected → no-op), so this never raises due to
# unconfigured MQTT and does not block the billing job.
try:
from app.services.ha_discovery import publish_states
publish_states(session)
except Exception:
logger.exception("_run_scheduled_energy_cost: publish_states failed (non-fatal)")
except Exception:
logger.exception("_run_scheduled_energy_cost: unexpected error")
finally:
session.close()
def _run_scheduled_ha_state_publish() -> None:
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
Runs every 60 seconds. When the MQTT broker is connected:
- Publishes (or re-publishes) all HA Discovery configs (retained, idempotent).
- Publishes the current state / availability for all enabled entities.
This also serves as the reliable "publish discovery after connect" mechanism:
because paho connects asynchronously, a synchronous call immediately after
``mqtt_manager.connect()`` would fire before the TCP handshake completes and
be a no-op. Instead, this periodic job picks it up within 60 seconds of the
broker becoming available — retained payloads make repeated publishes harmless.
Additionally, if MQTT is configured in DB but the manager is not yet connected
(e.g. MQTT was enabled via UI after startup), this job attempts to connect so
the user does not need to restart the server.
"""
session_local = get_session_local()
session: Session = session_local()
try:
runtime_settings = build_runtime_settings(session, get_settings())
# Reconnect if MQTT is configured (in DB) but not yet connected.
if mqtt_manager.is_configured(runtime_settings) and not mqtt_manager.is_connected:
logger.info("_run_scheduled_ha_state_publish: MQTT configured but not connected — attempting connect.")
mqtt_manager.connect(runtime_settings)
publish_discovery(session)
publish_states(session)
except Exception:
logger.exception("_run_scheduled_ha_state_publish: unexpected error")
finally:
session.close()
def _run_midnight_state_publish() -> None:
"""本地午夜后不久专门发布一次状态,让 *_today 的每日归零稳稳落在午夜之后
(对 HA 钟慢几秒鲁棒)。best-effort:失败仅记日志,不影响调度器。"""
session_local = get_session_local()
session = session_local()
try:
from app.services.ha_discovery import publish_states
publish_states(session)
except Exception:
logger.exception("_run_midnight_state_publish: failed (non-fatal)")
finally:
session.close()
def ensure_auth_db_ready() -> None:
session_local = auth_db.get_auth_session_local()
session_local = get_session_local()
session: Session = session_local()
try:
validate_app_runtime_db(get_settings().app_database_url)
@@ -37,43 +191,101 @@ def ensure_auth_db_ready() -> None:
session.close()
def ensure_location_db_ready() -> None:
settings = get_settings()
if settings.location_sqlite_path is None:
return
try:
validate_location_runtime_db(settings.location_database_url)
except LocationDatabaseAdoptionError as exc:
raise RuntimeError(str(exc)) from exc
def ensure_poo_db_ready() -> None:
settings = get_settings()
if settings.poo_sqlite_path is None:
return
try:
validate_poo_runtime_db(settings.poo_database_url)
except PooDatabaseAdoptionError as exc:
raise RuntimeError(str(exc)) from exc
def ensure_runtime_dirs() -> None:
settings = get_settings()
for path in (settings.app_sqlite_path, settings.location_sqlite_path, settings.poo_sqlite_path):
if path is not None:
path.parent.mkdir(parents=True, exist_ok=True)
if settings.app_sqlite_path is not None:
settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True)
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_runtime_dirs()
ensure_auth_db_ready()
ensure_location_db_ready()
ensure_poo_db_ready()
scheduler = BackgroundScheduler(timezone="UTC")
scheduler.add_job(
_run_scheduled_public_ip_check,
trigger=IntervalTrigger(hours=4),
id="public-ip-check",
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.add_job(
_run_scheduled_modbus_poll,
trigger=IntervalTrigger(seconds=BASE_POLL_TICK_SECONDS),
id="modbus-poll",
replace_existing=True,
max_instances=1,
coalesce=True,
)
# Periodic HA state / availability publish (60-second fallback sweep).
scheduler.add_job(
_run_scheduled_ha_state_publish,
trigger=IntervalTrigger(seconds=60),
id="ha-state-publish",
replace_existing=True,
max_instances=1,
coalesce=True,
)
# Tibber price refresh: fetch today + tomorrow every hour.
# The job is a no-op when no active tibber contract or token is configured,
# so it is safe to register unconditionally.
scheduler.add_job(
_run_scheduled_tibber_refresh,
trigger=IntervalTrigger(hours=1),
id="tibber-refresh",
replace_existing=True,
max_instances=1,
coalesce=True,
)
# Energy cost billing: compute uncalculated closed 15-minute periods every minute.
# The job is a no-op when no active contract or DSMR data is present, so it is
# safe to register unconditionally.
scheduler.add_job(
_run_scheduled_energy_cost,
trigger=IntervalTrigger(minutes=1),
id="energy-cost",
replace_existing=True,
max_instances=1,
coalesce=True,
)
# Dedicated midnight publish: fire at local 00:00:10 so *_today grace (5 s) has
# already elapsed and the day-rolled value is pushed to HA immediately, rather
# than waiting for the next 60-second ha-state-publish sweep.
scheduler.add_job(
_run_midnight_state_publish,
trigger=CronTrigger(hour=0, minute=0, second=10, timezone=local_tz()),
id="midnight-today-publish",
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.start()
# MQTT: connect using DB-merged runtime settings so broker configured via UI
# is picked up on restart (not just from env/bootstrap settings).
# Discovery will be published by the first run of _run_scheduled_ha_state_publish
# (within 60 s of startup), after the async paho handshake completes.
_startup_session_local = get_session_local()
_startup_session: Session = _startup_session_local()
try:
_startup_runtime_settings = build_runtime_settings(_startup_session, get_settings())
finally:
_startup_session.close()
mqtt_manager.connect(_startup_runtime_settings)
# DSMR ingest: subscribe to the configured MQTT topic when enabled. The same
# applier is called from PUT /api/config, so toggling DSMR via the UI takes
# effect without an app restart.
apply_dsmr_subscription(_startup_runtime_settings)
yield
# MQTT: clean shutdown before the process exits.
mqtt_manager.disconnect()
scheduler.shutdown(wait=False)
def create_app() -> FastAPI:
settings = get_settings()
@@ -92,12 +304,52 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(status.router)
app.include_router(auth_router)
app.include_router(pages.router)
app.include_router(api_config_router)
app.include_router(api_data_router)
app.include_router(api_energy_router)
app.include_router(api_energy_contracts_router)
app.include_router(api_meters_router)
app.include_router(api_expose_router)
app.include_router(api_modbus_router)
app.include_router(api_session_router)
app.include_router(homeassistant_router)
app.include_router(location_router)
app.include_router(poo_router)
app.include_router(public_ip_router)
app.include_router(ticktick_router)
# SPA hosting: mount frontend/dist if it exists and has index.html.
# If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely
# so that pytest stays green with only the API routes registered.
spa_dist = _get_spa_dist_dir()
spa_index = spa_dist / "index.html"
if spa_dist.is_dir() and spa_index.is_file():
spa_assets = spa_dist / "assets"
if spa_assets.is_dir():
app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets")
# Resolve the dist root once so the containment check is fast and consistent.
_spa_root = spa_dist.resolve()
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029
# Explicit 404 for unmatched /api/* — never return index.html for API paths.
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="not found")
# Resolve candidate to an absolute path and verify it stays within the SPA
# dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f")
# bypass Starlette's path parameter handling and allow arbitrary file reads.
candidate = (spa_dist / full_path).resolve()
if candidate.is_file() and candidate.is_relative_to(_spa_root):
return FileResponse(candidate)
# For any path outside the dist root, or for SPA client routes that don't
# correspond to a real file, return index.html so the SPA router handles it.
return FileResponse(spa_index)
else:
logger.warning(
"SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist
)
return app
+11 -1
View File
@@ -3,5 +3,15 @@
from app.models.auth import AuthSession, AuthUser
from app.models.config import AppConfigEntry
from app.models.location import Location
from app.models.poo import PooRecord
from app.models.public_ip import PublicIPHistory, PublicIPState
__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
__all__ = [
"AppConfigEntry",
"AuthSession",
"AuthUser",
"Location",
"PooRecord",
"PublicIPHistory",
"PublicIPState",
]
+25 -3
View File
@@ -3,10 +3,10 @@ from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.auth_db import AuthBase
from app.db import Base
class AuthUser(AuthBase):
class AuthUser(Base):
__tablename__ = "auth_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -15,11 +15,15 @@ class AuthUser(AuthBase):
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# TOTP fields (Phase B, M4-T04) — nullable/false by default so existing users are unaffected
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
recovery_codes: Mapped[list["RecoveryCode"]] = relationship(back_populates="user")
class AuthSession(AuthBase):
class AuthSession(Base):
__tablename__ = "auth_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -31,3 +35,21 @@ class AuthSession(AuthBase):
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[AuthUser] = relationship(back_populates="sessions")
class RecoveryCode(Base):
"""One-time TOTP recovery codes for AuthUser.
``code_hash`` stores the Argon2 hash of the plaintext code (plaintext is
returned only at setup time and never stored). ``used_at`` is NULL while
the code is still valid; set to the consumption timestamp when consumed.
"""
__tablename__ = "auth_recovery_code"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True)
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[AuthUser] = relationship(back_populates="recovery_codes")
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class LoginThrottle(Base):
"""Tracks per-key login failure state for exponential back-off throttling.
``scope`` is either ``'ip'`` or ``'user'``; ``key`` is the IP address or
username string respectively. The pair ``(scope, key)`` is unique — one
row per tracked entity. A row is deleted on successful login (clear).
"""
__tablename__ = "auth_login_throttle"
__table_args__ = (
UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
Index("ix_auth_login_throttle_scope_key", "scope", "key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), nullable=False)
scope: Mapped[str] = mapped_column(String(16), nullable=False) # 'ip' | 'user'
failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
first_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_allowed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
-4
View File
@@ -1,4 +0,0 @@
from app.db import Base
__all__ = ["Base"]
+2 -2
View File
@@ -3,10 +3,10 @@ from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.auth_db import AuthBase
from app.db import Base
class AppConfigEntry(AuthBase):
class AppConfigEntry(Base):
__tablename__ = "app_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+318
View File
@@ -0,0 +1,318 @@
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
Six tables:
- meter: physical electricity meter lifecycle epoch.
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
- energy_contract: contract head (manual or tibber, one active at a time).
- energy_contract_version: versioned pricing values; append-only for auditability.
- tibber_price: cached Tibber 15-minute spot prices (immutable).
- energy_cost_period: computed 15-minute billing periods (immutable snapshot).
"""
from __future__ import annotations
import uuid as _uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db import Base
def _uuid4_str() -> str:
return str(_uuid.uuid4())
class Meter(Base):
"""One physical electricity meter's installation epoch.
A ``meter`` record represents the period ``[started_at, ended_at)`` during
which a particular physical meter was installed and active. Replacing a meter
(swap, home move, etc.) is modelled by closing the current record
(``ended_at = swap_timestamp``) and opening a new one
(``started_at = swap_timestamp``).
**Invariant**: for each ``commodity`` there is at most one active meter
(``ended_at IS NULL``) at any point in time. The service layer enforces
this — no DB-level constraint is added to keep the migration simple and to
allow the application to return a meaningful error message.
``commodity`` defaults to ``"electricity"``; the field is a free-form string
(no CHECK constraint) so future commodities (``gas``, ``heating``) can be
added without a schema change.
``reason`` captures why this epoch started — one of ``initial``,
``meter_swap``, ``home_move``, or ``other`` — stored as a plain string so
the application layer controls the allowed set.
"""
__tablename__ = "meter"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Stable internal identity — used as HA Discovery unique_id anchor.
uuid: Mapped[str] = mapped_column(
String(36), unique=True, nullable=False, default=_uuid4_str
)
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
label: Mapped[str] = mapped_column(String(255), nullable=False)
# Energy commodity this meter measures. Defaults to "electricity".
commodity: Mapped[str] = mapped_column(String(32), nullable=False, default="electricity")
# UTC timestamp when this meter epoch starts (inclusive). May be in the past
# (retroactive declaration); effective billing start = max(started_at, data start).
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# UTC timestamp when this meter epoch ends (exclusive). NULL = currently active.
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Why this epoch was created. Application-layer validation enforces the
# allowed set; no CHECK constraint to keep migrations simple.
reason: Mapped[str] = mapped_column(String(64), nullable=False)
# Free-form note (e.g. location, physical meter id, reason details).
note: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# UTC timestamp of when this row was created.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship to cost periods attributed to this meter epoch (not loaded eagerly).
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
back_populates="meter", cascade="save-update, merge"
)
class DsmrReading(Base):
"""One down-sampled DSMR telegram stored as a full JSON blob.
Identity & idempotency are **independent of the DSMR Reader's telegram id**
(that field overflows and must be manually reset to zero — a known DSMR
quirk — so relying on it for uniqueness risks silently dropping new data).
The table's own autoincrement ``id`` PK is the stable internal identity, and
``recorded_at`` (the telegram timestamp) is the UNIQUE de-duplication key: a
single P1 meter emits exactly one telegram per timestamp.
``recorded_at`` is a real column (not inside the payload) so time-range
queries are efficient. The entire telegram frame is stored verbatim in
``payload``; no field allow-list is applied so future commodities (gas,
heating, three-phase) are accommodated without a schema change.
"""
__tablename__ = "dsmr_reading"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC timestamp of the sample — real column, UNIQUE (telegram-id-independent
# idempotency key). The unique index also serves time-range queries.
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, unique=True
)
# Telegram's own id (DSMR Reader assigns it). Stored only as a reference /
# debugging aid — NOT used for uniqueness or idempotency (it overflows and
# gets reset to zero). Nullable because some DSMR sources may not emit one.
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Full telegram frame as a JSON object; values are typically JSON strings
# (e.g. "20915.154") — callers must cast to Decimal before arithmetic.
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
class EnergyContract(Base):
"""Contract head: a named energy contract with a chosen pricing strategy.
``kind`` determines which price strategy is used (``manual`` for fixed
dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
Only one contract may be ``active`` at a time; the service layer enforces
mutual exclusion. Specific pricing values live in ``EnergyContractVersion``
so that price changes can be tracked without modifying historical records.
"""
__tablename__ = "energy_contract"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Human-readable label; freely editable by the user.
name: Mapped[str] = mapped_column(String(255), nullable=False)
# Strategy selector: "manual" or "tibber". Application-layer validation
# enforces the allowed set; no DB CHECK constraint is added to keep the
# migration simple and the strategy registry extensible.
kind: Mapped[str] = mapped_column(String(32), nullable=False)
# Whether this is the currently active contract (at most one should be True).
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# ISO 4217 currency code for all monetary values in this contract.
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="EUR")
# Audit timestamps.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship to versions (back-reference; not loaded eagerly).
versions: Mapped[list["EnergyContractVersion"]] = relationship(
back_populates="contract", cascade="save-update, merge"
)
class EnergyContractVersion(Base):
"""One time-bounded version of an energy contract's pricing values.
Pricing changes are modelled as new versions (append-only); existing versions
are never modified so that historical ``EnergyCostPeriod`` records remain
fully auditable. ``effective_to`` is ``NULL`` for the currently open version.
"""
__tablename__ = "energy_contract_version"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# FK to the parent contract. RESTRICT prevents deletion of a contract that
# still has versioned pricing rows attached to it.
contract_id: Mapped[int] = mapped_column(
ForeignKey("energy_contract.id", ondelete="RESTRICT"), nullable=False
)
# Start of this version's validity window (inclusive, UTC).
effective_from: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
# End of this version's validity window (exclusive, UTC). NULL means open-ended
# (i.e. this is the most recent / current version).
effective_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Pricing values as a JSON object conforming to the profile structure for
# ``contract.kind`` (validated by the application layer against the YAML profile).
values: Mapped[dict] = mapped_column(JSON, nullable=False)
# Creation timestamp.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship back to the parent contract.
contract: Mapped["EnergyContract"] = relationship(back_populates="versions")
# Relationship to cost periods that reference this version.
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
back_populates="contract_version", cascade="save-update, merge"
)
class TibberPrice(Base):
"""Cached Tibber 15-minute spot price point (immutable once fetched).
``starts_at`` is unique so that upserts are idempotent. Past prices are
never overwritten; the fetch job only adds rows for future time slots.
"""
__tablename__ = "tibber_price"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC start of the 15-minute slot; unique so upsert is idempotent.
starts_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, unique=True
)
# Resolution label as returned by the Tibber API (e.g. "QUARTER_HOURLY").
resolution: Mapped[str] = mapped_column(String(32), nullable=False)
# Price components in the contract currency (all include VAT, user-facing).
energy: Mapped[float] = mapped_column(Float, nullable=False)
tax: Mapped[float] = mapped_column(Float, nullable=False)
total: Mapped[float] = mapped_column(Float, nullable=False)
# Tibber price level (e.g. "NORMAL", "CHEAP", "EXPENSIVE"); may be absent.
level: Mapped[str | None] = mapped_column(String(32), nullable=True)
# ISO 4217 currency code as returned by the API.
currency: Mapped[str] = mapped_column(String(8), nullable=False)
# UTC timestamp of when this row was fetched/inserted.
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class EnergyCostPeriod(Base):
"""Computed billing record for one 15-minute metering period (immutable snapshot).
Each row captures the per-register kWh deltas, the resulting import cost and
export revenue, and a full snapshot of the pricing values used so that the
calculation is fully auditable and reproducible without re-querying the
contract version. Rows are written once and never modified; explicit
recomputation via the API is the only way to overwrite a period.
"""
__tablename__ = "energy_cost_period"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# UTC start of the 15-minute period; unique so upsert is idempotent.
period_start: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, unique=True
)
# Per-register kWh deltas for the period (end minus start of cumulative registers).
# _1 = dal/low-tariff, _2 = normal/high-tariff (NL convention).
d1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered low
d2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # delivered high
r1_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned low
r2_kwh: Mapped[float] = mapped_column(Float, nullable=False) # returned high
# Computed monetary amounts for the period (in ``currency``).
import_cost: Mapped[float] = mapped_column(Float, nullable=False)
export_revenue: Mapped[float] = mapped_column(Float, nullable=False)
net_cost: Mapped[float] = mapped_column(Float, nullable=False)
# ISO 4217 currency code matching the contract.
currency: Mapped[str] = mapped_column(String(8), nullable=False)
# Full snapshot of the pricing inputs used during computation. This makes
# each row self-contained and auditable even if the contract is later changed.
pricing: Mapped[dict] = mapped_column(JSON, nullable=False)
# FK to the exact contract version whose values were used. RESTRICT prevents
# deletion of a version that has cost records attached. Nullable to support
# periods computed in ``degraded`` mode (missing price data).
contract_version_id: Mapped[int | None] = mapped_column(
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
)
# FK to the meter epoch this period belongs to. RESTRICT prevents deletion of
# a meter that still has attributed cost periods. Nullable for backwards
# compatibility (pre-M7 rows) and degraded periods where the meter was not
# determinable.
meter_id: Mapped[int | None] = mapped_column(
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
)
# True when the period was computed with incomplete data (missing readings or
# missing price); serves as a flag for later recomputation.
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# UTC timestamp of when this row was computed/inserted.
computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship back to the contract version.
contract_version: Mapped["EnergyContractVersion | None"] = relationship(
back_populates="cost_periods"
)
# Relationship back to the meter epoch.
meter: Mapped["Meter | None"] = relationship(back_populates="cost_periods")
# Index on recorded_at for efficient time-range queries on DSMR readings.
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
# no composite index is needed for single-meter deployments.)
# Index on period_start is covered by the unique constraint (SQLite creates an
# implicit index for UNIQUE columns), so no additional index is required.
# Index on starts_at for TibberPrice is covered by the unique constraint similarly.
+49
View File
@@ -0,0 +1,49 @@
"""SQLAlchemy model for the exposed-entity toggle table.
``ExposedEntityToggle`` stores the per-entity enable/disable state for the
MQTT / HA Discovery expose framework. The *catalog* of what *can* be
exposed is computed dynamically by provider functions (see
``app/integrations/expose.py``); this table only records which entries the
user has explicitly enabled.
Key design decisions
--------------------
- ``key`` is a stable string identifier derived from device uuid + metric key
(e.g. ``"modbus.<uuid>.voltage"``), so it does not drift if rows are
deleted and re-inserted.
- Default state for an entity not yet in this table is **disabled** (false).
``build_catalog`` in expose.py treats a missing row as ``enabled=False``.
- Only one row per entity key (``key`` is UNIQUE).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class ExposedEntityToggle(Base):
"""Per-entity on/off switch for MQTT / HA Discovery publishing.
Rows are created on demand (first toggle); entities with no row are
treated as disabled.
"""
__tablename__ = "exposed_entity_toggle"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Stable entity key, e.g. "modbus.<uuid>.voltage" or "modbus.<uuid>.online".
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
# Whether this entity should be published via MQTT / HA Discovery.
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Last time this row was created or modified.
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
+114
View File
@@ -0,0 +1,114 @@
"""SQLAlchemy models for Modbus device management and telemetry.
Two tables:
- modbus_device: deployment/configurable metadata for each polled device.
- modbus_reading: generic telemetry rows (one per device per poll cycle).
"""
from __future__ import annotations
import uuid as _uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db import Base
def _uuid4_str() -> str:
return str(_uuid.uuid4())
class ModbusDevice(Base):
"""Deployment-layer record for a Modbus slave device reachable via a TCP gateway.
Protocol knowledge (register map, decoding rules) lives in the YAML profile
referenced by ``profile``. Per-device variables (network address, slave ID,
display name, poll rate) live here.
"""
__tablename__ = "modbus_device"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Stable internal identity — used as the API path key and HA Discovery unique_id anchor.
uuid: Mapped[str] = mapped_column(
String(36), unique=True, nullable=False, default=_uuid4_str
)
# Human-readable label (may be changed; changing it triggers re-publish of HA Discovery).
friendly_name: Mapped[str] = mapped_column(String(255), nullable=False)
# Transport protocol — only "tcp" is supported for now.
transport: Mapped[str] = mapped_column(String(16), nullable=False, default="tcp")
# TCP gateway address.
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=502)
# Modbus slave address (set on the device panel; different meters on the same gateway
# must have distinct unit_ids).
unit_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Which YAML profile to use for register mapping and decoding (e.g. "sdm120").
profile: Mapped[str] = mapped_column(String(64), nullable=False)
# Polling interval in seconds.
poll_interval_s: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
# Whether this device is included in the periodic poll sweep.
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Poll-status fields (updated by the poll service after each attempt).
last_poll_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_poll_ok: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# Audit timestamps.
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
# Relationship to readings (back-reference; not loaded eagerly).
readings: Mapped[list["ModbusReading"]] = relationship(
back_populates="device", cascade="save-update, merge"
)
class ModbusReading(Base):
"""Generic telemetry row: one device, one poll instant, all decoded metrics as JSON.
``payload`` contains the full dict of engineering values produced by the YAML profile
decoder, e.g. ``{"voltage": 230.2, "current": 1.3, ...}``. The profile is the key
to interpreting which keys exist and what units they carry.
"""
__tablename__ = "modbus_reading"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# FK to the device that produced this reading.
# ON DELETE RESTRICT: prevents accidental deletion of a device that has historical data.
device_id: Mapped[int] = mapped_column(
ForeignKey("modbus_device.id", ondelete="RESTRICT"), nullable=False
)
# The UTC timestamp of when the sample was taken — real indexed column, NOT embedded in payload.
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
# Profile-decoded engineering values as a JSON object.
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
device: Mapped["ModbusDevice"] = relationship(back_populates="readings")
# Composite index for efficient time-range queries scoped to a single device.
Index("ix_modbus_reading_device_recorded", ModbusReading.device_id, ModbusReading.recorded_at)
+2 -2
View File
@@ -1,10 +1,10 @@
from sqlalchemy import Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.poo_db import PooBase
from app.db import Base
class PooRecord(PooBase):
class PooRecord(Base):
__tablename__ = "poo_records"
timestamp: Mapped[str] = mapped_column(String, primary_key=True)
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class PublicIPState(Base):
__tablename__ = "public_ip_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
current_ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
previous_ipv4: Mapped[str | None] = mapped_column(String(45), nullable=True)
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_check_status: Mapped[str] = mapped_column(String(32), nullable=False)
last_check_error: Mapped[str | None] = mapped_column(String(255), nullable=True)
last_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
class PublicIPHistory(Base):
__tablename__ = "public_ip_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
change_type: Mapped[str] = mapped_column(String(32), nullable=False)
provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
-28
View File
@@ -1,28 +0,0 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class PooBase(DeclarativeBase):
pass
settings = get_settings()
connect_args: dict[str, object] = {}
if settings.poo_database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
poo_engine = create_engine(settings.poo_database_url, connect_args=connect_args)
PooSessionLocal = sessionmaker(bind=poo_engine, autoflush=False, autocommit=False, class_=Session)
def get_poo_db_session() -> Generator[Session, None, None]:
session = PooSessionLocal()
try:
yield session
finally:
session.close()
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
class ConfigField(BaseModel):
env_name: str
label: str
value: str
secret: bool
input_type: str
configured: bool
class ConfigSection(BaseModel):
name: str
fields: list[ConfigField]
class ConfigResponse(BaseModel):
sections: list[ConfigSection]
class ConfigUpdateRequest(BaseModel):
"""Flat mapping of env_name → value, mirroring the existing form semantics."""
updates: dict[str, str]
class ConfigUpdateResponse(BaseModel):
sections: list[ConfigSection]
class SmtpTestResponse(BaseModel):
"""Response from POST /api/config/smtp/test."""
result: Literal["success", "config-error", "failed"]
message: str
class MqttTestResponse(BaseModel):
"""Response from POST /api/config/mqtt/test."""
result: Literal["success", "config-error", "failed"]
message: str
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Location
# ---------------------------------------------------------------------------
class LocationRecord(BaseModel):
person: str
datetime: str
latitude: float
longitude: float
altitude: float | None
class LocationsResponse(BaseModel):
items: list[LocationRecord]
limit: int
offset: int
class LocationUpdateRequest(BaseModel):
"""PATCH body for a location record — all fields optional; PK fields excluded."""
latitude: float | None = None
longitude: float | None = None
altitude: float | None = None
# ---------------------------------------------------------------------------
# Poo
# ---------------------------------------------------------------------------
class PooRecord(BaseModel):
timestamp: str
status: str
latitude: float
longitude: float
class PooResponse(BaseModel):
items: list[PooRecord]
limit: int
offset: int
class PooUpdateRequest(BaseModel):
"""PATCH body for a poo record — all fields optional; PK field excluded."""
status: str | None = None
latitude: float | None = None
longitude: float | None = None
# ---------------------------------------------------------------------------
# Public IP
# ---------------------------------------------------------------------------
class PublicIPStateSchema(BaseModel):
id: int
current_ipv4: str
previous_ipv4: str | None
first_seen_at: datetime
last_checked_at: datetime
last_changed_at: datetime | None
last_check_status: str
last_check_error: str | None
last_provider: str | None
model_config = {"from_attributes": True}
class PublicIPHistorySchema(BaseModel):
id: int
ipv4: str
observed_at: datetime
change_type: str
provider: str | None
model_config = {"from_attributes": True}
class PublicIPResponse(BaseModel):
state: PublicIPStateSchema | None
history: list[PublicIPHistorySchema]
+216
View File
@@ -0,0 +1,216 @@
"""Pydantic schemas for the Energy data API (M6-T09).
Covers six endpoint groups under /api/energy:
- GET /prices — price curve (tibber 15min points or manual tariff)
- GET /costs — energy_cost_period rows (time-range, paginated)
- GET /costs/summary — aggregated metered + standing charges credits
- GET /dsmr/latest — most recent dsmr_reading blob
- POST /costs/recompute — explicit idempotent recompute (returns count)
- POST /tibber/test — three-state Tibber connection test
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# GET /api/energy/prices
# ---------------------------------------------------------------------------
class PricePointSchema(BaseModel):
"""A single 15-minute price point (tibber) or placeholder entry."""
starts_at: datetime
buy: float = Field(description="All-in buy price in EUR/kWh (including taxes).")
sell: float = Field(description="Net sell price in EUR/kWh.")
level: str | None = Field(
default=None,
description="Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual.",
)
class ManualTariffSchema(BaseModel):
"""Fixed-tariff breakdown for manual contracts.
Prices are the *effective* buy prices as used by the billing engine
(energy_buy_x + energy_tax + ode) and the raw sell prices.
"""
buy_dal: float = Field(description="Effective buy price, low-tariff / dal (EUR/kWh).")
buy_normal: float = Field(description="Effective buy price, normal / high-tariff (EUR/kWh).")
sell_dal: float = Field(description="Sell price, low-tariff / dal (EUR/kWh).")
sell_normal: float = Field(description="Sell price, normal / high-tariff (EUR/kWh).")
class PricesResponse(BaseModel):
"""Response for GET /api/energy/prices.
``kind`` mirrors the active contract kind:
- ``"tibber"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.
- ``"manual"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.
- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.
``currency`` comes from the active contract (or "EUR" fallback).
``points`` is always ascending by ``starts_at``.
"""
kind: str | None = Field(
description="Active contract kind ('tibber' or 'manual'), or null if no active contract."
)
currency: str = Field(description="ISO 4217 currency code.")
points: list[PricePointSchema] = Field(
description=(
"15-minute price points for tibber contracts (ascending by starts_at). "
"Empty for manual contracts or when no active contract exists."
)
)
tariff: ManualTariffSchema | None = Field(
default=None,
description=(
"Fixed tariff table for manual contracts. "
"Null for tibber contracts and when no active contract exists."
),
)
# ---------------------------------------------------------------------------
# GET /api/energy/costs
# ---------------------------------------------------------------------------
class CostPeriodSchema(BaseModel):
"""One 15-minute billing record from the energy_cost_period table."""
period_start: datetime
d1_kwh: float = Field(description="Delivered low-tariff kWh for this period.")
d2_kwh: float = Field(description="Delivered normal-tariff kWh for this period.")
r1_kwh: float = Field(description="Returned low-tariff kWh for this period.")
r2_kwh: float = Field(description="Returned normal-tariff kWh for this period.")
import_cost: float = Field(description="Cost of electricity drawn from grid (EUR).")
export_revenue: float = Field(description="Revenue from electricity fed to grid (EUR).")
net_cost: float = Field(description="import_cost export_revenue (EUR).")
currency: str = Field(description="ISO 4217 currency code.")
degraded: bool = Field(
description="True when the period was computed with incomplete data."
)
contract_version_id: int | None = Field(
default=None,
description="FK to the contract version used for this billing period (null when degraded).",
)
model_config = {"from_attributes": True}
class CostsResponse(BaseModel):
"""Response for GET /api/energy/costs."""
items: list[CostPeriodSchema]
total: int = Field(description="Number of items returned.")
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary
# ---------------------------------------------------------------------------
class SummaryResponse(BaseModel):
"""Response for GET /api/energy/costs/summary.
All monetary values are in ``currency``.
``total_payable = metered_net + fixed_costs credits``
"""
currency: str
metered_import: float = Field(description="Σ import_cost for non-degraded periods.")
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.")
metered_net: float = Field(description="Σ net_cost for non-degraded periods.")
fixed_costs: float = Field(
description="Standing charges (network_fee + management_fee) apportioned over the interval."
)
credits: float = Field(
description="Energy-tax credit (heffingskorting) apportioned over the interval."
)
total_payable: float = Field(
description="metered_net + fixed_costs credits (actual amount owed)."
)
period_count: int = Field(description="Number of non-degraded billing periods in range.")
degraded_count: int = Field(description="Number of degraded billing periods in range.")
days: float = Field(description="Interval length in days.")
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest
# ---------------------------------------------------------------------------
class DsmrLatestResponse(BaseModel):
"""Response for GET /api/energy/dsmr/latest.
``found`` is False when no dsmr_reading rows exist yet. The front-end
should check ``found`` before reading ``recorded_at`` or ``payload``.
"""
found: bool
recorded_at: datetime | None = None
payload: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute
# ---------------------------------------------------------------------------
class RecomputeResponse(BaseModel):
"""Response for POST /api/energy/costs/recompute."""
recomputed: int = Field(
description=(
"Number of 15-minute periods for which a billing record was written "
"(inserted or updated). Periods skipped due to missing contract or "
"missing Tibber price are not counted."
)
)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test
# ---------------------------------------------------------------------------
class TibberTestPriceSchema(BaseModel):
"""Current Tibber price point returned on a successful test.
Carries enough fields for the front-end to confirm the API is working and
display the live price. The API token is **never** included.
"""
starts_at: datetime
total: float
energy: float
tax: float
currency: str
level: str | None = None
class TibberTestResponse(BaseModel):
"""Three-state response for POST /api/energy/tibber/test.
Possible ``result`` values:
- ``"success"`` — Tibber API responded with a valid price.
- ``"config-error"`` — Token is missing or not configured.
- ``"failed"`` — API call failed (auth rejected, network error, timeout, etc.).
``price`` is populated only on ``"success"``; it is null otherwise.
``message`` always contains a human-readable explanation.
"""
result: Literal["success", "config-error", "failed"]
message: str
price: TibberTestPriceSchema | None = None
+148
View File
@@ -0,0 +1,148 @@
"""Pydantic schemas for the EnergyContract CRUD + versioning API (M6-T04).
Schema hierarchy
----------------
ContractVersionResponse — single version row (id, dates, values, created_at)
ContractResponse — contract head (id, name, kind, active, currency, timestamps)
ContractDetailResponse — contract head + embedded versions list (for GET{id})
ContractListResponse — paginated list of ContractResponse items
ContractCreate — POST /api/energy/contracts body
ContractPatch — PATCH /api/energy/contracts/{id} body (all fields optional)
VersionCreate — POST /api/energy/contracts/{id}/versions body
ProfilesResponse — GET /api/energy/profiles response
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Version schemas
# ---------------------------------------------------------------------------
class ContractVersionResponse(BaseModel):
"""Response schema for a single EnergyContractVersion row."""
id: int
effective_from: datetime
effective_to: datetime | None
values: dict[str, Any]
created_at: datetime
model_config = {"from_attributes": True}
# ---------------------------------------------------------------------------
# Contract schemas
# ---------------------------------------------------------------------------
class ContractResponse(BaseModel):
"""Response schema for a single EnergyContract (without embedded versions).
Used for list responses where embedding all versions would be expensive.
"""
id: int
name: str
kind: str
active: bool
currency: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ContractDetailResponse(BaseModel):
"""Response schema for a single EnergyContract with full version history.
Returned by GET /api/energy/contracts/{id} and by successful POST / PATCH
operations where the caller needs to see all version data.
"""
id: int
name: str
kind: str
active: bool
currency: str
created_at: datetime
updated_at: datetime
versions: list[ContractVersionResponse]
model_config = {"from_attributes": True}
class ContractListResponse(BaseModel):
"""Response schema for GET /api/energy/contracts."""
items: list[ContractResponse]
total: int
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class ContractCreate(BaseModel):
"""Request body for POST /api/energy/contracts.
``effective_from`` defaults to the current UTC time if not provided,
giving the first version an open-ended start from "now".
``kind`` is validated at the application layer against the profile registry;
clients should send ``"manual"`` or ``"tibber"``.
"""
name: str = Field(..., min_length=1, max_length=255)
kind: str = Field(..., min_length=1, max_length=32)
currency: str = Field(default="EUR", min_length=1, max_length=8)
values: dict[str, Any]
effective_from: datetime | None = Field(
default=None,
description=(
"UTC datetime from which the first pricing version is effective. "
"Defaults to the current UTC time when omitted."
),
)
class ContractPatch(BaseModel):
"""Request body for PATCH /api/energy/contracts/{id}.
All fields are optional. Sending ``active=true`` activates this contract
(deactivating all others); ``active=false`` deactivates it without affecting
other contracts.
"""
name: str | None = Field(default=None, min_length=1, max_length=255)
active: bool | None = None
class VersionCreate(BaseModel):
"""Request body for POST /api/energy/contracts/{id}/versions."""
effective_from: datetime
values: dict[str, Any]
# ---------------------------------------------------------------------------
# Profile response schemas
# ---------------------------------------------------------------------------
class ProfilesResponse(BaseModel):
"""Response schema for GET /api/energy/profiles.
``profiles`` is a list of raw profile dicts as produced by
``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum
``kind`` and ``label``; the full nested structure allows the front-end to
render a type-appropriate form for each pricing profile.
"""
profiles: list[dict[str, Any]]
+95
View File
@@ -0,0 +1,95 @@
"""Pydantic schemas for the Expose API (M5-T12).
Three endpoints:
GET /api/expose — catalog + toggle state + MQTT/Discovery status
PUT /api/expose — set toggles (map key → bool)
POST /api/expose/republish — trigger discovery re-publish
"""
from __future__ import annotations
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Nested schemas
# ---------------------------------------------------------------------------
class DeviceInfoSchema(BaseModel):
"""HA device grouping info for an exposable entity."""
identifiers: list[str]
name: str
class ExposableEntitySchema(BaseModel):
"""One exposable entity in the catalog.
``value_getter`` is intentionally excluded — it is a non-serialisable
callable and is only used internally by the HA Discovery service.
"""
key: str
component: str
device: DeviceInfoSchema
device_class: str | None
unit: str
name: str
state_class: str | None = None
class CatalogEntrySchema(BaseModel):
"""An entity from the catalog with its current toggle state."""
entity: ExposableEntitySchema
enabled: bool
class MqttStatusSchema(BaseModel):
"""Connection status for MQTT and HA Discovery."""
mqtt_configured: bool
mqtt_connected: bool
discovery_enabled: bool
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class ExposeResponse(BaseModel):
"""Response for GET /api/expose."""
catalog: list[CatalogEntrySchema]
mqtt_status: MqttStatusSchema
class ExposeUpdateResponse(BaseModel):
"""Response for PUT /api/expose (returns updated catalog + status)."""
catalog: list[CatalogEntrySchema]
mqtt_status: MqttStatusSchema
class RepublishResponse(BaseModel):
"""Response for POST /api/expose/republish."""
ok: bool
message: str
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class ExposeUpdateRequest(BaseModel):
"""Request body for PUT /api/expose.
``toggles`` is a map from entity key to desired enabled state (bool).
Only keys present in the map are updated; absent keys are untouched.
"""
toggles: dict[str, bool]
+131
View File
@@ -0,0 +1,131 @@
"""Pydantic schemas for the Meter CRUD + swap declaration API (M7-T05).
Schema hierarchy
----------------
MeterResponse — single meter row (id/label/commodity/started_at/ended_at/reason/note/created_at)
MeterListResponse — ordered list of MeterResponse items
MeterDeclareRequest — POST /api/energy/meters body (declare a swap or initial meter)
MeterPatchRequest — PATCH /api/energy/meters/{id} body (all fields optional)
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MeterReason(str, Enum):
"""Allowed values for the meter epoch creation reason."""
initial = "initial"
meter_swap = "meter_swap"
home_move = "home_move"
other = "other"
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class MeterResponse(BaseModel):
"""Response schema for a single Meter epoch row.
``ended_at`` is ``null`` for the currently active meter.
"""
id: int
label: str
commodity: str
started_at: datetime
ended_at: datetime | None
reason: str
note: str | None
created_at: datetime
model_config = {"from_attributes": True}
class MeterListResponse(BaseModel):
"""Response schema for GET /api/energy/meters.
Meters are returned in ascending ``started_at`` order so the caller sees
the historical installation sequence.
"""
items: list[MeterResponse]
total: int
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
_VALID_REASONS = ", ".join(r.value for r in MeterReason)
class MeterDeclareRequest(BaseModel):
"""Request body for POST /api/energy/meters.
Declares a new meter epoch (swap, home move, or initial declaration). The
service layer closes the current active meter for the given commodity at
``started_at`` and opens a new one.
``started_at`` follows the Principle-A localisation convention: a
timezone-naive value is interpreted as the **server's local wall-clock time**
(e.g. CEST midnight → stored as UTC the night before); a timezone-aware
value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
every meter declaration must carry an explicit start timestamp.
``commodity`` defaults to ``"electricity"``; the field is available for
future use with ``gas`` or ``heating``.
"""
label: str = Field(..., min_length=1, max_length=255)
started_at: datetime = Field(
...,
description=(
"UTC (or server-local naive) datetime from which this meter epoch starts. "
"May be in the past (retroactive declaration)."
),
)
reason: MeterReason = Field(
...,
description=f"Why this epoch was created. One of: {_VALID_REASONS}.",
)
note: str | None = Field(default=None, max_length=1024)
commodity: str = Field(
default="electricity",
min_length=1,
max_length=32,
description="Energy commodity this meter measures. Defaults to 'electricity'.",
)
class MeterPatchRequest(BaseModel):
"""Request body for PATCH /api/energy/meters/{id}.
All fields are optional. Only non-``None`` values are applied.
Updating ``started_at`` is a **retroactive correction**: the service layer
maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
and the API layer triggers ``recompute_range`` over the affected window so
that billing attribution is re-judged.
"""
label: str | None = Field(default=None, min_length=1, max_length=255)
note: str | None = Field(default=None, max_length=1024)
started_at: datetime | None = Field(
default=None,
description=(
"Retroactive correction of the meter epoch start timestamp. "
"Triggers billing recompute over the affected window."
),
)
+171
View File
@@ -0,0 +1,171 @@
"""Pydantic schemas for the Modbus device CRUD + readings + metrics API (M5-T05)."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Device schemas
# ---------------------------------------------------------------------------
class ModbusDeviceCreate(BaseModel):
"""Request body for POST /api/modbus/devices."""
friendly_name: str = Field(..., min_length=1, max_length=255)
transport: str = Field(default="tcp", max_length=16)
host: str = Field(..., min_length=1, max_length=255)
port: int = Field(default=502, ge=1, le=65535)
unit_id: int = Field(default=1, ge=0, le=247)
profile: str = Field(..., min_length=1, max_length=64)
poll_interval_s: int = Field(default=5, ge=1, le=3600)
enabled: bool = Field(default=True)
class ModbusDeviceUpdate(BaseModel):
"""Request body for PATCH /api/modbus/devices/{uuid} — all fields optional."""
friendly_name: str | None = Field(default=None, min_length=1, max_length=255)
transport: str | None = Field(default=None, max_length=16)
host: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, ge=1, le=65535)
unit_id: int | None = Field(default=None, ge=0, le=247)
profile: str | None = Field(default=None, min_length=1, max_length=64)
poll_interval_s: int | None = Field(default=None, ge=1, le=3600)
enabled: bool | None = None
class ModbusDeviceResponse(BaseModel):
"""Response schema for a single Modbus device."""
uuid: str
friendly_name: str
transport: str
host: str
port: int
unit_id: int
profile: str
poll_interval_s: int
enabled: bool
last_poll_at: datetime | None
last_poll_ok: bool | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ModbusDeviceListResponse(BaseModel):
"""Response schema for listing Modbus devices."""
items: list[ModbusDeviceResponse]
total: int
# ---------------------------------------------------------------------------
# Reading schemas
# ---------------------------------------------------------------------------
class ModbusReadingResponse(BaseModel):
"""A single reading row: timestamp + decoded payload."""
recorded_at: datetime
payload: dict[str, Any]
model_config = {"from_attributes": True}
class ModbusReadingsResponse(BaseModel):
"""Response schema for the readings time-range endpoint."""
items: list[ModbusReadingResponse]
class ModbusLatestResponse(BaseModel):
"""Response for the /latest endpoint.
``found`` is False and ``recorded_at``/``payload`` are None when the device
has no readings yet. Callers should check ``found`` before using the values.
"""
found: bool
recorded_at: datetime | None
payload: dict[str, Any] | None
# ---------------------------------------------------------------------------
# Metrics / profile schemas
# ---------------------------------------------------------------------------
class MetricInfo(BaseModel):
"""Metadata for a single measurable quantity in a device's profile."""
key: str
label: str
unit: str
device_class: str
class ModbusMetricsResponse(BaseModel):
"""Response schema for GET /api/modbus/devices/{uuid}/metrics."""
profile: str
metrics: list[MetricInfo]
# ---------------------------------------------------------------------------
# Profile list schema
# ---------------------------------------------------------------------------
class ProfileSummary(BaseModel):
"""One entry in the GET /api/modbus/profiles response."""
name: str
description: str
class ModbusProfilesResponse(BaseModel):
"""Response schema for GET /api/modbus/profiles."""
profiles: list[ProfileSummary]
# ---------------------------------------------------------------------------
# Test-read schema
# ---------------------------------------------------------------------------
class ModbusTestReadResponse(BaseModel):
"""Response for POST /api/modbus/devices/{uuid}/test.
On success ``ok=True`` and ``payload`` contains the decoded values.
On failure ``ok=False`` and ``error`` describes the problem.
"""
ok: bool
payload: dict[str, Any] | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# Cascade-delete schema
# ---------------------------------------------------------------------------
class ModbusDeleteResponse(BaseModel):
"""Response for DELETE /api/modbus/devices/{uuid}?cascade=true.
Returned only when cascade deletion succeeds (HTTP 200). Non-cascade
successful deletes continue to return HTTP 204 (no body).
"""
deleted: bool
readings_deleted: int
toggles_deleted: int
+13
View File
@@ -0,0 +1,13 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel
PublicIPCheckStatus = Literal["first_seen", "unchanged", "changed", "error"]
class PublicIPCheckResponse(BaseModel):
status: PublicIPCheckStatus
checked_at: datetime
changed: bool
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from pydantic import BaseModel
class SessionUser(BaseModel):
username: str
force_password_change: bool
class SessionResponse(BaseModel):
user: SessionUser
csrf_token: str
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str | None = None
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str
confirm_password: str
+59
View File
@@ -0,0 +1,59 @@
"""Pydantic schemas for TOTP setup / enable / disable / status endpoints (M4-T05)."""
from __future__ import annotations
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Setup (POST /api/auth/totp/setup) — response
# ---------------------------------------------------------------------------
class TotpSetupResponse(BaseModel):
"""Returned once after a setup call.
``secret`` and ``recovery_codes`` are **one-time plaintext values**.
They are never returned again by any subsequent API call.
The frontend must display and instruct the user to save them before confirming.
"""
secret: str
otpauth_uri: str
recovery_codes: list[str]
# ---------------------------------------------------------------------------
# Enable (POST /api/auth/totp/enable) — request
# ---------------------------------------------------------------------------
class TotpEnableRequest(BaseModel):
"""The user confirms setup by providing the 6-digit TOTP code."""
code: str
# ---------------------------------------------------------------------------
# Disable (POST /api/auth/totp/disable) — request
# ---------------------------------------------------------------------------
class TotpDisableRequest(BaseModel):
"""Disable TOTP by proving identity.
Exactly one of ``password`` or ``code`` must be provided.
"""
password: str | None = None
code: str | None = None
# ---------------------------------------------------------------------------
# Status (GET /api/auth/totp) — response
# ---------------------------------------------------------------------------
class TotpStatusResponse(BaseModel):
"""Minimal status response — never exposes secret or recovery codes."""
enabled: bool
+103 -7
View File
@@ -7,7 +7,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth_db import reset_auth_db_caches
from app.db import reset_db_caches
from app.config import Settings, get_settings
from app.models.config import AppConfigEntry
@@ -25,8 +25,17 @@ class ConfigField:
CONFIG_FIELDS: tuple[ConfigField, ...] = (
ConfigField("System", "APP_NAME", "app_name", "App Name"),
ConfigField("System", "APP_ENV", "app_env", "App Env"),
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug", input_type="checkbox"),
ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"),
ConfigField("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled", input_type="checkbox"),
ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"),
ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"),
ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"),
ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True),
ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"),
ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"),
ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"),
ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS", input_type="checkbox"),
ConfigField(
"Authentication",
"AUTH_SESSION_COOKIE_NAME",
@@ -40,6 +49,13 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
"auth_cookie_secure_override",
"Cookie Secure Override",
),
ConfigField(
"Authentication",
"AUTH_LOGIN_THROTTLE_ENABLED",
"auth_login_throttle_enabled",
"Login Throttle Enabled",
input_type="checkbox",
),
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
ConfigField(
"Poo",
@@ -87,6 +103,61 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
"home_assistant_action_task_project_id",
"Home Assistant Action Task Project ID",
),
ConfigField("MQTT", "MQTT_ENABLED", "mqtt_enabled", "MQTT Enabled", input_type="checkbox"),
ConfigField("MQTT", "MQTT_BROKER_HOST", "mqtt_broker_host", "MQTT Broker Host"),
ConfigField("MQTT", "MQTT_BROKER_PORT", "mqtt_broker_port", "MQTT Broker Port", input_type="number"),
ConfigField("MQTT", "MQTT_USERNAME", "mqtt_username", "MQTT Username"),
ConfigField("MQTT", "MQTT_PASSWORD", "mqtt_password", "MQTT Password", secret=True),
ConfigField("MQTT", "MQTT_TLS_ENABLED", "mqtt_tls_enabled", "MQTT TLS Enabled", input_type="checkbox"),
ConfigField(
"Home Assistant Discovery",
"HA_DISCOVERY_ENABLED",
"ha_discovery_enabled",
"HA Discovery Enabled",
input_type="checkbox",
),
ConfigField(
"Home Assistant Discovery",
"HA_DISCOVERY_PREFIX",
"ha_discovery_prefix",
"HA Discovery Prefix",
),
ConfigField(
"Home Assistant Discovery",
"HA_STATE_TOPIC_PREFIX",
"ha_state_topic_prefix",
"HA State Topic Prefix",
),
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
ConfigField(
"DSMR",
"DSMR_INGEST_ENABLED",
"dsmr_ingest_enabled",
"DSMR Ingest Enabled",
input_type="checkbox",
),
ConfigField("DSMR", "DSMR_MQTT_TOPIC", "dsmr_mqtt_topic", "DSMR MQTT Topic"),
ConfigField(
"DSMR",
"DSMR_SAMPLE_INTERVAL_S",
"dsmr_sample_interval_s",
"DSMR Sample Interval (s)",
input_type="number",
),
ConfigField(
"DSMR",
"DSMR_TARIFF_TOPIC",
"dsmr_tariff_topic",
"DSMR Tariff Topic",
),
ConfigField(
"Tibber",
"TIBBER_API_TOKEN",
"tibber_api_token",
"Tibber API Token",
secret=True,
),
ConfigField("Tibber", "TIBBER_HOME_ID", "tibber_home_id", "Tibber Home ID"),
)
@@ -118,7 +189,7 @@ def sync_app_hostname_from_bootstrap(session: Session, bootstrap_settings: Setti
current_values["APP_HOSTNAME"] = bootstrap_hostname
_persist_config_values(session, current_values)
get_settings.cache_clear()
reset_auth_db_caches()
reset_db_caches()
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
@@ -175,7 +246,7 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
_validate_config_values(merged_values, bootstrap_settings)
_persist_config_values(session, merged_values)
get_settings.cache_clear()
reset_auth_db_caches()
reset_db_caches()
def save_config_value(
@@ -190,7 +261,7 @@ def save_config_value(
_validate_config_values(current_values, bootstrap_settings)
_persist_config_values(session, current_values)
get_settings.cache_clear()
reset_auth_db_caches()
reset_db_caches()
def is_ticktick_oauth_ready(settings: Settings) -> bool:
@@ -251,8 +322,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"app_debug": settings.app_debug,
"app_hostname": settings.app_hostname,
"app_database_url": settings.app_database_url,
"location_database_url": settings.location_database_url,
"poo_database_url": settings.poo_database_url,
"ticktick_client_id": settings.ticktick_client_id,
"ticktick_client_secret": settings.ticktick_client_secret,
"ticktick_token": settings.ticktick_token,
@@ -260,6 +329,15 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"home_assistant_auth_token": settings.home_assistant_auth_token,
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
"home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
"smtp_enabled": settings.smtp_enabled,
"smtp_host": settings.smtp_host,
"smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username,
"smtp_password": settings.smtp_password,
"smtp_from_name": settings.smtp_from_name,
"smtp_from_address": settings.smtp_from_address,
"smtp_to_address": settings.smtp_to_address,
"smtp_use_starttls": settings.smtp_use_starttls,
"poo_webhook_id": settings.poo_webhook_id,
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
@@ -268,4 +346,22 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"auth_session_cookie_name": settings.auth_session_cookie_name,
"auth_session_ttl_hours": settings.auth_session_ttl_hours,
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
"auth_trust_forwarded_for": settings.auth_trust_forwarded_for,
"modbus_polling_enabled": settings.modbus_polling_enabled,
"mqtt_enabled": settings.mqtt_enabled,
"mqtt_broker_host": settings.mqtt_broker_host,
"mqtt_broker_port": settings.mqtt_broker_port,
"mqtt_username": settings.mqtt_username,
"mqtt_password": settings.mqtt_password,
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
"ha_discovery_enabled": settings.ha_discovery_enabled,
"ha_discovery_prefix": settings.ha_discovery_prefix,
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
"dsmr_tariff_topic": settings.dsmr_tariff_topic,
"tibber_api_token": settings.tibber_api_token,
"tibber_home_id": settings.tibber_home_id,
}
+364
View File
@@ -0,0 +1,364 @@
"""Service layer for EnergyContract CRUD, versioning, and activation.
All functions accept an explicit SQLAlchemy Session; callers are responsible
for committing or rolling back the transaction.
Design decisions
----------------
- ``create_contract``: validates values against the pricing profile *before*
writing any rows; raises ``ProfileValidationError`` on non-compliance.
- ``add_version``: append-only; closes the previous open version by
setting its ``effective_to`` to the new version's ``effective_from``; raises
``ContractVersionError`` if the new date is strictly earlier than the previous
version's ``effective_from``.
- ``activate_contract``: mutual-exclusion; sets all other contracts' ``active``
to False, then sets the given contract's ``active`` to True.
- ``active_contract_version_at``: returns the single version of the currently
active contract that covers *ts* (``effective_from ts < effective_to``,
or open-ended when ``effective_to`` is None).
SQLite timezone note
--------------------
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
read-back they come out as **timezone-naive** datetimes. Wherever this code
compares timestamps from the DB against timezone-aware values (e.g. from
Pydantic or ``datetime.now(UTC)``), it calls ``_as_utc()`` to make both sides
comparable without tripping on "offset-naive vs offset-aware" TypeErrors.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.integrations.pricing.profiles import validate_values
from app.models.energy import EnergyContract, EnergyContractVersion
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _as_utc(dt: datetime) -> datetime:
"""Return *dt* as a timezone-aware UTC datetime.
SQLite's DateTime(timezone=True) column type stores datetimes as naive UTC
strings and gives them back as naive datetimes on read. This helper
re-attaches the UTC timezone info when it is missing, making cross-origin
comparisons safe.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
# ---------------------------------------------------------------------------
# Custom exception
# ---------------------------------------------------------------------------
class ContractVersionError(ValueError):
"""Raised when a new contract version has an invalid effective date.
Specifically: the new version's ``effective_from`` must be greater than or
equal to the previous open version's ``effective_from``. Allowing equal
timestamps would cause ambiguous overlap; the service therefore also rejects
strictly-equal values (same second) to avoid silent data loss.
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_contract_or_none(session: Session, contract_id: int) -> EnergyContract | None:
"""Return the contract with the given id, or None if not found."""
return session.execute(
select(EnergyContract).where(EnergyContract.id == contract_id)
).scalar_one_or_none()
def list_contracts(session: Session) -> list[EnergyContract]:
"""Return all contracts ordered by id (ascending)."""
return list(
session.execute(select(EnergyContract).order_by(EnergyContract.id)).scalars().all()
)
def _open_version(session: Session, contract: EnergyContract) -> EnergyContractVersion | None:
"""Return the current open version (effective_to IS NULL) for *contract*, or None."""
return session.execute(
select(EnergyContractVersion)
.where(
EnergyContractVersion.contract_id == contract.id,
EnergyContractVersion.effective_to.is_(None),
)
.order_by(EnergyContractVersion.effective_from.desc())
.limit(1)
).scalar_one_or_none()
# ---------------------------------------------------------------------------
# Core service functions
# ---------------------------------------------------------------------------
def create_contract(
session: Session,
*,
name: str,
kind: str,
currency: str = "EUR",
values: dict[str, Any],
effective_from: datetime,
) -> EnergyContract:
"""Create a new energy contract with its first pricing version.
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
name:
Human-readable label for the contract.
kind:
Pricing strategy identifier (``"manual"`` or ``"tibber"``).
currency:
ISO 4217 currency code (default ``"EUR"``).
values:
Pricing values dict conforming to the named profile's structure.
Validated via ``validate_values(kind, values)`` before any writes.
effective_from:
UTC datetime at which the first pricing version takes effect.
Returns
-------
EnergyContract
The newly created contract (not yet committed).
Raises
------
ProfileNotFoundError
If no YAML profile exists for *kind*.
ProfileValidationError
If *values* does not conform to the profile structure.
"""
# Validate (and fill defaults) before any DB write.
filled_values = validate_values(kind, values)
now = datetime.now(UTC)
contract = EnergyContract(
name=name,
kind=kind,
currency=currency,
active=False, # New contracts are inactive; caller must explicitly activate.
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush() # Assign contract.id so we can reference it in the version FK.
version = EnergyContractVersion(
contract_id=contract.id,
effective_from=effective_from,
effective_to=None,
values=filled_values,
created_at=now,
)
session.add(version)
logger.info("Created energy contract %r (kind=%s, id=%d)", name, kind, contract.id)
return contract
def add_version(
session: Session,
contract: EnergyContract,
*,
effective_from: datetime,
values: dict[str, Any],
) -> EnergyContractVersion:
"""Add a new pricing version to an existing contract (append-only).
The previous open version's ``effective_to`` is automatically set to the
new version's ``effective_from`` (version closure), ensuring there is never
a gap or overlap between consecutive versions.
The new ``effective_from`` **must be strictly greater than** the previous
open version's ``effective_from``. Equal timestamps are rejected because
they would produce two versions starting at the same instant, making it
impossible to determine which is current. If this constraint is not met,
``ContractVersionError`` is raised and **no rows are written**.
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
contract:
The parent ``EnergyContract`` to append a version to.
effective_from:
UTC datetime at which this version's pricing takes effect.
values:
New pricing values dict conforming to the contract's profile.
Returns
-------
EnergyContractVersion
The newly created version (not yet committed).
Raises
------
ContractVersionError
If *effective_from* is not strictly after the previous open version's
``effective_from``.
ProfileNotFoundError
If no YAML profile exists for the contract's kind.
ProfileValidationError
If *values* does not conform to the profile structure.
"""
# Validate (and fill defaults) before any DB write.
filled_values = validate_values(contract.kind, values)
prev = _open_version(session, contract)
if prev is not None:
# Enforce strictly-after constraint to prevent ambiguous overlap.
# Use _as_utc() on both sides: the incoming value may be tz-aware
# while the DB-read value is tz-naive (SQLite limitation).
if _as_utc(effective_from) <= _as_utc(prev.effective_from):
raise ContractVersionError(
f"New version effective_from ({effective_from.isoformat()}) must be strictly "
f"after the previous open version's effective_from "
f"({prev.effective_from.isoformat()})."
)
# Close the previous open version.
prev.effective_to = effective_from
now = datetime.now(UTC)
new_version = EnergyContractVersion(
contract_id=contract.id,
effective_from=effective_from,
effective_to=None,
values=filled_values,
created_at=now,
)
session.add(new_version)
logger.info(
"Added version to contract id=%d (kind=%s, effective_from=%s)",
contract.id,
contract.kind,
effective_from.isoformat(),
)
return new_version
def activate_contract(session: Session, contract: EnergyContract) -> None:
"""Activate a contract with mutual exclusion.
Sets every other contract's ``active`` flag to False, then sets the given
contract's ``active`` to True. This guarantees at most one active contract
at any time.
Caller must commit after this returns.
"""
# Deactivate all contracts (including the target; we re-activate below).
for other in session.execute(select(EnergyContract)).scalars().all():
other.active = False
contract.active = True
contract.updated_at = datetime.now(UTC)
logger.info("Activated contract %r (id=%d)", contract.name, contract.id)
def deactivate_contract(session: Session, contract: EnergyContract) -> None:
"""Deactivate a contract without touching other contracts.
Caller must commit after this returns.
"""
contract.active = False
contract.updated_at = datetime.now(UTC)
logger.info("Deactivated contract %r (id=%d)", contract.name, contract.id)
def active_contract_versions(session: Session) -> list[EnergyContractVersion]:
"""Return all versions of the currently active contract, ordered by effective_from ascending.
Returns an empty list when there is no active contract. The list spans the
full history of the active contract (all closed + the current open version)
and is used to iterate over pricing-rate segments for cross-version fixed-
cost / credit accumulation (Principle C).
"""
active = session.execute(
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
).scalar_one_or_none()
if active is None:
return []
return list(
session.execute(
select(EnergyContractVersion)
.where(EnergyContractVersion.contract_id == active.id)
.order_by(EnergyContractVersion.effective_from)
)
.scalars()
.all()
)
def active_contract_version_at(
session: Session, ts: datetime
) -> EnergyContractVersion | None:
"""Return the active contract's version that covers *ts*.
A version covers *ts* when:
``effective_from ts`` AND (``effective_to IS NULL`` OR ``ts < effective_to``)
Returns None when:
- There is no active contract.
- The active contract has no version covering *ts*.
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
ts:
UTC datetime to look up.
Returns
-------
EnergyContractVersion | None
"""
active = session.execute(
select(EnergyContract).where(EnergyContract.active.is_(True)).limit(1)
).scalar_one_or_none()
if active is None:
return None
# Build a query for all versions of the active contract covering ts.
stmt = (
select(EnergyContractVersion)
.where(
EnergyContractVersion.contract_id == active.id,
EnergyContractVersion.effective_from <= ts,
)
.order_by(EnergyContractVersion.effective_from.desc())
.limit(1)
)
version = session.execute(stmt).scalar_one_or_none()
if version is None:
return None
# Exclude versions whose effective window has already closed before ts.
if version.effective_to is not None and _as_utc(ts) >= _as_utc(version.effective_to):
return None
return version
+297
View File
@@ -0,0 +1,297 @@
"""DSMR telegram ingest service.
Subscribes to the DSMR Reader MQTT topic (``dsmr/json``) and persists
down-sampled DSMR telegram frames to the ``dsmr_reading`` table.
Design decisions
----------------
- **Whole-frame storage**: the entire parsed telegram dict is stored as a JSON
blob in ``DsmrReading.payload``; no field allow-list is applied. This lets
future commodities (gas, heating, three-phase) be accommodated without a
table-schema change.
- **10-second down-sampling** (configurable via ``dsmr_sample_interval_s``):
only telegrams whose ``timestamp`` second falls on an exact multiple of the
interval are persisted. This reduces write volume from ~60 rows/min to ~6
rows/min while guaranteeing that every 15-minute boundary (second=00) is
captured.
- **Idempotency**: the telegram's own ``id`` field is stored as ``source_id``
with a UNIQUE constraint. A second delivery of the same telegram (e.g. after
a broker reconnect) is silently skipped.
- **Network-thread safety**: ``handle_message`` is called from paho's background
loop thread. It opens and closes its own short-lived SQLAlchemy session and
swallows all exceptions so that a buggy payload or transient DB error never
crashes the paho loop or drops the MQTT connection.
- **Numeric values kept as strings**: the DSMR Reader emits all numeric readings
as JSON strings (e.g. ``"20915.154"``). They are stored verbatim; conversion
to ``Decimal`` is deferred to the billing engine (T07) where precision matters.
- **Null phases**: some telegrams omit certain phase readings (``null`` in JSON);
these are stored as-is without special handling.
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timezone
from typing import TYPE_CHECKING
import sqlalchemy.exc
from sqlalchemy import select
from app.db import get_session_local
from app.models.energy import DsmrReading
if TYPE_CHECKING:
from app.config import Settings
logger = logging.getLogger(__name__)
# Tracks the DSMR topic currently subscribed via the MQTT manager, so a config
# change can unsubscribe the old topic before subscribing the new one.
_current_dsmr_topic: str | None = None
# Tracks the DSMR tariff topic currently subscribed via the MQTT manager.
_current_tariff_topic: str | None = None
# Current electricity tariff: 1 = dal/off-peak, 2 = normal/peak.
# Written by the paho network thread, read by the publish job — guarded by a lock.
_current_tariff: int | None = None
_tariff_lock = threading.Lock()
def get_current_tariff() -> int | None:
"""Return the most recently received electricity tariff (1 or 2), or None."""
with _tariff_lock:
return _current_tariff
def set_current_tariff(value: int | None) -> None:
"""Set the current electricity tariff (1 or 2), or clear it with None."""
with _tariff_lock:
global _current_tariff
_current_tariff = value
def handle_tariff_message(payload_bytes: bytes) -> None:
"""Parse one DSMR tariff MQTT payload and update the in-memory tariff state.
Called from the paho network thread; *must* swallow all exceptions so that
a bad payload never crashes the loop or drops the broker connection.
Accepts payload as bytes or str (paho can deliver either). Ignores
whitespace. Only accepts integer values 1 or 2; anything else is discarded
and the previous known tariff is preserved.
Parameters
----------
payload_bytes:
Raw bytes from the MQTT message (may also be a str in some paho versions).
"""
try:
# Decode bytes → str if needed; strip surrounding whitespace.
if isinstance(payload_bytes, (bytes, bytearray)):
raw = payload_bytes.decode("utf-8", errors="replace").strip()
else:
raw = str(payload_bytes).strip()
value = int(raw)
if value not in (1, 2):
logger.debug(
"dsmr_ingest.handle_tariff_message: unexpected tariff value %d (expected 1 or 2, ignored).",
value,
)
return
set_current_tariff(value)
logger.debug("dsmr_ingest.handle_tariff_message: tariff updated to %d.", value)
except Exception:
# Malformed payload (e.g. non-numeric); swallow silently to protect network thread.
logger.debug(
"dsmr_ingest.handle_tariff_message: could not parse payload %r (ignored).",
payload_bytes,
)
def apply_dsmr_subscription(settings: "Settings") -> None:
"""(Re)apply the DSMR MQTT subscriptions to match *settings* — restart-free.
Call this at startup and after every config save. It makes the live MQTT
subscriptions reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic``
/ ``dsmr_sample_interval_s`` / ``dsmr_tariff_topic`` settings without an app
restart:
- **Disabled** unsubscribe any active DSMR and tariff subscriptions.
- **Enabled** (re)subscribe to ``dsmr_mqtt_topic`` with a handler bound to
a *fresh* settings snapshot, so a changed sample interval also takes effect.
Also subscribe to ``dsmr_tariff_topic`` when non-empty.
- **Topic changed** unsubscribe the old topic before subscribing the new one.
Idempotent and safe to call when MQTT is not connected (the subscription is
queued in the manager and established on the next connect).
"""
# Imported here (not at module top) to avoid a circular import at app start.
from app.integrations.mqtt import mqtt_manager
global _current_dsmr_topic, _current_tariff_topic
if not settings.dsmr_ingest_enabled:
if _current_dsmr_topic is not None:
mqtt_manager.unsubscribe(_current_dsmr_topic)
logger.info("DSMR ingest disabled — unsubscribed from topic=%s.", _current_dsmr_topic)
_current_dsmr_topic = None
if _current_tariff_topic is not None:
mqtt_manager.unsubscribe(_current_tariff_topic)
logger.info(
"DSMR ingest disabled — unsubscribed from tariff topic=%s.",
_current_tariff_topic,
)
_current_tariff_topic = None
return
# --- Main DSMR telegram topic ---
topic = settings.dsmr_mqtt_topic
if _current_dsmr_topic is not None and _current_dsmr_topic != topic:
mqtt_manager.unsubscribe(_current_dsmr_topic)
# Re-subscribe (overwrites any existing handler for this topic) with a fresh
# settings snapshot so dsmr_sample_interval_s changes take effect too.
snapshot = settings
mqtt_manager.subscribe(topic, lambda payload: handle_message(payload, snapshot))
_current_dsmr_topic = topic
logger.info("DSMR ingest enabled — subscribed to topic=%s.", topic)
# --- DSMR tariff topic (dual-tariff slot indicator) ---
tariff_topic = settings.dsmr_tariff_topic if settings.dsmr_tariff_topic else ""
if tariff_topic:
if _current_tariff_topic is not None and _current_tariff_topic != tariff_topic:
mqtt_manager.unsubscribe(_current_tariff_topic)
mqtt_manager.subscribe(tariff_topic, lambda payload: handle_tariff_message(payload))
_current_tariff_topic = tariff_topic
logger.info("DSMR tariff topic — subscribed to topic=%s.", tariff_topic)
else:
# tariff_topic is empty → unsubscribe any existing tariff subscription.
if _current_tariff_topic is not None:
mqtt_manager.unsubscribe(_current_tariff_topic)
logger.info(
"DSMR tariff topic cleared — unsubscribed from topic=%s.",
_current_tariff_topic,
)
_current_tariff_topic = None
def handle_message(payload_bytes: bytes, settings: "Settings") -> None:
"""Parse one DSMR MQTT payload and persist it if it passes the sample filter.
Called from the paho network thread; *must* swallow all exceptions so that
a bad payload or transient error does not crash the loop or drop the broker
connection.
Parameters
----------
payload_bytes:
Raw bytes from the MQTT message.
settings:
Runtime settings snapshot (captured at subscription time). Used for
``dsmr_sample_interval_s``.
"""
try:
_handle_message_inner(payload_bytes, settings)
except Exception:
logger.exception("dsmr_ingest.handle_message: unexpected error (swallowed).")
def _handle_message_inner(payload_bytes: bytes, settings: "Settings") -> None:
"""Inner implementation — may raise; caller wraps in try/except."""
# --- 1. Parse JSON ---
try:
data: dict = json.loads(payload_bytes)
except (json.JSONDecodeError, ValueError):
logger.debug("dsmr_ingest: invalid JSON payload (skipped).")
return
if not isinstance(data, dict):
logger.debug("dsmr_ingest: payload is not a JSON object (skipped).")
return
# --- 2. Parse timestamp ---
raw_ts = data.get("timestamp")
if raw_ts is None:
logger.debug("dsmr_ingest: missing 'timestamp' field (skipped).")
return
try:
# Python 3.11+ accepts the trailing 'Z' directly; for 3.10 compat we
# replace 'Z' with '+00:00' before parsing.
ts_str = raw_ts if not isinstance(raw_ts, str) else raw_ts.replace("Z", "+00:00")
ts_utc: datetime = datetime.fromisoformat(ts_str)
# Ensure it is timezone-aware UTC.
if ts_utc.tzinfo is None:
ts_utc = ts_utc.replace(tzinfo=timezone.utc)
except (ValueError, TypeError, AttributeError):
logger.debug(
"dsmr_ingest: cannot parse 'timestamp' value %r (skipped).", raw_ts
)
return
# --- 3. Down-sample: only persist if second falls on interval boundary ---
interval = settings.dsmr_sample_interval_s
if interval > 0 and (ts_utc.second % interval) != 0:
# This telegram is between sample points; discard silently.
return
# --- 4. Extract source_id (telegram's own id) — stored only as a reference,
# NOT used for uniqueness/idempotency (it overflows and gets reset). ---
source_id: int | None = data.get("id")
if source_id is not None and not isinstance(source_id, int):
# Unexpected type — treat as missing rather than raising.
logger.debug(
"dsmr_ingest: 'id' field has unexpected type %s (ignoring).",
type(source_id).__name__,
)
source_id = None
# --- 5. Persist to database ---
# Idempotency is keyed on recorded_at (the telegram timestamp), which is
# telegram-id-independent: a single P1 meter emits one telegram per second,
# and down-sampling keeps at most one per interval-aligned second. The
# UNIQUE(recorded_at) constraint is the backstop for the IntegrityError race.
session_local = get_session_local()
session = session_local()
try:
existing = session.scalar(
select(DsmrReading).where(DsmrReading.recorded_at == ts_utc)
)
if existing is not None:
logger.debug(
"dsmr_ingest: recorded_at=%s already in DB, skipping.",
ts_utc.isoformat(),
)
return
reading = DsmrReading(
recorded_at=ts_utc,
source_id=source_id,
payload=data, # full frame, verbatim
)
session.add(reading)
session.commit()
logger.debug(
"dsmr_ingest: persisted reading recorded_at=%s source_id=%s.",
ts_utc.isoformat(),
source_id,
)
except sqlalchemy.exc.IntegrityError:
# Race / duplicate: another insert beat us to the same recorded_at.
session.rollback()
logger.debug(
"dsmr_ingest: IntegrityError for recorded_at=%s (duplicate, skipped).",
ts_utc.isoformat(),
)
except Exception:
session.rollback()
logger.exception("dsmr_ingest: DB error (swallowed).")
finally:
session.close()
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage
from email.utils import formataddr
import smtplib
from app.config import Settings
class EmailConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or disabled."""
class EmailDeliveryError(RuntimeError):
"""Raised when sending email fails."""
@dataclass(frozen=True, slots=True)
class SMTPConfig:
host: str
port: int
username: str
password: str
from_name: str
from_address: str
to_address: str
use_starttls: bool
def get_smtp_config(settings: Settings, *, require_enabled: bool = True) -> SMTPConfig:
if require_enabled and not settings.smtp_enabled:
raise EmailConfigurationError("SMTP is disabled")
if not settings.smtp_host:
raise EmailConfigurationError("SMTP host is required")
if settings.smtp_port <= 0:
raise EmailConfigurationError("SMTP port must be greater than zero")
if not settings.smtp_from_address:
raise EmailConfigurationError("SMTP from address is required")
if not settings.smtp_to_address:
raise EmailConfigurationError("SMTP to address is required")
return SMTPConfig(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
from_name=settings.smtp_from_name,
from_address=settings.smtp_from_address,
to_address=settings.smtp_to_address,
use_starttls=settings.smtp_use_starttls,
)
def is_smtp_ready(settings: Settings) -> bool:
try:
get_smtp_config(settings, require_enabled=False)
except EmailConfigurationError:
return False
return True
def send_plaintext_email(
settings: Settings,
*,
subject: str,
body: str,
recipient: str | None = None,
require_enabled: bool = True,
) -> None:
smtp_config = get_smtp_config(settings, require_enabled=require_enabled)
message = EmailMessage()
message["Subject"] = subject
message["From"] = _build_from_header(smtp_config)
message["To"] = recipient or smtp_config.to_address
message.set_content(body)
try:
with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=10) as smtp:
smtp.ehlo()
if smtp_config.use_starttls:
smtp.starttls()
smtp.ehlo()
if smtp_config.username:
smtp.login(smtp_config.username, smtp_config.password)
smtp.send_message(
message,
from_addr=smtp_config.from_address,
to_addrs=[recipient or smtp_config.to_address],
)
except (OSError, smtplib.SMTPException) as exc:
error_message = _sanitize_error_message(str(exc), smtp_config.password)
raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc
def send_smtp_test_email(settings: Settings) -> None:
send_plaintext_email(
settings,
subject="Home Automation SMTP Test",
body="This is a test email from Home Automation SMTP settings.",
require_enabled=False,
)
def send_public_ip_changed_email(
settings: Settings,
*,
previous_ipv4: str,
current_ipv4: str,
detected_at: datetime,
) -> None:
send_plaintext_email(
settings,
subject="Public IP changed",
body=(
"Your public IPv4 address has changed.\n\n"
f"Previous IP: {previous_ipv4}\n"
f"Current IP: {current_ipv4}\n"
f"Detected at: {_format_utc_timestamp(detected_at)}\n\n"
"If you use Namecheap API trusted IP restrictions, you may need to "
"update the trusted IP manually.\n"
),
)
def _sanitize_error_message(message: str, password: str) -> str:
sanitized = message
if password:
sanitized = sanitized.replace(password, "[redacted]")
return sanitized
def _format_utc_timestamp(value: datetime) -> str:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.strftime("%Y-%m-%d %H:%M:%S UTC")
def _build_from_header(smtp_config: SMTPConfig) -> str:
if smtp_config.from_name:
return formataddr((smtp_config.from_name, smtp_config.from_address))
return smtp_config.from_address
+894
View File
@@ -0,0 +1,894 @@
"""Billing engine for DSMR 15-minute energy metering periods.
This module implements the two-layer billing model described in §3.4 of the
M6 design document, extended in M7-T03 to be meter-aware:
**Layer 1 per-period metering cost (immutable, price-snapshot)**
``compute_period(session, t0)`` computes the import cost, export revenue, and
net cost for the 15-minute period ``[t0, t0+15min)``. The result is written
to ``energy_cost_period`` with a full pricing snapshot so each row is
self-contained and auditable. Existing *successful* rows are never overwritten
by the normal tick path; only an explicit ``recompute_range`` call passes
``overwrite=True``.
**Layer 2 summary (computed at read time, not stored)**
``summarize(session, start, end)`` aggregates all non-degraded
``energy_cost_period`` rows in ``[start, end)``, then adds the daily
standing charges (network_fee + management_fee, apportioned at EUR/month
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
apportioned at EUR/year ÷ 365 per day).
Design notes
------------
- **Decimal arithmetic throughout**: all monetary computations use
``decimal.Decimal`` to avoid float binary rounding errors. Only when
writing to ``EnergyCostPeriod`` columns (Float) are values converted to
float. ``summarize`` converts back to Decimal for summation.
- **UTC quarter-hour grid**: period boundaries are aligned to UTC 00/15/30/45
minutes (``floor_to_quarter``). NL local time (CET/CEST) is always a whole
number of hours from UTC, so the quarter-hour grid is the same in both
timezone representations.
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
for cumulative kWh registers. ``register_at`` converts them to Decimal.
- **Degraded vs skip semantics**:
- *No meter coverage* (``meter_at`` returns None for t0): write a
``degraded=True`` row with ``meter_id=None``.
- *Cross-meter boundary* (m0.id != m1.id for t0/t1): write a ``degraded=True``
row with ``meter_id=m0.id``; losing this one period at the swap boundary is
acceptable (D5 decision).
- *Missing readings* (``register_at`` returns None for start or end
boundary within the meter window): write a ``degraded=True`` row with
``meter_id=m0.id`` so the period is tracked and can be retried by
``compute_closed_periods``.
- *Negative or excessively large delta* (delta sanity guard D6): write a
``degraded=True`` row with ``meter_id=m0.id``; prevents negative costs and
grossly inflated costs from meter resets, DSMR rollover, or data spikes.
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
not write a row); the period will be retried once prices arrive.
- *Missing active contract version*: skip (no contract to compute against).
- **Meter-aware register lookup**: ``register_at`` now accepts a ``meter``
parameter and restricts the DSMR reading query to readings within
``[meter.started_at, meter.ended_at)`` (half-open), preventing old-meter
readings from leaking into a new-meter epoch.
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all
historical DSMR data on every tick, the function looks back at most 7 days
from the current time. This covers typical short outages (no data / no
contract) while staying bounded. Periods older than 7 days must be
recovered via an explicit ``recompute_range`` call.
Meter-aware compute_period ordering rationale (M7-T03)
-------------------------------------------------------
The order of checks inside ``compute_period`` is:
1. **Immutability guard** (existing non-degraded row, overwrite=False) return False.
2. **Meter determination** (m0 = meter_at(t0), m1 = meter_at(t1)):
- No meter (m0 is None) write degraded, meter_id=None.
- Cross-meter boundary (m0.id != m1.id) write degraded, meter_id=m0.id.
3. **Active contract version check** skip (no write) if absent.
4. **Boundary register readings** within m0's window → write degraded if missing.
5. **Delta sanity guard** write degraded if any delta < 0 or > _MAX_DELTA_KWH.
6. **Price strategy** skip (no write) if Tibber price missing.
7. **Upsert billing record** with meter_id=m0.id.
Why meter before contract? The meter is a *structural* prerequisite: without a
known meter epoch we cannot trust the delta at all, so we commit a degraded row
immediately. The contract skip, by contrast, is transient (the period can be
re-computed once a contract is configured), so it produces no row.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.integrations.pricing.strategies import (
PeriodDeltas,
TibberPriceNotFoundError,
get_strategy,
)
from app.models.energy import DsmrReading, EnergyCostPeriod, Meter
from app.services.contracts import active_contract_version_at, active_contract_versions
from app.services.meters import meter_at
from app.services.timezone import local_date, local_now
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_PERIOD_MINUTES = 15
_LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
# Maximum age a DSMR reading may have relative to the boundary being queried.
# Under normal operation DSMR readings arrive every ~10 seconds, so a reading
# more than one period (15 minutes) old at the boundary indicates either a data
# gap or — critically — a *future* boundary being resolved against the last
# historical reading. In both cases the reading is considered stale and
# ``register_at`` returns None, letting the period be marked degraded instead of
# producing a spurious zero-delta "successful" row.
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# Maximum plausible kWh delta for a single 15-minute period (D6 sanity guard).
# A typical Dutch household uses well under 5 kWh per quarter hour even under
# heavy load. 100 kWh per 15 minutes corresponds to ~400 kW — far beyond any
# residential consumption — but is lenient enough to never fire on legitimate
# data. Any delta at or above this threshold indicates a meter reset, DSMR
# rollover, sign error, or other data anomaly, and the period is marked
# degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100")
# 每日固定费/税补在"本地午夜后多久"才结算入账。延后到 01:05 是为了让累计成本的
# 整天阶跃落在新一天、且避开 01:00 整点(HA 长期统计的小时桶边界)。
_SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5)
# DSMR payload register keys (cumulative kWh, JSON string values).
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
_KEY_R1 = "electricity_returned_1" # returned low-tariff
_KEY_R2 = "electricity_returned_2" # returned high-tariff
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def floor_to_quarter(dt: datetime) -> datetime:
"""Return *dt* floored to the nearest UTC quarter-hour boundary.
The result always has seconds=0 and microseconds=0, and minutes in
{0, 15, 30, 45}. Timezone info is preserved if present.
"""
floored_minute = (dt.minute // _PERIOD_MINUTES) * _PERIOD_MINUTES
return dt.replace(minute=floored_minute, second=0, microsecond=0)
def _to_decimal(value: Any) -> Decimal:
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
return Decimal(str(value))
def _as_utc(dt: datetime) -> datetime:
"""Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
"""Return the EnergyCostPeriod row for period_start=t0, or None."""
return session.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == t0)
).scalar_one_or_none()
# ---------------------------------------------------------------------------
# register_at — boundary reading lookup (meter-aware)
# ---------------------------------------------------------------------------
def register_at(
session: Session,
boundary: datetime,
meter: Meter,
) -> dict[str, Decimal] | None:
"""Return the four cumulative kWh register values at *boundary*, within *meter*'s window.
Queries the most recent ``DsmrReading`` with:
``recorded_at boundary``
AND ``recorded_at meter.started_at``
AND (``meter.ended_at IS NULL`` OR ``recorded_at < meter.ended_at``)
The meter window constraint (half-open ``[started_at, ended_at)``) ensures
that readings from a previous meter epoch are never used to anchor a new
meter's computation. Without this guard, the final reading of the old meter
would be visible at the start of the new meter's epoch and produce a
cross-meter delta, defeating the isolation guarantee.
Extracts the four energy registers from ``payload``:
d1 electricity_delivered_1 (delivered low-tariff / dal)
d2 electricity_delivered_2 (delivered high-tariff / normal)
r1 electricity_returned_1 (returned low-tariff)
r2 electricity_returned_2 (returned high-tariff)
Returns
-------
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
when:
- No ``DsmrReading`` row exists with ``recorded_at boundary`` within
*meter*'s epoch window.
- The most recent such reading is older than ``_READING_MAX_STALENESS``
relative to *boundary* (freshness guard).
- Any of the four register keys is absent from the payload.
- Any of the four register values is ``None`` (null in JSON).
SQLite naive datetime note
--------------------------
``recorded_at`` is stored as a naive UTC datetime in SQLite. Comparisons
against *boundary* (always tz-aware UTC) use ``_as_utc()`` for the
freshness check. The SQL ``WHERE`` clause comparisons work correctly
because SQLAlchemy's SQLite dialect strips tzinfo when binding parameters
(leaving the wall-clock UTC value unchanged), consistent with the storage
format.
"""
# Build the meter-window constraints: [started_at, ended_at).
meter_lower = meter.started_at # DsmrReading.recorded_at >= meter.started_at
meter_upper = meter.ended_at # DsmrReading.recorded_at < meter.ended_at (if set)
stmt = (
select(DsmrReading)
.where(
DsmrReading.recorded_at <= boundary,
DsmrReading.recorded_at >= meter_lower,
)
.order_by(DsmrReading.recorded_at.desc())
.limit(1)
)
# Apply the upper bound only when the meter is closed (ended_at is not None).
if meter_upper is not None:
stmt = stmt.where(DsmrReading.recorded_at < meter_upper)
row: DsmrReading | None = session.execute(stmt).scalar_one_or_none()
if row is None:
return None
# Freshness guard: reject readings that are too old relative to *boundary*.
# ``recorded_at`` is stored as a naive UTC datetime in SQLite; attach UTC
# tzinfo before comparing with *boundary* (which is always tz-aware UTC) to
# avoid an "offset-naive vs offset-aware" TypeError.
if _as_utc(row.recorded_at) < _as_utc(boundary) - _READING_MAX_STALENESS:
return None
payload = row.payload or {}
try:
d1_raw = payload[_KEY_D1]
d2_raw = payload[_KEY_D2]
r1_raw = payload[_KEY_R1]
r2_raw = payload[_KEY_R2]
except KeyError:
return None
if any(v is None for v in (d1_raw, d2_raw, r1_raw, r2_raw)):
return None
return {
"d1": _to_decimal(d1_raw),
"d2": _to_decimal(d2_raw),
"r1": _to_decimal(r1_raw),
"r2": _to_decimal(r2_raw),
}
# ---------------------------------------------------------------------------
# compute_period — single 15-minute period
# ---------------------------------------------------------------------------
def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -> bool:
"""Compute and upsert the billing record for the period ``[t0, t0+15min)``.
Parameters
----------
session:
Active SQLAlchemy session. Caller is responsible for committing.
t0:
UTC start of the 15-minute period. **Must** lie on a quarter-hour
grid boundary (minutes {0, 15, 30, 45}, seconds=0, microseconds=0).
overwrite:
If ``True``, overwrite an existing *successful* row (i.e. re-compute
even when a non-degraded record already exists). The normal tick path
always passes ``False``; only ``recompute_range`` passes ``True``.
Returns
-------
bool
``True`` if a record was written (inserted or updated), ``False`` if
the period was skipped (missing contract or missing Tibber price).
Side-effects
------------
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
- If no meter covers t0 (``meter_at`` returns None for t0): inserts/updates
a degraded row with ``meter_id=None``.
- If the period spans a meter boundary (``meter_at(t0).id != meter_at(t1).id``):
inserts/updates a degraded row with ``meter_id=m0.id`` (D5 decision).
- If readings are missing at either boundary within the meter window:
inserts/updates a degraded row with ``meter_id=m0.id``.
- If any delta is negative or exceeds ``_MAX_DELTA_KWH`` (D6 sanity guard):
inserts/updates a degraded row with ``meter_id=m0.id``.
- If the active contract version is missing: **skips** (returns False, no write).
- If the Tibber price is missing (TibberPriceNotFoundError): **skips**
(returns False, no write).
"""
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
now = datetime.now(UTC)
# Immutability guard: skip if a successful record already exists and we
# are not in overwrite mode.
existing = _existing_period(session, t0)
if existing is not None and not existing.degraded and not overwrite:
return False
# --- Meter determination (structural prerequisite, checked before contract) ---
#
# A missing or cross-boundary meter is a structural problem: we cannot trust
# the delta at all, so we write a degraded row immediately. This is different
# from the contract skip (transient, no write): the degraded row ensures the
# period appears in the history and can be revisited once the meter timeline
# is corrected and a recompute_range is triggered.
#
# Ordering rationale:
# 1. No meter (m0 is None) → degraded(meter_id=None): no epoch for t0.
# 2. Cross-meter boundary (m0.id != m1.id) → degraded(meter_id=m0.id): D5.
# 3. (Single meter, proceed) → contract check → readings → delta guard → price.
#
# We place meter before contract so that "cross-table period" is always
# marked degraded regardless of contract state. If we checked contract
# first, a missing-contract skip would silently discard the cross-table
# evidence; once a contract is added and recompute runs, the engine would
# incorrectly use cross-table reads.
m0 = meter_at(session, t0)
m1 = meter_at(session, t1)
if m0 is None:
# No meter epoch covers t0 — degraded with no meter attribution.
logger.debug(
"compute_period(%s): no active meter at t0 — writing degraded (meter_id=None).",
t0.isoformat(),
)
_upsert_degraded(session, t0, now, existing, meter_id=None)
return True
if m1 is None or m0.id != m1.id:
# Period spans a meter boundary or t1 has no meter. Degrade with m0's id
# (t0's meter attribution): the period's start belongs to m0's epoch.
logger.debug(
"compute_period(%s): period crosses meter boundary "
"(m0.id=%s, m1.id=%s) — writing degraded.",
t0.isoformat(),
m0.id,
m1.id if m1 is not None else None,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Active contract version at t0 ---
# If there is no active contract covering t0, skip the period entirely.
# We do not write a degraded row — there is no meaningful state to recover
# without a contract (we would not know which strategy to apply once data
# arrives). The period can be recovered via an explicit recompute_range once
# a contract is configured and activated.
version = active_contract_version_at(session, t0)
if version is None:
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
return False
# --- Boundary readings within m0's meter window ---
start_regs = register_at(session, t0, m0)
end_regs = register_at(session, t1, m0)
if start_regs is None or end_regs is None:
# Missing readings within the meter window → degraded with m0 attribution.
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True # a record was written (degraded)
# --- Compute deltas (end start) ---
deltas = PeriodDeltas(
d1=end_regs["d1"] - start_regs["d1"],
d2=end_regs["d2"] - start_regs["d2"],
r1=end_regs["r1"] - start_regs["r1"],
r2=end_regs["r2"] - start_regs["r2"],
)
# --- Delta sanity guard (D6) ---
# Any negative delta indicates a meter reset, DSMR rollover, or data error.
# Any delta exceeding _MAX_DELTA_KWH (100 kWh per 15 min = 400 kW average)
# is implausible for residential use and indicates an anomaly.
# Both cases produce a degraded row so no negative or grossly inflated cost
# is ever written to the billing record.
all_deltas = (deltas.d1, deltas.d2, deltas.r1, deltas.r2)
if any(d < Decimal("0") for d in all_deltas) or any(d > _MAX_DELTA_KWH for d in all_deltas):
logger.debug(
"compute_period(%s): delta sanity guard triggered "
"(d1=%s, d2=%s, r1=%s, r2=%s) — writing degraded.",
t0.isoformat(),
deltas.d1,
deltas.d2,
deltas.r1,
deltas.r2,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Price strategy ---
strategy = get_strategy(version.contract.kind)
try:
result = strategy(deltas, t0, version.values, session)
except TibberPriceNotFoundError:
# Missing Tibber price → skip the period; it will be retried once the
# price arrives (e.g. after the next Tibber refresh job runs).
logger.debug(
"compute_period(%s): no Tibber price found — skipping.", t0.isoformat()
)
return False
# --- Upsert the billing record ---
import_cost: Decimal = result["import_cost"]
export_revenue: Decimal = result["export_revenue"]
net_cost: Decimal = result["net_cost"]
pricing: dict = result["pricing"]
if existing is not None:
# Update in-place (overwrite=True or previous record was degraded).
existing.d1_kwh = float(deltas.d1)
existing.d2_kwh = float(deltas.d2)
existing.r1_kwh = float(deltas.r1)
existing.r2_kwh = float(deltas.r2)
existing.import_cost = float(import_cost)
existing.export_revenue = float(export_revenue)
existing.net_cost = float(net_cost)
existing.currency = version.contract.currency
existing.pricing = pricing
existing.contract_version_id = version.id
existing.meter_id = m0.id
existing.degraded = False
existing.computed_at = now
else:
period = EnergyCostPeriod(
period_start=t0,
d1_kwh=float(deltas.d1),
d2_kwh=float(deltas.d2),
r1_kwh=float(deltas.r1),
r2_kwh=float(deltas.r2),
import_cost=float(import_cost),
export_revenue=float(export_revenue),
net_cost=float(net_cost),
currency=version.contract.currency,
pricing=pricing,
contract_version_id=version.id,
meter_id=m0.id,
degraded=False,
computed_at=now,
)
session.add(period)
return True
def _upsert_degraded(
session: Session,
t0: datetime,
now: datetime,
existing: EnergyCostPeriod | None,
*,
meter_id: int | None,
) -> None:
"""Insert or update a degraded placeholder for period *t0*.
Parameters
----------
session:
Active SQLAlchemy session.
t0:
UTC start of the 15-minute period.
now:
Current UTC timestamp for the ``computed_at`` field.
existing:
The existing ``EnergyCostPeriod`` row for this period, or ``None``.
meter_id:
The meter ID to attribute this degraded period to, or ``None`` when
no meter epoch covers the period (no-meter degraded case).
When *existing* is not None (row was previously written either degraded
or successful), the row is explicitly reset to the standard degraded state.
This is required for the ``recompute_range`` (overwrite=True) path: if the
row was previously a *successful* computation and the boundary readings have
since disappeared, the stale non-zero costs must be cleared so the row
accurately reflects the current "missing readings" state rather than
masquerading as a valid result.
The ``meter_id`` is always updated to reflect the current meter attribution
judgment (the result of ``meter_at`` at the time of recompute). This
ensures that a retroactive ``started_at`` change + ``recompute_range`` will
re-attribute historical degraded periods to the correct meter epoch.
"""
if existing is not None:
# Explicitly reset to degraded state — identical field values to the
# new-row path below. This covers the recompute-over-successful-row
# case where old non-zero costs must not survive the downgrade.
existing.d1_kwh = 0.0
existing.d2_kwh = 0.0
existing.r1_kwh = 0.0
existing.r2_kwh = 0.0
existing.import_cost = 0.0
existing.export_revenue = 0.0
existing.net_cost = 0.0
existing.pricing = {}
existing.contract_version_id = None
existing.meter_id = meter_id
existing.degraded = True
existing.computed_at = now
else:
period = EnergyCostPeriod(
period_start=t0,
d1_kwh=0.0,
d2_kwh=0.0,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.0,
export_revenue=0.0,
net_cost=0.0,
currency="EUR", # placeholder; real currency known after contract lookup
pricing={},
contract_version_id=None,
meter_id=meter_id,
degraded=True,
computed_at=now,
)
session.add(period)
# ---------------------------------------------------------------------------
# compute_closed_periods — periodic tick
# ---------------------------------------------------------------------------
def compute_closed_periods(session: Session) -> int:
"""Find and compute all uncalculated closed 15-minute periods.
A period ``[t0, t1)`` is *closed* when ``t1 now``. This function:
1. Determines the lookback window: from ``now LOOKBACK_DAYS`` to ``now``,
floored to the nearest quarter-hour. This avoids an unbounded full
historical scan on every tick while still covering the typical recovery
window (short outages, missing contract, etc.). Periods older than
``LOOKBACK_DAYS`` must be recovered via an explicit ``recompute_range``.
2. Iterates over all quarter-hour boundaries in that window where
``t1 now`` (i.e. the period has already closed).
3. For each boundary, calls ``compute_period(overwrite=False)``, which:
- Skips periods that already have a *successful* (non-degraded) record.
- Retries periods that have a *degraded* record.
- Writes degraded rows for periods with no meter or cross-meter boundaries.
- Skips periods for which no active contract version exists or the
Tibber price is unavailable (without writing a degraded row).
Returns
-------
int
Number of periods for which a record was written (inserted or updated).
Does not count skipped periods.
"""
now = datetime.now(UTC)
# Current period boundary (the one whose t1 has not yet passed).
current_t0 = floor_to_quarter(now)
# Earliest boundary to consider.
earliest_t0 = floor_to_quarter(now - timedelta(days=_LOOKBACK_DAYS))
written = 0
t0 = earliest_t0
while t0 < current_t0:
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
if t1 <= now:
try:
did_write = compute_period(session, t0, overwrite=False)
if did_write:
written += 1
except Exception:
logger.exception(
"compute_closed_periods: unexpected error for t0=%s — continuing.",
t0.isoformat(),
)
t0 += timedelta(minutes=_PERIOD_MINUTES)
if written:
session.commit()
logger.info("compute_closed_periods: wrote %d period(s).", written)
return written
# ---------------------------------------------------------------------------
# recompute_range — explicit full recompute
# ---------------------------------------------------------------------------
def recompute_range(session: Session, start: datetime, end: datetime) -> int:
"""Recompute (overwrite) all 15-minute periods in ``[start, end)``.
This is the *explicit opt-in* path for recovering from:
- Periods where readings or prices arrived late.
- Price corrections (new contract version retroactively applied).
- Retroactive meter changes (``update_meter`` with new ``started_at``)
re-running this function will re-judge meter attribution and re-compute
costs using the corrected epoch boundaries.
- Any other reason to override the immutability guard.
The function iterates over every UTC quarter-hour boundary in
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
Existing rows (including successful ones) are overwritten; their
``meter_id`` fields will reflect the *current* ``meter_at`` judgment for
each period's start timestamp, naturally re-attributing periods when meter
``started_at`` values have been retroactively corrected.
Parameters
----------
session:
Active SQLAlchemy session. The function commits after all periods
have been processed.
start:
Inclusive start datetime (floored to the nearest quarter-hour internally).
end:
Exclusive end datetime.
Returns
-------
int
Number of periods for which a record was written (inserted or updated).
Periods skipped due to missing contract or missing Tibber price are
*not* counted.
"""
t0 = floor_to_quarter(_as_utc(start))
end_utc = _as_utc(end)
now = datetime.now(UTC)
written = 0
while t0 < end_utc:
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
# Only recompute periods that have already closed (t1 ≤ now). Even
# when the caller passes a future *end*, we must not write speculative
# "future" rows: register_at would resolve to the latest historical
# reading for both boundaries, producing a zero-delta fake-success row
# that blocks the real computation once the period actually closes.
if t1 <= now:
try:
did_write = compute_period(session, t0, overwrite=True)
if did_write:
written += 1
except Exception:
logger.exception(
"recompute_range: unexpected error for t0=%s — continuing.",
t0.isoformat(),
)
t0 += timedelta(minutes=_PERIOD_MINUTES)
session.commit()
logger.info(
"recompute_range(%s, %s): wrote %d period(s).",
start.isoformat(),
end.isoformat(),
written,
)
return written
# ---------------------------------------------------------------------------
# summarize — layer-2 aggregation (read-time, not stored)
# ---------------------------------------------------------------------------
def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any]:
"""Aggregate billing for the interval ``[start, end)``.
Computes the total payable as:
total_payable = Σ(net_cost) -- metered electricity
+ fixed_costs -- per-day standing charges, cross-version
- credits -- per-day heffingskorting, cross-version
**Fixed-cost / credit counting Principle C (symmetric begin/end)**:
Both the local calendar day in which *start* falls and the local calendar
day in which *end* falls are counted as full days. Fixed charges
(network_fee, management_fee) and the energy-tax credit (heffingskorting)
are assessed on a "service-is-active" basis if the meter was online on a
given calendar day, the full day's charge/credit applies, regardless of
whether the window starts at midnight or mid-morning.
For each local calendar date D in the range
``[local_date(start), min(local_date(end), today_local)]``:
- D today_local (only elapsed / today days count as "whole days").
- The contract version whose effective_from local-date D is used.
- This is cross-version: if V1 is from June 1 and V2 from June 25,
querying June 130 uses V1 for days 1-24 and V2 for day 25.
The end-day (last_counted) is included when the end's local midnight falls
strictly before end_utc; combined with the always-counted start day this
makes the begin/end handling symmetric. A short same-day window therefore
counts its single local day. A window contributes 0 days only when the
counted range is empty (first_counted > last_counted) e.g. a window lying
entirely in the future, since last_counted is capped at today_local.
Daily getters (``*_today``) use windows exactly aligned to local midnight,
so their ``first_counted`` is always today unaffected by this fix.
Days that have not yet started in local time (D > today_local) are
never counted. Switching versions never resets the counter.
**Timezone note**: the ``days`` field in the returned dict still represents
the window length in calendar days (total_seconds / 86400), for backward
compatibility with existing API consumers. The fixed/credit calculation
independently counts whole local days as described above.
All arithmetic uses Decimal; the returned dict contains Python floats for
JSON-serialisation convenience.
Parameters
----------
session:
Active read-only SQLAlchemy session.
start:
Inclusive start of the summary interval (UTC or naive-UTC).
end:
Exclusive end of the summary interval (UTC or naive-UTC).
Returns
-------
dict with keys:
currency str ISO 4217 currency (from contract, or "EUR" fallback)
metered_import float Σ import_cost from non-degraded periods
metered_export float Σ export_revenue from non-degraded periods
metered_net float Σ net_cost from non-degraded periods
fixed_costs float standing charges for elapsed whole local days
credits float energy-tax credit for elapsed whole local days
total_payable float metered_net + fixed_costs credits
period_count int number of non-degraded periods in range
degraded_count int number of degraded periods in range
days float interval length in days (total_seconds / 86400)
"""
from datetime import timedelta as _td, date as _date
start_utc = _as_utc(start)
end_utc = _as_utc(end)
# --- Fetch all EnergyCostPeriod rows in [start, end) ---
rows = session.execute(
select(EnergyCostPeriod).where(
EnergyCostPeriod.period_start >= start_utc,
EnergyCostPeriod.period_start < end_utc,
)
).scalars().all()
good_rows = [r for r in rows if not r.degraded]
degraded_rows = [r for r in rows if r.degraded]
# Σ monetary amounts (Decimal arithmetic).
sum_import = sum((_to_decimal(r.import_cost) for r in good_rows), Decimal("0"))
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
# --- Interval length in days (window, not elapsed — kept for API compat) ---
total_seconds = (end_utc - start_utc).total_seconds()
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
_now_local = local_now()
today_local: _date = _now_local.date()
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
#
# Both the window-start day and the window-end day are counted as complete
# local calendar days, regardless of whether the window starts/ends at midnight.
#
# Principle C (symmetric begin/end):
# • first_counted = local calendar date of start_utc (start day always counted)
# • last_counted = local calendar date of end_utc (end day counted if its
# local midnight is strictly before end_utc)
# • Both are then capped at today_local (only elapsed / today days count).
#
# Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits
# (heffingskorting) are assessed on a "service-is-active" basis, not on how many
# hours the service was actually running within that calendar day. If the meter
# anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge
# and credit still apply because the service was online for that day.
#
# Previous asymmetric behaviour: a start_utc that was *later* than the local
# midnight of local_start_date caused first_counted to be bumped to the *next*
# day, silently dropping the anchor day's charges/credits. This was incorrect
# for cumulative entities (import_cost_total / export_revenue_total) whose anchor
# is often an above-midnight started_at. The end-side had always been symmetric
# (counted if local midnight < end_utc), creating an inconsistency.
#
# Daily getters (window = [local today 00:00, local tomorrow 00:00)) are
# unaffected: local_date(local_midnight_utc(today)) == today, so first_counted
# is still today regardless of the fix.
#
# A short same-day window now counts its single local day (the start day is
# always counted, symmetric with the end side). A window contributes 0 days
# only when the counted range is empty (first_counted > last_counted) — e.g. a
# window lying entirely in the future, where last_counted is capped at
# today_local while first_counted is later.
from app.services.timezone import local_midnight_utc as _lmu
# Start side: always count the local calendar day in which start_utc falls.
first_counted: _date = local_date(start_utc)
local_end_date: _date = local_date(end_utc)
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
if _lmu(local_end_date) < end_utc:
last_counted: _date = local_end_date
else:
last_counted = local_end_date - _td(days=1)
# Settlement cap: "today" is only counted once the local clock has passed the
# settlement offset since midnight (01:05). This defers the daily standing-charge
# step from 00:00 to 01:05, ensuring the cumulative-cost adiabatic jump lands
# inside the new calendar day and avoids the HA 01:00 hourly-bucket boundary.
_today_midnight_utc = _lmu(today_local)
if _now_local >= _today_midnight_utc + _SETTLEMENT_OFFSET:
settled_cap = today_local
else:
settled_cap = today_local - _td(days=1)
last_counted = min(last_counted, settled_cap)
# If the range is empty (first_counted > last_counted), no days are counted.
# Fetch all versions of the active contract once (single DB call).
versions = active_contract_versions(session)
fixed_dec = Decimal("0")
credits_dec = Decimal("0")
currency = "EUR"
if versions:
# Currency comes from the contract regardless of day count.
currency = versions[0].contract.currency
if versions and first_counted <= last_counted:
# For each version segment, find its overlap with [first_counted, last_counted]
# and accumulate whole days.
version_segments: list[tuple[_date, _date | None, dict]] = []
for v in versions:
v_start_local = local_date(_as_utc(v.effective_from))
v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None
version_segments.append((v_start_local, v_end_local, v.values or {}))
for v_start, v_end_excl, v_values in version_segments:
# The version covers [v_start, v_end_excl) in local dates,
# where v_end_excl=None means open-ended (no upper bound).
# Intersection with [first_counted, last_counted]:
seg_start = max(first_counted, v_start)
if v_end_excl is not None:
seg_end_excl = min(last_counted + _td(days=1), v_end_excl)
else:
seg_end_excl = last_counted + _td(days=1)
if seg_start >= seg_end_excl:
continue # no overlap
n_days = (seg_end_excl - seg_start).days
if n_days <= 0:
continue
standing: dict = v_values.get("standing", {})
creds: dict = v_values.get("credits", {})
network_fee = _to_decimal(standing.get("network_fee", 0))
management_fee = _to_decimal(standing.get("management_fee", 0))
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
# Standing charges: EUR/month → EUR/day (÷ 30) × n_days.
fixed_dec += (network_fee + management_fee) / Decimal("30") * Decimal(str(n_days))
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × n_days.
credits_dec += heffingskorting / Decimal("365") * Decimal(str(n_days))
total_payable = sum_net + fixed_dec - credits_dec
return {
"currency": currency,
"metered_import": float(sum_import),
"metered_export": float(sum_export),
"metered_net": float(sum_net),
"fixed_costs": float(fixed_dec),
"credits": float(credits_dec),
"total_payable": float(total_payable),
"period_count": len(good_rows),
"degraded_count": len(degraded_rows),
"days": float(days),
}
+499
View File
@@ -0,0 +1,499 @@
"""HA MQTT Discovery service — build and publish discovery configs + state.
This module handles:
1. Building HA MQTT Discovery payloads for each ``ExposableEntity``.
2. Publishing discovery configs (retained) for enabled entities.
3. Clearing (empty-payload) discovery configs for disabled entities.
4. Publishing current entity state values.
5. A per-device helper for use in the Modbus poll loop.
Design
------
- **No-op when MQTT / discovery is not configured**: every public function
checks ``ha_discovery_enabled`` and ``mqtt_manager.is_connected`` before
doing any work. Callers never need to guard these conditions.
- **Stable unique_id**: derived from device ``uuid`` + metric ``key``, never
from mutable fields like ``friendly_name`` or auto-increment DB ids.
- **Discovery topic format**: ``<discovery_prefix>/<component>/<node_id>/<object_id>/config``
where ``node_id`` is the device uuid (slugified to be safe) and
``object_id`` is a uuid-prefixed stable string. Uses ``ha_discovery_prefix``
(default ``"homeassistant"``), which must stay under the HA discovery namespace.
- **State topic format**: ``<state_prefix>/<component>/<node_id>/<object_id>/state``
Uses ``ha_state_topic_prefix`` (default ``"home_automation"``), separate from
the discovery namespace so HA discovery and state/availability topics live under
different prefixes.
- **Availability topic**: ``<state_prefix>/modbus/<node_id>/availability`` (shared
across all entities of the same device; publishes "online"/"offline"). Also uses
the state prefix, not the discovery prefix.
- **best-effort**: functions catch all exceptions internally so that callers
(e.g. the Modbus poll loop) never crash due to MQTT publish errors.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from sqlalchemy.orm import Session
from app.integrations.expose import ExposableEntity, build_catalog
from app.integrations.mqtt import mqtt_manager
from app.services.config_page import build_runtime_settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _should_publish(settings: Any) -> bool:
"""Return True only if MQTT and HA Discovery are both enabled and connected."""
return bool(
settings.mqtt_enabled
and settings.ha_discovery_enabled
and mqtt_manager.is_connected
)
def _node_id(device_uuid: str) -> str:
"""Stable MQTT node_id derived from device uuid (replace hyphens for safety)."""
return device_uuid.replace("-", "_")
def _object_id(entity: ExposableEntity) -> str:
"""Stable MQTT object_id derived from entity key (dots + hyphens → underscores)."""
return entity.key.replace(".", "_").replace("-", "_")
def _discovery_topic(entity: ExposableEntity, prefix: str) -> str:
"""Build the HA Discovery config topic for *entity*.
Format: ``<prefix>/<component>/<node_id>/<object_id>/config``
"""
node = _node_id(entity.device.identifiers[1]) # device uuid
obj = _object_id(entity)
return f"{prefix}/{entity.component}/{node}/{obj}/config"
def _state_topic(entity: ExposableEntity, prefix: str) -> str:
"""Build the state publish topic for *entity*.
Format: ``<prefix>/<component>/<node_id>/<object_id>/state``
"""
node = _node_id(entity.device.identifiers[1])
obj = _object_id(entity)
return f"{prefix}/{entity.component}/{node}/{obj}/state"
def _availability_topic(device_uuid: str, prefix: str) -> str:
"""Shared availability topic for all entities of a device.
Format: ``<prefix>/modbus/<node_id>/availability``
"""
node = _node_id(device_uuid)
return f"{prefix}/modbus/{node}/availability"
def _unique_id(entity: ExposableEntity) -> str:
"""Stable unique_id — device uuid + metric key (never from mutable fields)."""
device_uuid = entity.device.identifiers[1]
# entity.key is already "modbus.<uuid>.<metric_key>" — use it as the seed
return f"{device_uuid}_{entity.key.replace('.', '_')}"
# ---------------------------------------------------------------------------
# Public: build discovery payload
# ---------------------------------------------------------------------------
def build_discovery_payload(
entity: ExposableEntity,
discovery_prefix: str,
state_prefix: str | None = None,
) -> tuple[str, dict[str, Any]]:
"""Build the HA MQTT Discovery config topic and payload dict for *entity*.
Parameters
----------
entity:
An ``ExposableEntity`` (from ``build_catalog``).
discovery_prefix:
The HA Discovery config topic prefix (e.g. ``"homeassistant"``).
This determines where HA looks for the discovery config topic.
state_prefix:
The prefix used for state and availability topics (e.g.
``"home_automation"``). When omitted or ``None``, falls back to
``discovery_prefix`` for backward compatibility (e.g. in tests that
pass only one prefix).
Returns
-------
tuple[str, dict]
``(topic, config_dict)`` the config topic and the HA Discovery
payload (not yet JSON-serialised).
"""
if state_prefix is None:
state_prefix = discovery_prefix
device_uuid = entity.device.identifiers[1]
avail_topic = _availability_topic(device_uuid, state_prefix)
state_t = _state_topic(entity, state_prefix)
topic = _discovery_topic(entity, discovery_prefix)
config: dict[str, Any] = {
"unique_id": _unique_id(entity),
"name": entity.name,
"state_topic": state_t,
"device": {
"identifiers": list(entity.device.identifiers),
"name": entity.device.name,
},
}
# Only declare an availability topic for devices that actually publish an
# online/offline heartbeat (e.g. Modbus, via its "online" binary_sensor).
# Devices without a heartbeat (e.g. energy-cost) omit availability so HA
# treats their entities as always-available — otherwise HA would mark them
# ``unavailable`` forever even though their state is being published.
if entity.device.provides_availability:
config["availability"] = [{"topic": avail_topic}]
config["availability_mode"] = "all"
# Component-specific fields
if entity.component == "binary_sensor":
# "online" binary sensor: payload ON/OFF
config["payload_on"] = "ON"
config["payload_off"] = "OFF"
if entity.device_class:
config["device_class"] = entity.device_class
else:
# sensor (and others)
if entity.device_class:
config["device_class"] = entity.device_class
if entity.unit:
config["unit_of_measurement"] = entity.unit
if entity.state_class:
config["state_class"] = entity.state_class
return topic, config
# ---------------------------------------------------------------------------
# Public: publish discovery
# ---------------------------------------------------------------------------
def publish_discovery(session: Session) -> None:
"""Publish HA Discovery configs for all entities in the catalog.
- Enabled entities: publish retained JSON config payload.
- Disabled entities: publish empty (b"") payload to clear any previously
retained config from HA.
No-op if MQTT / discovery is not enabled or the client is not connected.
All MQTT errors are caught internally this function never raises.
"""
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
logger.debug("publish_discovery: skipped (MQTT/Discovery not enabled or not connected)")
return
discovery_prefix = settings.ha_discovery_prefix
state_prefix = settings.ha_state_topic_prefix
try:
catalog = build_catalog(session)
except Exception:
logger.exception("publish_discovery: failed to build catalog; aborting")
return
for entry in catalog:
entity = entry.entity
try:
topic, config = build_discovery_payload(entity, discovery_prefix, state_prefix)
if entry.enabled:
payload = json.dumps(config)
mqtt_manager.publish(topic, payload, retain=True)
logger.debug(
"publish_discovery: published config for %r%s", entity.key, topic
)
else:
# Clear retained config for disabled entities.
mqtt_manager.publish(topic, b"", retain=True)
logger.debug(
"publish_discovery: cleared config for disabled entity %r%s",
entity.key,
topic,
)
except Exception:
logger.exception(
"publish_discovery: error processing entity %r; continuing", entity.key
)
# ---------------------------------------------------------------------------
# Public: publish states
# ---------------------------------------------------------------------------
def publish_states(session: Session) -> None:
"""Publish current state values for all enabled entities.
Calls each entity's ``value_getter`` to obtain the current value and
publishes it to the entity's state topic. Also publishes availability
(online/offline) for each device.
No-op if MQTT / discovery is not enabled or the client is not connected.
All errors are caught internally this function never raises.
"""
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
logger.debug("publish_states: skipped (MQTT/Discovery not enabled or not connected)")
return
state_prefix = settings.ha_state_topic_prefix
try:
catalog = build_catalog(session)
except Exception:
logger.exception("publish_states: failed to build catalog; aborting")
return
# Collect enabled entries and publish
for entry in catalog:
if not entry.enabled:
continue
entity = entry.entity
try:
_publish_entity_state(entity, state_prefix, session)
except Exception:
logger.exception(
"publish_states: error publishing state for %r; continuing", entity.key
)
def _publish_entity_state(
entity: ExposableEntity,
prefix: str,
session: Session,
) -> None:
"""Publish one entity's current state value to its state topic.
Also publishes the availability topic for ``binary_sensor`` "online" entities.
"""
state_t = _state_topic(entity, prefix)
device_uuid = entity.device.identifiers[1]
if entity.component == "binary_sensor" and "online" in entity.key:
# The online sensor represents device availability.
# Ask the value_getter for the current ON/OFF string, then also publish
# the shared availability topic (online/offline).
raw_value = None
if entity.value_getter is not None:
try:
raw_value = entity.value_getter(session)
except Exception:
logger.exception("value_getter raised for online entity %r", entity.key)
# Default to offline when no reading is available.
online = (raw_value == "ON")
avail_payload = "online" if online else "offline"
avail_topic = _availability_topic(device_uuid, prefix)
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
# The state of the binary_sensor itself
state_payload = "ON" if online else "OFF"
mqtt_manager.publish(state_t, state_payload, retain=False)
else:
# Regular sensor: call value_getter(session) if available.
value = None
if entity.value_getter is not None:
try:
value = entity.value_getter(session)
except Exception:
logger.exception("value_getter raised for entity %r", entity.key)
if value is None:
logger.debug("publish_states: no value for %r — skipping state publish", entity.key)
return
mqtt_manager.publish(state_t, str(value), retain=False)
# ---------------------------------------------------------------------------
# Public: per-device state push (called by modbus_poll after each poll)
# ---------------------------------------------------------------------------
def publish_device_state(session: Session, device: Any) -> None:
"""Publish state + availability for all enabled entities of *device*.
Called by ``modbus_poll.poll_device`` after a successful (or failed) poll.
Publishes:
- The current availability topic ("online" / "offline") for the device.
- The state topic for each **enabled** entity of the device.
No-op if MQTT / discovery is not enabled or the client is not connected.
All errors are caught internally never raises.
"""
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
return
state_prefix = settings.ha_state_topic_prefix
device_uuid = device.uuid
online = bool(device.last_poll_ok)
# Publish availability topic first.
try:
avail_topic = _availability_topic(device_uuid, state_prefix)
avail_payload = "online" if online else "offline"
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
except Exception:
logger.exception(
"publish_device_state: failed to publish availability for device uuid=%s", device_uuid
)
if not online:
# Device is offline — no point pushing stale state values.
return
# Publish state for each enabled entity of this device.
try:
catalog = build_catalog(session)
except Exception:
logger.exception(
"publish_device_state: failed to build catalog for device uuid=%s", device_uuid
)
return
for entry in catalog:
if not entry.enabled:
continue
entity = entry.entity
# Only process entities belonging to this device.
if entity.device.identifiers[1] != device_uuid:
continue
# Skip the online binary_sensor itself (availability handled above).
if entity.component == "binary_sensor" and "online" in entity.key:
# Publish the binary_sensor state too.
try:
state_t = _state_topic(entity, state_prefix)
mqtt_manager.publish(state_t, "ON", retain=False)
except Exception:
logger.exception(
"publish_device_state: error publishing online sensor state for %r",
entity.key,
)
continue
# Regular sensor — call value_getter(session).
try:
value = None
if entity.value_getter is not None:
value = entity.value_getter(session)
if value is None:
continue
state_t = _state_topic(entity, state_prefix)
mqtt_manager.publish(state_t, str(value), retain=False)
except Exception:
logger.exception(
"publish_device_state: error publishing state for entity %r", entity.key
)
def clear_device_discovery(session: Session, device_uuid: str) -> None:
"""Best-effort: send empty retained payloads to all HA Discovery config topics
for the given device, effectively removing the device's entities from HA.
This must be called **before** the device rows are deleted from the DB, so
that ``build_catalog`` can still enumerate the device's entities.
Behaviour
---------
- No-op if MQTT / HA Discovery is not enabled or the MQTT client is not
connected.
- All exceptions are caught internally; this function never raises.
The caller proceeds with the DB deletion regardless of MQTT outcome.
Parameters
----------
session:
Active SQLAlchemy session (device must still exist in DB at call time).
device_uuid:
UUID string of the device being deleted.
"""
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
logger.debug(
"clear_device_discovery: skipped (MQTT/Discovery not enabled or not connected)"
)
return
discovery_prefix = settings.ha_discovery_prefix
state_prefix = settings.ha_state_topic_prefix
try:
catalog = build_catalog(session)
except Exception:
logger.exception(
"clear_device_discovery: failed to build catalog for device uuid=%s; skipping HA cleanup",
device_uuid,
)
return
cleared = 0
for entry in catalog:
entity = entry.entity
# Only clear entities belonging to this device.
if entity.device.identifiers[1] != device_uuid:
continue
try:
topic, _config = build_discovery_payload(entity, discovery_prefix, state_prefix)
mqtt_manager.publish(topic, b"", retain=True)
cleared += 1
logger.debug(
"clear_device_discovery: cleared config topic for entity %r%s",
entity.key,
topic,
)
except Exception:
logger.exception(
"clear_device_discovery: error clearing entity %r; continuing", entity.key
)
logger.info(
"clear_device_discovery: cleared %d HA discovery topic(s) for device uuid=%s",
cleared,
device_uuid,
)
def publish_device_offline(session: Session, device_uuid: str) -> None:
"""Publish an "offline" availability payload for *device_uuid*.
Called by ``modbus_poll.poll_device`` when a poll fails, to immediately
reflect the device going offline in HA (before the next full state sweep).
No-op if MQTT / discovery is not enabled or the client is not connected.
Never raises.
"""
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
return
try:
state_prefix = settings.ha_state_topic_prefix
avail_topic = _availability_topic(device_uuid, state_prefix)
mqtt_manager.publish(avail_topic, "offline", retain=False)
except Exception:
logger.exception(
"publish_device_offline: failed for device uuid=%s", device_uuid
)
+37
View File
@@ -4,11 +4,14 @@ import json
from datetime import UTC, datetime, time, timedelta
from sqlalchemy.orm import Session
from app.config import Settings
from app.integrations.homeassistant import HomeAssistantClient
from app.integrations.ticktick import TICKTICK_DATETIME_FORMAT, TickTickClient, TickTickTask
from app.schemas.homeassistant import HomeAssistantPublishEnvelope
from app.schemas.location import LocationRecordRequest
from app.schemas.ticktick import TickTickActionTaskRequest
from app.services.location import record_location
from app.services.poo import publish_latest_poo_status
class UnsupportedHomeAssistantMessage(RuntimeError):
@@ -19,11 +22,23 @@ def handle_homeassistant_message(
session: Session,
envelope: HomeAssistantPublishEnvelope,
ticktick_client: TickTickClient | None = None,
poo_session: Session | None = None,
settings: Settings | None = None,
homeassistant_client: HomeAssistantClient | None = None,
) -> None:
if envelope.target == "location_recorder":
_handle_location_message(session, envelope)
return
if envelope.target == "poo_recorder":
_handle_poo_message(
envelope,
poo_session=poo_session,
settings=settings,
homeassistant_client=homeassistant_client,
)
return
if envelope.target == "ticktick":
_handle_ticktick_message(envelope, ticktick_client)
return
@@ -44,6 +59,28 @@ def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnv
record_location(session, payload)
def _handle_poo_message(
envelope: HomeAssistantPublishEnvelope,
*,
poo_session: Session | None,
settings: Settings | None,
homeassistant_client: HomeAssistantClient | None,
) -> None:
if envelope.action != "get_latest":
raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
)
if poo_session is None or settings is None or homeassistant_client is None:
raise RuntimeError("Poo recorder integration is unavailable")
publish_latest_poo_status(
session=poo_session,
settings=settings,
homeassistant_client=homeassistant_client,
)
def _handle_ticktick_message(
envelope: HomeAssistantPublishEnvelope,
ticktick_client: TickTickClient | None,
+56 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import insert
from sqlalchemy import delete, insert, select
from sqlalchemy.orm import Session
from app.models.location import Location
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
)
session.execute(stmt)
session.commit()
def update_location(
session: Session,
person: str,
datetime_pk: str,
*,
latitude: float | None,
longitude: float | None,
altitude: float | None,
) -> Location | None:
"""Update non-PK fields of a single location row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The caller must not pass PK fields they are immutable.
Only fields with a non-``None`` value are written; ``altitude`` being
``None`` in the request means "leave unchanged", not "clear to NULL".
"""
row = session.execute(
select(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
).scalar_one_or_none()
if row is None:
return None
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
if altitude is not None:
row.altitude = altitude
session.commit()
session.refresh(row)
return row
def delete_location(session: Session, person: str, datetime_pk: str) -> bool:
"""Delete the single location row identified by its full composite PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
no batch/truncate path exists.
"""
result = session.execute(
delete(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
)
session.commit()
return result.rowcount == 1
+179
View File
@@ -0,0 +1,179 @@
"""Exponential back-off throttle service for the login endpoint.
Design
------
Failures are tracked per (scope, key) in the ``auth_login_throttle`` table:
* scope ``'ip'`` key is the client IP address
* scope ``'user'`` key is the username
On each login attempt the endpoint checks **both** keys and takes the **maximum**
remaining wait (in seconds). A wait > 0 means the caller is still inside a
back-off window and should receive 429.
Exponential formula
-------------------
The first ``N_FREE`` failures are free (no delay). After that::
wait = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (failures - N_FREE))
Constants (module-level so they can be tuned without touching the algorithm):
* ``N_FREE`` = 3 failures before any delay starts
* ``BACKOFF_BASE_S`` = 1 base wait in seconds (1st delayed attempt 2 s)
* ``BACKOFF_CAP_S`` = 900 hard cap (15 min)
Example delay schedule:
failures 1, 2, 3 0 s (free)
failures 4 2 s
failures 5 4 s
failures 6 8 s
failures 7 16 s
failures 13+ 900 s (capped)
Timezone notes
--------------
The model uses ``DateTime(timezone=True)``. SQLite stores timestamps without
timezone info, so when rows are read back the column value may be timezone-naive.
We normalise every stored value to UTC-aware via ``_as_utc`` before comparing
against ``datetime.now(UTC)``.
"""
from __future__ import annotations
import math
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.auth_throttle import LoginThrottle
# ---------------------------------------------------------------------------
# Tunable constants
# ---------------------------------------------------------------------------
N_FREE: int = 3 # failures before any backoff starts
BACKOFF_BASE_S: int = 1 # seconds — wait after the first delayed failure
BACKOFF_CAP_S: int = 900 # seconds — maximum wait (15 min)
# Internal scope labels (keep in sync with model constraint)
_SCOPE_IP = "ip"
_SCOPE_USER = "user"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def check_and_get_wait(session: Session, *, ip: str, username: str) -> int:
"""Return the number of seconds the caller must still wait, or 0 if allowed.
Checks both the IP row and the username row; returns the *larger* remaining
wait so that both keys must have their back-off window expire before the
caller is permitted to try again.
"""
now = datetime.now(UTC)
wait_ip = _remaining_wait(session, scope=_SCOPE_IP, key=ip, now=now)
wait_user = _remaining_wait(session, scope=_SCOPE_USER, key=username, now=now)
return max(wait_ip, wait_user)
def register_failure(session: Session, *, ip: str, username: str) -> None:
"""Record one login failure for both the IP and the username keys.
Updates (or creates) the throttle row for each key, increments the failure
counter, and recomputes ``next_allowed_at`` using the exponential formula.
"""
now = datetime.now(UTC)
_upsert_failure(session, scope=_SCOPE_IP, key=ip, now=now)
_upsert_failure(session, scope=_SCOPE_USER, key=username, now=now)
session.commit()
def clear(session: Session, *, ip: str, username: str) -> None:
"""Delete the throttle rows for the given IP and username (successful login).
It is safe to call this even if no rows exist for the given keys.
"""
session.execute(
delete(LoginThrottle).where(
(LoginThrottle.scope == _SCOPE_IP) & (LoginThrottle.key == ip)
| (LoginThrottle.scope == _SCOPE_USER) & (LoginThrottle.key == username)
)
)
session.commit()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _remaining_wait(session: Session, *, scope: str, key: str, now: datetime) -> int:
"""Return the number of seconds that ``key`` in ``scope`` must still wait.
Returns 0 if the key has no throttle row or if the back-off window has
already expired.
"""
row = session.scalar(
select(LoginThrottle)
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
.limit(1)
)
if row is None or row.next_allowed_at is None:
return 0
next_allowed = _as_utc(row.next_allowed_at)
if next_allowed <= now:
return 0
remaining = (next_allowed - now).total_seconds()
# Round up so callers are never told "0 seconds" while still blocked.
return math.ceil(remaining)
def _compute_next_allowed_at(failures: int, now: datetime) -> datetime | None:
"""Compute the earliest time the key should be allowed to try again.
Returns ``None`` for the first ``N_FREE`` failures (no delay).
"""
if failures <= N_FREE:
return None
wait_s = min(BACKOFF_CAP_S, BACKOFF_BASE_S * (2 ** (failures - N_FREE)))
return now + timedelta(seconds=wait_s)
def _upsert_failure(session: Session, *, scope: str, key: str, now: datetime) -> None:
"""Create or update the throttle row for (scope, key) with one more failure."""
row = session.scalar(
select(LoginThrottle)
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
.limit(1)
)
if row is None:
new_failures = 1
row = LoginThrottle(
scope=scope,
key=key,
failures=new_failures,
first_failed_at=now,
last_failed_at=now,
next_allowed_at=_compute_next_allowed_at(new_failures, now),
)
session.add(row)
else:
row.failures += 1
row.last_failed_at = now
row.next_allowed_at = _compute_next_allowed_at(row.failures, now)
def _as_utc(value: datetime | None) -> datetime | None:
"""Normalise a possibly-naive datetime to UTC-aware."""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
+480
View File
@@ -0,0 +1,480 @@
"""Service layer for Meter epoch CRUD, swap/close, and time-range lookup.
All functions accept an explicit SQLAlchemy Session; callers are responsible
for committing or rolling back the transaction.
Design decisions
----------------
- ``meter_at``: half-open interval ``[started_at, ended_at)`` lookup;
returns the meter whose epoch covers *ts* for the given commodity.
SQLite naive datetime is normalised via ``_as_utc()`` before comparison.
- ``declare_meter``: validates that the new ``started_at`` is **not earlier
than** the current active meter's ``started_at`` (rejects back-dating below
the active meter's own start). Equal timestamps are allowed because the
typical "swap now" use-case sets ``started_at`` to the current moment, which
coincides with the active meter's ``started_at`` only in degenerate test
scenarios but blocking equal values would make that workflow impossible.
The old active meter is closed (``ended_at = started_at``) and a new active
meter is opened in the same operation, guaranteeing continuity: the old
meter's ``ended_at`` equals the new meter's ``started_at`` (contiguous,
no gap, no overlap).
- ``update_meter``: when ``started_at`` is modified, the service keeps the
timeline contiguous by also updating the **previous** meter's ``ended_at``
(the one whose ``ended_at`` matched the old ``started_at``) to the new
``started_at``. Validation ensures the new ``started_at``:
* is strictly after the previous meter's own ``started_at``
(cannot push the boundary before the previous meter even started);
* is strictly before the current meter's ``ended_at``, if set
(cannot push the boundary past where the current meter was already
closed).
- Mutual exclusion (at most one active meter per commodity) is enforced by the
service layer; no DB-level unique partial index is added to keep migrations
simple and to allow the application to return a meaningful error message.
SQLite timezone note
--------------------
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
read-back they come out as **timezone-naive** datetimes. Wherever this code
compares timestamps from the DB against timezone-aware values, it calls
``_as_utc()`` to make both sides comparable without tripping on
"offset-naive vs offset-aware" TypeErrors.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.energy import Meter
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _as_utc(dt: datetime) -> datetime:
"""Return *dt* as a timezone-aware UTC datetime.
SQLite's ``DateTime(timezone=True)`` column type stores datetimes as naive
UTC strings and gives them back as naive datetimes on read. This helper
re-attaches the UTC timezone info when it is missing, making cross-origin
comparisons safe.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class MeterError(ValueError):
"""Base class for meter service validation errors."""
class MeterOverlapError(MeterError):
"""Raised when a new meter's ``started_at`` would create an overlap or backdate.
Specifically: the new meter's ``started_at`` must be greater than or equal
to the current active meter's ``started_at``. Allowing a value strictly
earlier than the active meter's start would mean the new epoch begins before
the current one, which is chronologically inconsistent.
"""
class MeterIntervalError(MeterError):
"""Raised when an ``update_meter`` call would produce an inconsistent interval.
Examples of inconsistent intervals:
- New ``started_at`` this meter's ``ended_at`` (epoch would be empty/inverted).
- New ``started_at`` the previous meter's own ``started_at`` (previous meter
would become empty/inverted after its ``ended_at`` is updated).
"""
# ---------------------------------------------------------------------------
# Internal query helpers
# ---------------------------------------------------------------------------
def _active_meter(session: Session, commodity: str) -> Optional[Meter]:
"""Return the current active (``ended_at IS NULL``) meter for *commodity*, or None."""
return session.execute(
select(Meter)
.where(
Meter.commodity == commodity,
Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
def _meter_before(session: Session, meter: Meter) -> Optional[Meter]:
"""Return the meter whose ``ended_at`` equals *meter*'s ``started_at``.
This is the meter that was closed when *meter* was opened; its ``ended_at``
needs to stay equal to *meter*'s ``started_at`` to maintain timeline
continuity. Returns None if *meter* is the first epoch for its commodity.
The lookup compares naive/aware datetimes via string to avoid SQLite timezone
quirks: both are formatted as ISO 8601 UTC strings for the WHERE clause.
We rely on the fact that the service layer always stores the same timestamp
object as both ``prev.ended_at`` and ``new.started_at``, so their string
representations are identical.
"""
# Normalise the target to a tz-aware UTC datetime for comparison.
# We scan in Python (rather than SQL) to avoid SQLite naive-vs-aware
# mismatch issues when comparing DateTime columns against tz-aware values.
target = _as_utc(meter.started_at)
# Fetch all closed meters of the same commodity and find the one whose
# ended_at equals this meter's started_at (the standard contiguous handoff).
candidates = session.execute(
select(Meter)
.where(
Meter.commodity == meter.commodity,
Meter.ended_at.is_not(None),
)
).scalars().all()
for candidate in candidates:
if candidate.id == meter.id:
continue
candidate_ended = _as_utc(candidate.ended_at)
if candidate_ended == target:
return candidate
return None
# ---------------------------------------------------------------------------
# Core service functions
# ---------------------------------------------------------------------------
def meter_at(
session: Session,
ts: datetime,
commodity: str = "electricity",
) -> Optional[Meter]:
"""Return the meter epoch that covers *ts* for *commodity*.
A meter covers *ts* when:
``started_at ts`` AND (``ended_at IS NULL`` OR ``ts < ended_at``)
This is the standard half-open interval ``[started_at, ended_at)`` lookup,
consistent with ``active_contract_version_at`` in ``contracts.py``.
Returns ``None`` when no meter covers *ts* (e.g. before the first epoch
was declared, or after a gap).
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
ts:
UTC datetime to look up.
commodity:
Energy commodity (default ``"electricity"``).
Returns
-------
Meter | None
Implementation note why the upper bound is pushed into SQL
------------------------------------------------------------
When ``declare_meter`` is called with ``started_at == active.started_at``
(the "equal-timestamp swap" allowed by §3.5), the old meter is closed as a
zero-width epoch ``[T0, T0)`` and the new active meter also has
``started_at == T0``. Two rows share the same ``started_at``; SQLite's
row-ordering for ``ORDER BY started_at DESC LIMIT 1`` is then determined by
rowid (i.e. insertion order), which returns the older zero-width row first.
If the upper bound were checked in Python *after* fetching that one row, the
condition ``ts < ended_at`` would be False for **any** ``ts >= T0`` (because
``ended_at == T0``), causing the function to return ``None`` and making the
new active meter permanently invisible.
Pushing both bounds into the SQL ``WHERE`` clause eliminates the ambiguity:
the zero-width row is excluded by ``ts < ended_at`` before ``LIMIT 1`` is
applied, so only the genuinely covering row survives.
SQLite naive-vs-aware datetime note: SQLAlchemy's SQLite dialect strips the
``tzinfo`` from aware datetimes when binding parameters (it does *not*
convert to UTC first). Since all datetimes in this codebase are UTC (either
naive-UTC from the DB or aware-UTC from ``datetime.now(UTC)``), stripping
the tzinfo leaves the wall-clock value unchanged and comparisons remain
correct. This is consistent with how ``contracts.py`` handles the same
situation (see OBS 2 in the M7-T02 review notes).
"""
# Push both the lower and upper bounds into the SQL WHERE clause so that
# zero-width epochs (ended_at == started_at) are excluded *before* LIMIT 1
# is applied. This prevents an equal-timestamp swap from making the new
# active meter invisible (see implementation note above).
stmt = (
select(Meter)
.where(
Meter.commodity == commodity,
Meter.started_at <= ts,
(Meter.ended_at.is_(None)) | (Meter.ended_at > ts),
)
.order_by(Meter.started_at.desc())
.limit(1)
)
return session.execute(stmt).scalar_one_or_none()
def declare_meter(
session: Session,
*,
label: str,
started_at: datetime,
reason: str,
commodity: str = "electricity",
note: Optional[str] = None,
) -> Meter:
"""Declare a meter swap or initial meter epoch.
Closes the current active meter for *commodity* (if one exists) by setting
its ``ended_at`` to *started_at*, then opens a new active meter. The
resulting timeline is **contiguous**: old meter's ``ended_at`` equals new
meter's ``started_at``.
If there is no current active meter (first-ever declaration for this
commodity), the new meter is simply opened without closing anything.
Validation
----------
- If a current active meter exists, *started_at* must be **** that meter's
own ``started_at``. A value strictly earlier would place the new epoch
entirely before the current active meter, which is a chronological
contradiction ("back-dating before the active epoch's start"). Raises
``MeterOverlapError`` when this constraint is violated.
Note: equal timestamps (``started_at == active.started_at``) are allowed
because that scenario effectively replaces the current meter at the same
logical moment (e.g. correcting a mis-entry), which is a valid use-case.
The old meter is then closed with ``ended_at == started_at`` (a zero-width
epoch), which is intentional and auditable.
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
label:
Human-readable label for the new meter.
started_at:
UTC datetime at which this meter epoch starts. May be in the past.
reason:
Why this epoch was created (``"initial"``, ``"meter_swap"``,
``"home_move"``, or ``"other"``).
commodity:
Energy commodity (default ``"electricity"``).
note:
Optional free-form note.
Returns
-------
Meter
The newly created, not-yet-committed active meter.
Raises
------
MeterOverlapError
If *started_at* is strictly earlier than the current active meter's
``started_at`` (chronological backdate below the active epoch's start).
"""
active = _active_meter(session, commodity)
if active is not None:
# Reject back-dating: new started_at must be ≥ current active's started_at.
if _as_utc(started_at) < _as_utc(active.started_at):
raise MeterOverlapError(
f"New meter started_at ({started_at.isoformat()}) must be ≥ the current "
f"active {commodity!r} meter's started_at "
f"({active.started_at.isoformat()}). "
"Declare a started_at on or after the active meter's start to avoid "
"a chronologically inconsistent epoch ordering."
)
# Close the current active meter at the swap point (contiguous handoff).
active.ended_at = started_at
logger.info(
"Closed active %r meter id=%d (ended_at=%s)",
commodity,
active.id,
started_at.isoformat(),
)
now = datetime.now(UTC)
new_meter = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=None,
reason=reason,
note=note,
created_at=now,
)
session.add(new_meter)
logger.info(
"Declared new %r meter %r (started_at=%s, reason=%s)",
commodity,
label,
started_at.isoformat(),
reason,
)
return new_meter
def list_meters(
session: Session,
commodity: Optional[str] = None,
) -> list[Meter]:
"""List all meter epochs, ordered by started_at ascending.
Active meters (``ended_at IS NULL``) sort naturally to the end of the
timeline since they have the latest ``started_at``. Within a single
commodity, the ascending ``started_at`` order reflects the historical
sequence of installed meters.
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
commodity:
If provided, filter to this commodity only. If ``None``, return all
meters across all commodities.
Returns
-------
list[Meter]
Meters ordered by (started_at ASC).
"""
stmt = select(Meter).order_by(Meter.started_at.asc())
if commodity is not None:
stmt = stmt.where(Meter.commodity == commodity)
return list(session.execute(stmt).scalars().all())
def update_meter(
session: Session,
meter: Meter,
*,
label: Optional[str] = None,
note: Optional[str] = None,
started_at: Optional[datetime] = None,
) -> Meter:
"""Update a meter's mutable fields (label, note, started_at).
Passing ``None`` for a field leaves it unchanged. At least one keyword
argument must be non-``None``; calling with all-``None`` is a no-op but
is not an error.
Updating ``started_at`` (retroactive correction)
------------------------------------------------
When ``started_at`` is provided the service maintains **timeline
continuity** across the adjacent meter boundaries:
1. **Previous meter's ``ended_at``** — if the meter immediately before
this one has ``ended_at == meter.started_at`` (the standard contiguous
handoff), its ``ended_at`` is updated to the new ``started_at`` so the
boundary between the two epochs stays seamless.
2. **Validation** the new ``started_at`` is checked for consistency:
a. It must be **strictly after** the previous meter's own ``started_at``
(otherwise the previous meter's epoch would collapse to zero or
invert).
b. It must be **strictly before** this meter's ``ended_at`` (if set),
so this meter's epoch remains non-empty.
Note: triggering a billing recompute (``recompute_range``) after a
retroactive ``started_at`` change is **out of scope** for this service
layer; that is the API layer's responsibility (M7-T05).
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
meter:
The ``Meter`` ORM instance to update (already loaded from the session).
label:
New human-readable label; ``None`` = keep existing.
note:
New free-form note; ``None`` = keep existing.
started_at:
New start timestamp for this meter epoch; ``None`` = keep existing.
Returns
-------
Meter
The updated ``Meter`` instance (not yet committed).
Raises
------
MeterIntervalError
If the new ``started_at`` would produce an invalid (empty or inverted)
epoch for this meter or the immediately preceding one.
"""
if label is not None:
meter.label = label
logger.info("Updated meter id=%d label=%r", meter.id, label)
if note is not None:
meter.note = note
logger.info("Updated meter id=%d note=%r", meter.id, note)
if started_at is not None:
old_started_at = meter.started_at
# --- Validate upper bound: new started_at must be < this meter's ended_at (if set).
if meter.ended_at is not None:
if _as_utc(started_at) >= _as_utc(meter.ended_at):
raise MeterIntervalError(
f"New started_at ({started_at.isoformat()}) must be strictly before "
f"this meter's ended_at ({meter.ended_at.isoformat()}). "
"The meter epoch would become empty or inverted."
)
# --- Find the immediately preceding meter (its ended_at == meter's old started_at).
prev = _meter_before(session, meter)
# --- Validate lower bound: new started_at must be strictly after prev's started_at.
if prev is not None:
if _as_utc(started_at) <= _as_utc(prev.started_at):
raise MeterIntervalError(
f"New started_at ({started_at.isoformat()}) must be strictly after "
f"the previous meter's started_at ({prev.started_at.isoformat()}). "
"Moving the boundary that far back would collapse the previous meter's epoch."
)
# Maintain continuity: update the previous meter's ended_at to match the new start.
prev.ended_at = started_at
logger.info(
"Updated previous meter id=%d ended_at=%s (boundary shift from %s)",
prev.id,
started_at.isoformat(),
old_started_at.isoformat(),
)
meter.started_at = started_at
logger.info(
"Updated meter id=%d started_at=%s (was %s)",
meter.id,
started_at.isoformat(),
old_started_at.isoformat(),
)
return meter
+210
View File
@@ -0,0 +1,210 @@
"""Modbus polling service — periodic read-and-store for enabled Modbus devices.
Design notes
------------
- ``poll_device`` loads the device's YAML profile, calls the driver to bulk-read
all register blocks in a single TCP connection, decodes the raw registers into
an engineering-value dict via the profile, and persists one ``ModbusReading``
row. It also stamps ``last_poll_at`` / ``last_poll_ok`` on the device.
- All exceptions are caught and logged inside ``poll_device``; the function
never re-raises. This prevents a single broken device from aborting the
sweep for all other devices.
- ``poll_all_enabled_devices`` respects per-device ``poll_interval_s``: it skips
a device whose ``last_poll_at`` is recent enough. This lets the APScheduler
job run on a short base tick (e.g. 5 s) while each device self-regulates its
own cadence.
- The global ``modbus_polling_enabled`` flag from ``Settings`` (merged with any
DB overrides via ``build_runtime_settings``) gates the entire sweep as a
no-op. T08 will expose this flag in CONFIG_FIELDS/UI; for now it is
controlled by the ``MODBUS_POLLING_ENABLED`` env var (or the default True).
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.integrations.modbus import driver, profiles
from app.models.modbus import ModbusDevice, ModbusReading
from app.services.config_page import build_runtime_settings
from app.services.ha_discovery import publish_device_offline, publish_device_state
logger = logging.getLogger(__name__)
# Base polling tick in seconds (used by APScheduler; each device checks its own interval).
BASE_POLL_TICK_SECONDS = 5
def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
"""Read one enabled Modbus device, decode, and persist a reading row.
Parameters
----------
session:
Open SQLAlchemy session. The caller is responsible for session
lifecycle (open / commit / close).
device:
``ModbusDevice`` ORM instance to poll.
Returns
-------
ModbusReading | None
The newly-persisted reading on success, or ``None`` on any failure.
Failure is logged and the device's ``last_poll_ok`` is set to ``False``.
"""
now = datetime.now(UTC)
try:
profile = profiles.load_profile(device.profile)
registers = driver.read_blocks(
device.host,
device.port,
device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
)
payload = profiles.decode(profile, registers)
reading = ModbusReading(
device_id=device.id,
recorded_at=now,
payload=payload,
)
session.add(reading)
device.last_poll_at = now
device.last_poll_ok = True
session.commit()
logger.debug(
"Polled device %r (id=%d): %d metrics recorded at %s",
device.friendly_name,
device.id,
len(payload),
now.isoformat(),
)
# Best-effort: push MQTT state after successful poll.
# Must never let MQTT errors crash the poll loop.
try:
publish_device_state(session, device)
except Exception: # noqa: BLE001
logger.exception(
"poll_device: MQTT state publish failed for device %r (id=%d); "
"continuing (poll itself succeeded)",
device.friendly_name,
device.id,
)
return reading
except Exception: # noqa: BLE001
logger.exception(
"Failed to poll device %r (id=%d, host=%s:%d, unit_id=%d)",
device.friendly_name,
device.id,
device.host,
device.port,
device.unit_id,
)
# Rollback first — if session.add(reading) already ran in the success
# branch before commit() failed, that pending ModbusReading must be
# discarded. Without rollback it would remain staged and could be
# flushed/committed by a subsequent operation (even for a different
# device), producing a spurious reading row for a poll that failed.
# Rollback also expires all ORM objects; device fields are re-set below.
try:
session.rollback()
except Exception: # noqa: BLE001
logger.exception(
"session.rollback() failed for device id=%d; session may be unusable", device.id
)
try:
device.last_poll_at = now
device.last_poll_ok = False
session.commit()
except Exception: # noqa: BLE001
logger.exception(
"Also failed to persist last_poll_ok=False for device id=%d", device.id
)
# Best-effort: publish "offline" availability after failed poll.
try:
publish_device_offline(session, device.uuid)
except Exception: # noqa: BLE001
logger.exception(
"poll_device: MQTT offline publish failed for device %r (id=%d)",
device.friendly_name,
device.id,
)
return None
def poll_all_enabled_devices(
session: Session,
*,
bootstrap_settings: Settings,
) -> None:
"""Sweep all enabled Modbus devices and poll each one whose interval has elapsed.
Global kill-switch: if ``build_runtime_settings(...).modbus_polling_enabled``
is ``False``, this function returns immediately without touching any device.
Per-device interval: a device is skipped if its ``last_poll_at`` is not yet
``poll_interval_s`` seconds in the past. This allows the APScheduler job to
run on a short fixed tick while each device self-regulates its own cadence.
Exceptions from individual devices are swallowed inside ``poll_device``;
this function itself will not raise.
Parameters
----------
session:
Open SQLAlchemy session.
bootstrap_settings:
The bootstrap ``Settings`` instance (from ``get_settings()``), used as
the base for ``build_runtime_settings`` to pick up any DB overrides.
"""
try:
runtime_settings = build_runtime_settings(session, bootstrap_settings)
except Exception: # noqa: BLE001
logger.exception("Failed to build runtime settings for Modbus poll; aborting sweep")
return
if not runtime_settings.modbus_polling_enabled:
logger.debug("Modbus polling is disabled (modbus_polling_enabled=False); skipping sweep")
return
try:
devices = session.execute(
select(ModbusDevice).where(ModbusDevice.enabled == True) # noqa: E712
).scalars().all()
except Exception: # noqa: BLE001
logger.exception("Failed to query enabled Modbus devices; aborting sweep")
return
now = datetime.now(UTC)
for device in devices:
# Respect per-device poll interval: skip if last poll was too recent.
if device.last_poll_at is not None:
# SQLite may return a naive datetime despite DateTime(timezone=True) —
# treat naive values as UTC so the comparison is safe.
last_poll = device.last_poll_at
if last_poll.tzinfo is None:
last_poll = last_poll.replace(tzinfo=UTC)
elapsed = (now - last_poll).total_seconds()
if elapsed < device.poll_interval_s:
logger.debug(
"Skipping device %r (id=%d): %.1fs elapsed < %ds interval",
device.friendly_name,
device.id,
elapsed,
device.poll_interval_s,
)
continue
poll_device(session, device)
+48 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
import logging
from sqlalchemy import desc, insert, select
from sqlalchemy import delete, desc, insert, select
from sqlalchemy.orm import Session
from app.config import Settings
@@ -74,6 +74,53 @@ def record_poo(
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
def update_poo_record(
session: Session,
timestamp_pk: str,
*,
status: str | None,
latitude: float | None,
longitude: float | None,
) -> PooRecord | None:
"""Update non-PK fields of a single poo record row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The ``timestamp`` PK is immutable and must not be passed as an update field.
Only fields with a non-``None`` value are written.
"""
row = session.execute(
select(PooRecord).where(PooRecord.timestamp == timestamp_pk)
).scalar_one_or_none()
if row is None:
return None
if status is not None:
row.status = status
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
session.commit()
session.refresh(row)
return row
def delete_poo_record(session: Session, timestamp_pk: str) -> bool:
"""Delete the single poo record row identified by its PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
no batch/truncate path exists.
"""
result = session.execute(
delete(PooRecord).where(PooRecord.timestamp == timestamp_pk)
)
session.commit()
return result.rowcount == 1
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
record = session.execute(stmt).scalar_one_or_none()
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import ipaddress
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Callable, Literal
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.models.public_ip import PublicIPHistory, PublicIPState
from app.services.config_page import build_runtime_settings
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_public_ip_changed_email
logger = logging.getLogger(__name__)
PUBLIC_IP_PROVIDER_NAME = "ipify"
PUBLIC_IP_PROVIDER_URL = "https://api.ipify.org"
PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS = 5.0
PublicIPResultStatus = Literal["first_seen", "unchanged", "changed", "error"]
PublicIPv4Fetcher = Callable[[], str]
class PublicIPCheckError(RuntimeError):
"""Raised when the public IPv4 provider cannot return a valid IPv4."""
@dataclass(slots=True)
class PublicIPCheckResult:
status: PublicIPResultStatus
checked_at: datetime
changed: bool
previous_ipv4: str | None = None
current_ipv4: str | None = None
def check_public_ipv4(
session: Session,
*,
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
) -> PublicIPCheckResult:
checked_at = _utc_now()
state = session.scalar(select(PublicIPState).where(PublicIPState.id == 1).limit(1))
try:
raw_ipv4 = (fetch_public_ipv4 or fetch_public_ipv4_from_provider)()
current_ipv4 = _validate_ipv4(raw_ipv4)
except PublicIPCheckError as exc:
logger.warning("Public IPv4 check failed: %s", exc)
if state is not None:
state.last_checked_at = checked_at
state.last_check_status = "error"
state.last_check_error = str(exc)
state.last_provider = provider_name
session.commit()
return PublicIPCheckResult(status="error", checked_at=checked_at, changed=False)
if state is None:
state = PublicIPState(
id=1,
current_ipv4=current_ipv4,
previous_ipv4=None,
first_seen_at=checked_at,
last_checked_at=checked_at,
last_changed_at=None,
last_check_status="first_seen",
last_check_error=None,
last_provider=provider_name,
)
session.add(state)
session.add(
PublicIPHistory(
ipv4=current_ipv4,
observed_at=checked_at,
change_type="first_seen",
provider=provider_name,
)
)
session.commit()
return PublicIPCheckResult(
status="first_seen",
checked_at=checked_at,
changed=False,
current_ipv4=current_ipv4,
)
if state.current_ipv4 == current_ipv4:
state.last_checked_at = checked_at
state.last_check_status = "unchanged"
state.last_check_error = None
state.last_provider = provider_name
session.commit()
return PublicIPCheckResult(
status="unchanged",
checked_at=checked_at,
changed=False,
current_ipv4=current_ipv4,
)
previous_ipv4 = state.current_ipv4
state.previous_ipv4 = previous_ipv4
state.current_ipv4 = current_ipv4
state.last_checked_at = checked_at
state.last_changed_at = checked_at
state.last_check_status = "changed"
state.last_check_error = None
state.last_provider = provider_name
session.add(
PublicIPHistory(
ipv4=current_ipv4,
observed_at=checked_at,
change_type="changed",
provider=provider_name,
)
)
session.commit()
return PublicIPCheckResult(
status="changed",
checked_at=checked_at,
changed=True,
previous_ipv4=previous_ipv4,
current_ipv4=current_ipv4,
)
def check_public_ipv4_and_notify(
session: Session,
*,
bootstrap_settings: Settings,
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
) -> PublicIPCheckResult:
result = check_public_ipv4(
session,
fetch_public_ipv4=fetch_public_ipv4,
provider_name=provider_name,
)
if result.status != "changed" or result.previous_ipv4 is None or result.current_ipv4 is None:
return result
runtime_settings = build_runtime_settings(session, bootstrap_settings)
try:
send_public_ip_changed_email(
runtime_settings,
previous_ipv4=result.previous_ipv4,
current_ipv4=result.current_ipv4,
detected_at=result.checked_at,
)
except (EmailConfigurationError, EmailDeliveryError) as exc:
logger.warning("Public IPv4 change notification failed: %s", exc)
return result
def fetch_public_ipv4_from_provider() -> str:
try:
response = httpx.get(
PUBLIC_IP_PROVIDER_URL,
params={"format": "text"},
timeout=PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS,
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise PublicIPCheckError(f"provider request failed: {exc}") from exc
return response.text.strip()
def _validate_ipv4(raw_value: str) -> str:
if not raw_value:
raise PublicIPCheckError("provider returned an empty response")
try:
parsed = ipaddress.ip_address(raw_value)
except ValueError as exc:
raise PublicIPCheckError("provider returned an invalid IPv4 value") from exc
if parsed.version != 4:
raise PublicIPCheckError("provider returned a non-IPv4 value")
return str(parsed)
def _utc_now() -> datetime:
return datetime.now(UTC)
+139
View File
@@ -0,0 +1,139 @@
"""Service layer for fetching and persisting Tibber 15-minute electricity prices.
``refresh_prices`` is the main entry point. It is designed to be called from a
scheduled background job (see ``app/main.py``) and from tests.
Design decisions
----------------
- **Guard clause**: if no active contract with ``kind="tibber"`` exists, or if
the Tibber API token is empty in the runtime settings, the function is a
complete no-op (returns 0) and does not raise. This means the scheduler can
call ``refresh_prices`` unconditionally; the service itself decides whether to
do anything based on current configuration.
- **Upsert idempotency**: rows are matched by ``starts_at`` (the unique
constraint on ``tibber_price``). If a row already exists, its price fields
and ``fetched_at`` are updated in-place; if it does not exist, a new row is
inserted. Running ``refresh_prices`` twice in a row must not double-insert.
- **No destructive operations**: this service never deletes ``tibber_price``
rows. Only INSERT or UPDATE.
- **Exception propagation**: exceptions from the Tibber client are *not*
swallowed here. The scheduled job wrapper in ``main.py`` is responsible for
catching and logging errors so that a single fetch failure does not crash the
scheduler.
- **SQLite timezone note**: ``DateTime(timezone=True)`` columns come back as
timezone-naive UTC datetimes on read. We store UTC-aware datetimes on write
(``starts_at`` from ``PricePoint`` is already UTC-aware; ``fetched_at`` is
``datetime.now(UTC)``). Reads elsewhere that compare against these values
must normalise accordingly.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.integrations.tibber.client import fetch_price_range
from app.models.energy import EnergyContract, TibberPrice
logger = logging.getLogger(__name__)
def _active_tibber_contract_exists(session: Session) -> bool:
"""Return True if there is an active contract with kind='tibber'."""
row = session.execute(
select(EnergyContract).where(
EnergyContract.active.is_(True),
EnergyContract.kind == "tibber",
).limit(1)
).scalar_one_or_none()
return row is not None
def refresh_prices(session: Session, settings: object) -> int:
"""Fetch today-and-tomorrow Tibber prices and upsert them into ``tibber_price``.
Parameters
----------
session:
An active SQLAlchemy session. The caller is responsible for closing it;
this function commits each upserted row individually to keep transactions
short.
settings:
A runtime settings object that exposes ``tibber_api_token`` and
``tibber_home_id`` attributes (typically a ``Settings`` or merged
runtime-settings instance from ``build_runtime_settings``).
Returns
-------
int
Number of rows upserted (inserted or updated). Returns 0 for a no-op.
Notes
-----
- No-op (returns 0) when:
* No active contract exists with ``kind="tibber"``, OR
* ``settings.tibber_api_token`` is empty or whitespace.
- Exceptions from the Tibber client are *not* caught here; they propagate to
the caller (the scheduled job wrapper swallows them and logs).
"""
token: str = getattr(settings, "tibber_api_token", "") or ""
if not token.strip():
logger.debug("refresh_prices: tibber_api_token is empty — no-op")
return 0
if not _active_tibber_contract_exists(session):
logger.debug("refresh_prices: no active tibber contract — no-op")
return 0
home_id: str = getattr(settings, "tibber_home_id", "") or ""
home_id_or_none: str | None = home_id.strip() or None
logger.info("refresh_prices: fetching Tibber price range (home_id=%r)", home_id_or_none)
# May raise TibberError or TibberAuthError — let them propagate.
price_points = fetch_price_range(token, home_id_or_none)
if not price_points:
logger.info("refresh_prices: API returned zero price points — nothing to upsert")
return 0
fetched_at = datetime.now(UTC)
upserted = 0
for point in price_points:
existing = session.execute(
select(TibberPrice).where(TibberPrice.starts_at == point.starts_at)
).scalar_one_or_none()
if existing is not None:
# Update in-place — price fields may change (e.g. Tibber corrects a
# forecast), but starts_at and resolution stay the same.
existing.total = point.total
existing.energy = point.energy
existing.tax = point.tax
existing.level = point.level
existing.currency = point.currency
existing.fetched_at = fetched_at
else:
session.add(
TibberPrice(
starts_at=point.starts_at,
resolution=point.resolution,
total=point.total,
energy=point.energy,
tax=point.tax,
level=point.level,
currency=point.currency,
fetched_at=fetched_at,
)
)
upserted += 1
session.commit()
logger.info("refresh_prices: upserted %d price points", upserted)
return upserted
+100
View File
@@ -0,0 +1,100 @@
"""Server local-timezone helpers.
All time is stored as UTC (no change). This module provides a single,
monkeypatch-friendly entry point for converting UTC moments to the server's
local timezone used for business-day boundary calculations (standing charges,
effective-from date interpretation, etc.).
Design goals
------------
- Zero third-party dependencies (no tzlocal, no pytz).
- Testable without depending on CI host timezone:
monkeypatch ``local_tz`` to a fixed ``ZoneInfo`` in unit tests.
- DST-correct: conversions use ``astimezone(local_tz())`` per instant, so each
moment is independently correct even during DST transitions.
- Consistent with the existing ``.astimezone()`` pattern already used in
``homeassistant_inbound.py`` and ``poo.py``.
Priority for resolving the local timezone
-----------------------------------------
1. ``TZ`` environment variable ``ZoneInfo(os.environ["TZ"])``.
Set ``TZ=Europe/Amsterdam`` in the deployment env for correct NL handling.
2. System local timezone fallback: ``datetime.now().astimezone().tzinfo``.
This matches the behaviour callers already relied on implicitly.
"""
from __future__ import annotations
import os
from datetime import date, datetime, timezone
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from datetime import tzinfo
def local_tz() -> "tzinfo":
"""Return the server's local timezone.
Resolution order:
1. ``TZ`` environment variable (``ZoneInfo(TZ)``). Set
``TZ=Europe/Amsterdam`` in production for correct NL/DST handling.
2. System local timezone via ``datetime.now().astimezone().tzinfo``.
**Monkeypatch this function in tests** to get deterministic timezone
behaviour regardless of CI host configuration::
monkeypatch.setattr("app.services.timezone.local_tz",
lambda: ZoneInfo("Europe/Amsterdam"))
"""
tz_env = os.environ.get("TZ", "").strip()
if tz_env:
return ZoneInfo(tz_env)
# System fallback — identical to the .astimezone() pattern already used
# in homeassistant_inbound.py and poo.py.
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
def to_local(dt: datetime) -> datetime:
"""Convert *dt* to the server local timezone.
Handles three input cases:
- timezone-aware (any zone): converted via ``astimezone``.
- timezone-naive: assumed UTC (SQLite read-back convention), then converted.
Returns a timezone-aware datetime in ``local_tz()``.
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(local_tz())
def local_now() -> datetime:
"""Return the current moment as a timezone-aware local datetime."""
return datetime.now(local_tz())
def local_date(dt: datetime) -> date:
"""Return the local calendar date for *dt*.
Equivalent to ``to_local(dt).date()``. A distinct helper to make
call sites readable.
"""
return to_local(dt).date()
def local_midnight_utc(d: date) -> datetime:
"""Return the UTC instant that corresponds to local midnight on *d*.
Example (Europe/Amsterdam, CEST=UTC+2):
local_midnight_utc(date(2026, 6, 25))
datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
This is used to convert a local calendar date back to a UTC boundary, e.g.
to compute the start/end of a local day for DB queries.
"""
tz = local_tz()
# Build a naive local datetime at midnight, then localize and convert to UTC.
local_midnight = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
return local_midnight.astimezone(timezone.utc)
+234
View File
@@ -0,0 +1,234 @@
"""TOTP service: secret management, recovery-code lifecycle, setup/enable/disable (M4-T05).
State-machine summary
---------------------
DISABLED (default)
totp_secret=None, totp_enabled=False, no recovery codes
PENDING (after setup, before enable)
totp_secret=<base32>, totp_enabled=False, recovery codes stored as hashes
- Re-calling setup replaces the pending secret and regenerates recovery codes.
ENABLED (after enable)
totp_secret=<base32>, totp_enabled=True, recovery codes stored as hashes
After disable:
totp_secret=None, totp_enabled=False, recovery codes deleted back to DISABLED
Recovery-code timing
--------------------
1. setup generate 10 plaintext codes, persist their Argon2 hashes immediately,
return plaintext to caller (ONLY time).
2. enable verify 6-digit TOTP code; if ok, flip totp_enabled=True.
Recovery codes are already in the DB from step 1.
3. disable clear secret, clear enabled flag, delete all recovery codes.
Security note: ``totp_secret`` is stored as plaintext (same posture as other
secrets in this project: protected by file-system permissions on the SQLite
database). Recovery codes are stored as Argon2 hashes only.
"""
from __future__ import annotations
import logging
import secrets
import string
import pyotp
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.auth import AuthUser, RecoveryCode
from app.services.auth import hash_password, verify_password
logger = logging.getLogger(__name__)
# Number of recovery codes to generate per setup
_RECOVERY_CODE_COUNT = 10
# Alphabet for each 4-character segment: lowercase letters + digits, no ambiguous chars
_CODE_ALPHABET = string.ascii_lowercase + string.digits
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _generate_recovery_code() -> str:
"""Return a single recovery code in ``xxxx-xxxx`` format."""
segment = lambda: "".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) # noqa: E731
return f"{segment()}-{segment()}"
def _verify_totp_code(secret: str, code: str) -> bool:
"""Verify a 6-digit TOTP code against the given base-32 secret (±1 window)."""
return pyotp.TOTP(secret).verify(code, valid_window=1)
def _delete_recovery_codes(db: Session, *, user_id: int) -> None:
"""Delete ALL recovery codes for a user (called on setup re-run and disable)."""
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user_id))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def setup(
db: Session,
*,
user: AuthUser,
issuer: str,
) -> tuple[str, str, list[str]]:
"""Generate a new pending TOTP secret and recovery codes.
The secret is stored in ``user.totp_secret`` (``totp_enabled`` stays
``False``). Any pre-existing pending secret and recovery codes are
replaced atomically.
Returns
-------
(secret, otpauth_uri, plaintext_recovery_codes)
``plaintext_recovery_codes`` are returned **once** here and MUST NOT be
stored or returned elsewhere.
"""
# Generate new TOTP secret
new_secret = pyotp.random_base32()
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
name=user.username,
issuer_name=issuer,
)
# Generate plaintext recovery codes
plaintext_codes = [_generate_recovery_code() for _ in range(_RECOVERY_CODE_COUNT)]
# --- Persist atomically ---
# 1. Delete any old pending recovery codes (idempotent on re-setup)
assert user.id is not None # mypy guard; always set after DB insertion
_delete_recovery_codes(db, user_id=user.id)
# 2. Update the secret (pending — totp_enabled stays False)
user.totp_secret = new_secret
# 3. Persist new recovery codes as Argon2 hashes
for plaintext in plaintext_codes:
db.add(RecoveryCode(user_id=user.id, code_hash=hash_password(plaintext)))
db.commit()
db.refresh(user)
logger.info("TOTP setup (pending) for user '%s'; %d recovery codes generated.", user.username, _RECOVERY_CODE_COUNT)
return new_secret, otpauth_uri, plaintext_codes
def enable(db: Session, *, user: AuthUser, code: str) -> bool:
"""Enable TOTP after the user proves they can generate the correct code.
Parameters
----------
code:
The current 6-digit TOTP code from the user's authenticator app.
Returns
-------
True on success; False if the code is invalid or no secret is pending.
"""
if not user.totp_secret:
logger.info("TOTP enable rejected for '%s': no pending secret.", user.username)
return False
if not _verify_totp_code(user.totp_secret, code):
logger.info("TOTP enable rejected for '%s': wrong code.", user.username)
return False
user.totp_enabled = True
db.commit()
db.refresh(user)
logger.info("TOTP enabled for user '%s'.", user.username)
return True
def disable(
db: Session,
*,
user: AuthUser,
password: str | None = None,
code: str | None = None,
) -> bool:
"""Disable TOTP. Caller must supply exactly one of ``password`` or ``code``.
On success: ``totp_enabled=False``, ``totp_secret=None``, all recovery
codes deleted.
Returns
-------
True on success; False if neither credential is valid.
"""
if not password and not code:
logger.info("TOTP disable rejected for '%s': no credential provided.", user.username)
return False
if password:
if not verify_password(password, user.password_hash):
logger.info("TOTP disable rejected for '%s': wrong password.", user.username)
return False
elif code:
if not user.totp_secret or not _verify_totp_code(user.totp_secret, code):
logger.info("TOTP disable rejected for '%s': wrong TOTP code.", user.username)
return False
# Clear TOTP state
assert user.id is not None
_delete_recovery_codes(db, user_id=user.id)
user.totp_enabled = False
user.totp_secret = None
db.commit()
db.refresh(user)
logger.info("TOTP disabled for user '%s'.", user.username)
return True
def verify_totp_code(user: AuthUser, code: str) -> bool:
"""Verify a 6-digit TOTP code for a user.
Returns True if the code is valid (±1 time window), False otherwise.
The user must have a ``totp_secret`` set; returns False if not.
This is the public entry-point for T06 login two-factor verification.
"""
if not user.totp_secret:
return False
return _verify_totp_code(user.totp_secret, code)
def verify_recovery_code(db: Session, *, user: AuthUser, code: str) -> bool:
"""Verify and consume a one-time recovery code.
Finds the first unused recovery code whose hash matches ``code``, marks it
as consumed (sets ``used_at``), and returns ``True``. Returns ``False`` if
no matching unused code exists.
This function is provided for T06 (login two-factor) but lives here so the
TOTP service owns all recovery-code logic.
"""
from datetime import UTC, datetime
unused = db.execute(
select(RecoveryCode).where(
RecoveryCode.user_id == user.id,
RecoveryCode.used_at.is_(None),
)
).scalars().all()
for rc in unused:
if verify_password(code, rc.code_hash):
rc.used_at = datetime.now(UTC)
db.commit()
logger.info(
"Recovery code consumed for user '%s' (id=%d).", user.username, rc.id
)
return True
return False
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main class="shell">
{% block content %}{% endblock %}
</main>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More