SigenStor Control — Design (Python 3)
Single-user home-energy controller (Mick). k8s-hosted. Monitors + controls one Sigenergy SigenStor (battery + PV) using local Modbus TCP primary, Amber Electric for price/forecast, and a solar-forecast provider (Solcast preferred, Open-Meteo fallback).
Architecture, Modbus register map, Amber + solar integrations, 60s controller logic, safety guardrails, k8s deploy, observability, testing, and phasing are APPROVED from the Go draft and unchanged below. ONLY the stack section is rewritten in Python 3.
Stack (Python 3 — replaces Go)
- Runtime: Python 3.13 (latest stable at build time; pin
python:3.13-slimbase image by digest). Verify newest patched 3.12/3.13 at build. - Web framework: FastAPI (async) + uvicorn (uvloop loop). Pin
fastapi==<latest>,uvicorn[standard]==<latest>. Templates via Jinja2 (server-rendered HTML). - Modbus:
pymodbus(latest, async client). Pin exact version. Hold the register map insigenstor/modbus/registers.py(port of HA sigenergy integration constants; do not invent addresses). - HTTP clients:
httpx(async, latest pinned). Amber + Solcast + Open-Meteo clients. - DB: SQLite via
sqlmodel(SQLAlchemy 2.x core) +aiosqlitedriver. WAL mode. One file at/var/lib/sigenstor/sigenstor.dbon PVC. Pinsqlmodel==<latest>,sqlalchemy==<latest>,aiosqlite==<latest>. - Scheduler:
APSchedulerAsyncIOSchedulerfor fixed-tick jobs (Modbus 5s, Amber 60s, Solcast 30min/2h, controller 60s). Pinapscheduler==<latest>. Alternative: pureasyncio.create_taskloops — APScheduler chosen for crash-recovery + job-coalescing. - Validation:
pydanticv2 latest (pydantic==<latest>,pydantic-settings==<latest>for env). - Auth:
fastapi-users(HTTP bearer cookie session, single user) OR minimal Basic-Auth +itsdangeroussigned cookies — pick minimal Basic + signed cookie (matches single-user scope). Pinitsdangerous==<latest>,bcrypt==<latest>(orargon2-cffi==<latest>). - Metrics:
prometheus-client(latest pinned)./metricsendpoint. - Logging:
structlogJSON to stdout (latest pinned). - Frontend (unchanged): htmx + Alpine.js + Chart.js — vendored under
/static, no CDN. - Packaging: single uvicorn process, multi-stage Dockerfile, non-root user, distroless-free but
python:3.13-slimbase (slim is the patched, minimal Debian; pin by digest in FROM). Rebuild on base-image CVE / new patch. - Dependency mgmt:
uv(orpip-tools) lockfile —uv.lockcommitted. All runtime deps pinned exact inpyproject.toml+ lockfile. Dev deps separate. - Type/lint/format:
mypy --strict,ruff,pytest+pytest-asyncio+httpxtest client. Pin all. - Secrets:
pydantic-settingsreads env from k8s Secretsigenstor-secretsmounted as env vars. NEVER hardcoded. - NOTE: “verify newest patched at build time” — at every container build, run
pip-audit(oruv pip install --upgradereview) and checkpython:3.13-slimdigest for CVEs.
Architecture (one process, three internal layers — unchanged)
poller— APScheduler jobs: SigenStor every 5s, Amber every 60s, Solcast every 30min (04:00–22:00) / 2h overnight, price-forecast refresh every 5min. All write to DB and in-memorystatedataclass.controller— runs every 60s. Reads state + DB (schedules, mode, override, prices, forecast, SoC). Decides target charge/discharge kW and EMS mode. Idempotent.api— FastAPI app: JSON/api/v1/..., HTML/(Jinja2 templates),/metrics,/healthz//readyz.
Data model (SQLite, single file — unchanged)
telemetry(ts INT PK, soc_pct REAL, bat_kw REAL, pv_kw REAL, load_kw REAL, grid_kw REAL, mode TEXT, online INT) — 30d retention.prices(ts INT PK, buy_c_per_kwh REAL, feed_in_c_per_kwh REAL, source TEXT, fetched_at INT) — 30-min slots.forecast_solar(ts INT PK, kwh REAL, source TEXT).forecast_price(ts INT PK, predicted_c_per_kwh REAL, interval_type TEXT).schedules(id, name, days_mask, start_min, end_min, action TEXT, kw REAL, priority INT, enabled INT).mode_state(key TEXT PK, value TEXT) — current mode, manual override expiry, last decision reason, controller enabled flag.events(ts, kind, payload TEXT) — 90d retention.kv(key TEXT PK, value TEXT) — modbus host/port/unit-id/poll interval; modbus write-password AES-GCM-encrypted with key from secret.
Sigenergy Modbus integration (unchanged)
- Reference: HA
sigenergyintegration register map, ported verbatim. - Transport: Modbus TCP, port 502 (or 5020 — confirm at commissioning; default 502). Unit ID 1 default.
- Reads: device master EMS status, SoC, battery power, PV power, load, grid power, energy totals, fault bits.
- Writes: EMS mode (self-consume / forced charge / forced discharge / remote schedule), forced charge/discharge power limit, remote schedule enable/disable.
- Auth: installer/admin password for writes (HA integration prompts); passed per-session to pymodbus client.
- Failure: exponential backoff 1/2/4/8s, max 3, then event + revert safe mode (self-consume). Never leave battery in forced-charge indefinitely.
- Polling: 5s reads in single task; writes serialized through
asyncio.Lock(orasyncio.Queuewriter task) — no interleaved register updates. - Discovery: hardcode IP via k8s ConfigMap
SIGENSTOR_MODBUS_HOSTenv var; override fromkv.
Amber Electric (unchanged)
- Base URL
https://api.amber.com.au/v1. Bearer token from secret. - Endpoints:
GET /sites,GET /sites/{id}/prices/current,GET /sites/{id}/prices/forecasts,GET /sites/{id}/prices/next. - Store buy + feed-in at 30-min granularity,
generalprice type. - Refresh: current 60s, forecast 5min. 5s in-memory cache.
Solar forecast (unchanged)
- Primary Solcast (free tier, 7d horizon). Fallback Open-Meteo (no key, 11d).
- Refresh: 30min 04:00–22:00, 2h overnight. UPSERT to
forecast_solar. - Use rolling 4h PV estimate vs SoC for pre-charge headroom.
Controller logic (every 60s — unchanged)
- Decision order: Safety (SoC ≤10% force charge, ≥95% force discharge) → Manual override (if active, not expired) → Manual schedule (highest priority enabled covering now) → Auto PRICE (cheap window ≤60min + SoC<80% → charge; expensive now + SoC>30% → discharge capped) → Auto WEATHER (tomorrow solar <2 kWh + tonight prices low → pre-charge) → Auto USAGE (learn 14d baseline, top-up before high-load evening) → self-consume default.
- Single coherent EMS-mode write per tick. Log decision reason to
events.
UI (htmx + Alpine + Chart.js — unchanged)
- Pages:
/Dashboard,/schedules,/automations,/history,/settings,/events. - Big mode badge, one-click override buttons (“Charge now 30 min” etc.), banners on stale data.
- All charts 30-min resolution.
API (JSON — unchanged)
GET /api/v1/state,/api/v1/telemetry,/api/v1/prices,/api/v1/forecast/solar,/api/v1/schedules(GET/POST/PATCH/DELETE),POST /api/v1/mode,POST/DELETE /api/v1/override,GET /api/v1/events,GET /metrics.- All state-changing require CSRF + Basic-auth session.
k8s deployment
- Namespace
sigenstor. Helm chartsigenstor-control. 1 replica, no HPA. - Resources: req 50m/64Mi, lim 500m/256Mi (Python ~50–80Mi idle).
- PVC
sigenstor-data1Gi RWO (sqlite + logs). - Secret
sigenstor-secretsmounted as env vars (keys used):AMBER_API_TOKEN— Amber client.AMBER_SITE_ID— Amber client.SIGENSTOR_MODBUS_HOST/_PORT/_UNIT_ID— Modbus client.SOLCAST_API_KEY— Solcast client (optional; Open-Meteo needs none).MYSIGEN_USERNAME/MYSIGEN_PASSWORD— only if cloud path used (NOT primary; local Modbus is).BASIC_AUTH_HTPASSWD— UI auth.SESSION_SECRET— cookie signing.SQLITE_ENCRYPTION_KEY— encrypts modbus password inkv.
- ConfigMap
sigenstor-config(non-secret env:LOG_LEVEL,LISTEN_ADDR=:8080,DB_PATH,POLL_INTERVAL_S). - Service ClusterIP 8080. Ingress via existing nginx ingress, TLS via cert-manager, host
sigenstor.home.<domain>. - Probes: liveness
GET /healthz(DB pingable + last poll <60s); readiness same minus modbus (allow degraded). - PDB min 1 max 1.
- NetworkPolicy: egress only to LAN CIDR (SigenStor), api.amber.com.au, api.solcast.com, api.open-meteo.com.
- Backup: nightly CronJob
sqlite3 .backupto PVC, retain 14, push to S3-compatible bucket via restic.
Security
- All secrets via k8s secret env-injected, never logged.
pydantic-settingsreads them at startup; secret values masked in logs (structlogprocessor). - Modbus write password AES-GCM-encrypted in
kv, decrypted in-memory only. - HTTPS at ingress; HTTP→HTTPS redirect.
- CSP:
default-src 'self'; connect-src 'self' api.amber.com.au api.solcast.com;chart.js + alpine.js vendored under/static(no CDN). - CSRF token on all POST/PATCH/DELETE; signed cookie session.
- Non-root container user (
USER app).
Observability (unchanged)
- Prometheus
/metrics:sigenstor_soc_percent,sigenstor_battery_kw,sigenstor_pv_kw,sigenstor_grid_kw,sigenstor_amber_buy_cents,sigenstor_amber_feed_in_cents,sigenstor_controller_mode,sigenstor_modbus_errors_total,sigenstor_decision_duration_seconds. - structlog JSON to stdout. INFO/WARN/ERROR per Go draft.
- Grafana dashboard JSON committed.
Failure modes + safety (unchanged)
- Modbus unreachable >120s → safe self-consume + banner, no writes.
- Amber 401 → cache last, disable PRICE mode + banner.
- Solcast+Open-Meteo both fail >2h → disable WEATHER, PRICE+USAGE still work.
- DB write failure → controller pauses, ERROR log, banner.
- Pod restart → controller re-asserts last mode from
mode_stateon startup. - Battery fault bit → force self-consume + alert.
- All writes idempotent.
Testing strategy
- Unit:
pytesttable-driven on controller decision tree (synthetic inputs). - Integration: docker-compose with
pymodbus.server(async Modbus fake),respxfor httpx (Amber/Solcast mocks), fixtures. - E2e manual: deploy to k8s, point at real SigenStor, verify one diurnal cycle.
- Regression: golden-file test for controller output given canned input.
- Lint/type:
ruff,mypy --strictin CI. - Security:
pip-audit,trivyimage scan,kubeconformchart lint.
Out of scope (v1) — unchanged
- Multi-site/multi-user, VPP, EV charger, HA add-on packaging, mobile app, per-string PV.
Risks (additions for Python stack)
- Python base image CVEs → rebuild on
python:3.13-slimdigest updates; pin digest. - pymodbus async API changes between minor versions → pin exact, integration tests catch regressions.
- APScheduler in async loop — use
AsyncIOScheduler; ensure DB sessions closed per tick to avoid leaks. uv/pip-toolslockfile drift → CI fails builds on unpinned deps.
Decisions summary (locked, stack swap noted)
- Local Modbus primary, no cloud mySigen dependency.
- Python 3.13 single uvicorn process,
python:3.13-slimmulti-stage non-root image. - SQLite single-file DB, no external DB.
- htmx server-rendered UI, no SPA.
- Single replica, no HPA.
- PRICE / WEATHER / USAGE composable; safety overrides all.
- Decision logged every tick.
- Controller idempotent + restart-safe.