SigenStor Control — Design
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).
Stack
- Language: Go 1.22 (single static binary, low idle CPU, easy k8s deploy, mature modbus libs).
- Modbus:
github.com/whiteblock/go-modbusorgithub.com/grid-x/modbus(TCP, holding/input registers, FC6 write). - HTTP/JSON: stdlib
net/http+encoding/json. No web framework for the API server; usechifor routing (small, stdlib-shaped). - DB: SQLite via
modernc.org/sqlite(CGO-free). WAL mode. One file under/var/lib/sigenstor/sigenstor.db. - Time-series: keep last 30 days at 30-min resolution in DB tables; chart endpoints aggregate on the fly. No Prometheus — overkill for one site.
- UI: server-rendered HTML via
html/template+ htmx + Alpine.js + Chart.js (CDN). One Go binary serves UI + JSON API. No SPA. - Auth: single user; HTTP Basic + bcrypt hash in env/secret. CSRF token on state-changing forms. Cookie session (signed).
- Container: distroless
gcr.io/distroless/static-debian12, multi-stage Dockerfile. Helm chart. - Logging:
log/slogJSON to stdout. Metrics: Prometheus/metricsendpoint (process + custom). Traces: none.
Architecture (one process, three internal layers)
poller— fixed-tick goroutines: SigenStor every 5s, Amber every 60s, Solcast every 30min, price forecast refresh every 5min. All write to DB and an in-memorystatestruct.controller— runs every 60s. Reads state + DB (schedules, mode, manual override, prices, forecast, SoC). Decides target charge/discharge kW and EMS mode register writes. Idempotent.api— HTTP server: JSON/api/v1/...for programmatic access, HTML/for UI,/metricsfor Prometheus.
Data model (SQLite, single file)
telemetry(ts INT PK, soc_pct REAL, bat_kw REAL, pv_kw REAL, load_kw REAL, grid_kw REAL, mode TEXT, online INT) — append-only, partitioned by retention job (drop >30d).prices(ts INT PK, buy_c_per_kwh REAL, feed_in_c_per_kwh REAL, source TEXT, fetched_at INT) — 30-min slots, 24h+ horizon.forecast_solar(ts INT PK, kwh REAL, source TEXT) — 30-min slots, 7d horizon.forecast_price(ts INT PK, predicted_c_per_kwh REAL, interval_type TEXT) — from Amber.schedules(id, name, days_mask, start_min, end_min, action TEXT, kw REAL, priority INT, enabled INT) — manual time-of-day rules.mode_state(key TEXT PK, value TEXT) — current mode, manual override expiry, last decision reason, controller enabled flag.events(ts, kind, payload TEXT) — mode changes, override set/clear, errors, Modbus write failures. 90d retention.kv(key TEXT PK, value TEXT) — amber api token (encrypted at rest with key from k8s secret), solcast api key, modbus target, modbus unit id, modbus poll interval.
Sigenergy Modbus integration
- Reference impl: Home Assistant
sigenergyintegration (https://github.com/ypengju/homeassistant-sigen). Reuse its register map verbatim (do not reinvent — verified against real firmware). - Transport: Modbus TCP on port 502 (or 5020/TCP — confirm during commissioning; default 502). Read holding registers for telemetry, write holding registers for control. Unit ID = 1 default.
- Key registers (HA sigenergy integration maps; verify exact addresses at deploy):
- Read: device master EMS status, SoC, battery power, PV power, load, grid power, energy totals.
- Write: EMS mode (self-consume / forced charge / forced discharge / remote schedule), forced charge/discharge power limit, remote schedule enable/disable.
- Auth: SigenStor local Modbus requires the installer/admin password for write operations (HA integration prompts). Store in k8s secret; pass to Modbus client on each write session (some firmware require re-auth per session).
- Failure handling: on read error, mark last value stale, surface banner in UI. On write error, exponential backoff 1/2/4/8s, max 3 attempts, then raise event + revert to safe mode (self-consume). Never leave battery in forced-charge indefinitely.
- Polling: 5s reads in a single goroutine; writes are serialized through a
writeChchannel (one writer, FIFO) — avoids interleaved register updates. - Discovery: hardcode IP via k8s ConfigMap (sigenergy.local as env var
SIGEN_HOST). Allow override fromkv.
Amber Electric
- Base URL:
https://api.amber.com.au/v1. - Auth: bearer token from k8s secret. Endpoints:
GET /sites— list site IDs (resolve once, cache tokv).GET /sites/{id}/prices/current— current 30-min interval + next interval.GET /sites/{id}/prices/forecasts— predicted intervals (next 24h).GET /sites/{id}/prices/next— paginated upcoming.
- Store both buy (
perKwh, c/kWh × 100) and feed-in (feedInPerKwh) at 30-min granularity. Usegeneralprice type (controlled load ignored — Mick has none relevant). - Refresh: current every 60s, forecast every 5min. Cache 5s in-memory to absorb burst UI reads.
Solar forecast
- Primary: Solcast (free tier, 50 API calls/day, 30-min resolution, 7-day horizon). Use rooftop estimate if both sites configured.
- Fallback: Open-Meteo (no key, 11-day horizon, less accurate for cloud).
- Refresh: every 30min between 04:00–22:00 local, every 2h overnight. Cache to
forecast_solar(UPSERT). - Use rolling sum of next 4h PV estimate vs current SoC to decide pre-charging headroom.
Controller logic (every 60s)
Inputs: current SoC, current prices, next-12h price curve, next-24h solar curve, schedules, manual override, controller-enabled flag, safety bounds (SoC min 10%, max 95%, max charge/discharge C-rate).
Decision order (first match wins, write once):
- Safety: if SoC ≤ 10% → force charge at max rate (capped) regardless of price.
- Safety: if SoC ≥ 95% → force discharge at capped rate.
- Manual override: if active and not expired → honor override target (charge kW, discharge kW, or self-consume). Expiry on
mode_state.manual_override_until. - Manual schedule: if a schedule row covers now AND enabled AND priority highest → execute (charge, discharge, or self-consume).
- Auto PRICE mode:
- Compute next-12h average buy price vs current feed-in.
- If cheap window starting in ≤60min AND SoC < 80% → charge at max.
- If expensive window now AND SoC > 30% → discharge at capped kW (do not exceed house load + export cap).
- Otherwise: self-consume (EMS default mode).
- Auto WEATHER mode: if tomorrow’s solar forecast < 2 kWh AND tonight’s prices low → pre-charge tonight; else self-consume.
- Auto USAGE mode: learn daily baseline load from last 14 days (kWh). Top-up to target SoC before predicted high-load evening window.
- Default: self-consume.
Always write a single coherent EMS-mode register update per tick. Log decision reason to events.
UI (htmx + Alpine + Chart.js)
Pages:
/Dashboard — current SoC gauge, PV/load/grid/battery kW tiles, current Amber buy + sell price + next interval change countdown, 24h price+SoC+PV chart, mode indicator, manual override buttons./schedules— CRUD list of manual time-of-day rules, day-of-week mask, action, kW, priority./automations— toggle PRICE / WEATHER / USAGE modes, threshold sliders (price spread, SoC bounds), enable/disable controller./history— 7/30-day charts: SoC, energy in/out, self-consumption ratio, $ saved estimate./settings— Modbus host, Amber token, Solcast key, manual override (set + expiry), backup/restore DB./events— paginated log of mode changes, errors, writes.
UX rules:
- Big mode badge (AUTO / MANUAL / SCHEDULE / SAFETY) always visible.
- One-click manual override: “Charge now 30 min”, “Discharge now 30 min”, “Self-consume 2 h”. Confirmation not required.
- Banner on Modbus stale >60s, Amber token expired, forecast stale >2h.
- All charts use 30-min resolution.
API (JSON)
GET /api/v1/state— current snapshot.GET /api/v1/telemetry?from=&to=&bucket=— historic.GET /api/v1/prices?from=&to=GET /api/v1/forecast/solar?from=&to=GET /api/v1/schedules/POST/PATCH /:id/DELETE /:idPOST /api/v1/mode—{mode: auto|manual|self_consume, action?: charge|discharge, kw?: float, until?: iso8601}POST /api/v1/override— set manual override with expiry.DELETE /api/v1/override— clear override.GET /api/v1/events?from=&to=GET /metrics— Prometheus.
All POST/DELETE require CSRF token + basic-auth session.
k8s deployment
- Namespace:
sigenstor. - Helm chart
sigenstor-control(single Deployment, 1 replica, no HPA — single instance owns Modbus writes). - Resources: requests 50m / 64Mi, limits 500m / 256Mi (Go binary, idle ~30Mi).
- Mounts: PVC
sigenstor-data1Gi RWO (sqlite file, logs optional). - Secrets:
sigenstor-creds(amber token, solcast key, sigenergy modbus password, basic-auth htpasswd, sqlite-encryption key, session key). - ConfigMap:
sigenstor-config(non-secret env: SIGEN_HOST, POLL_INTERVAL, DB_PATH, LOG_LEVEL, LISTEN_ADDR=:8080). - Service: ClusterIP on 8080. Ingress via existing
nginxingress (TLS via cert-manager, hostsigenstor.home.<domain>). - Probes: liveness =
GET /healthz(200 if DB pingable + last SigenStor poll <60s); readiness = same minus modbus check (allow degraded reads). - PDB: min 1, max 1.
- Network: no egress restrictions to api.amber.com.au, api.solcast.com, api.open-meteo.com. Egress to SigenStor LAN IP only (NetworkPolicy egress to LAN CIDR).
- Backup: nightly cronjob runs
sqlite3 .backup /var/backups/sigenstor-$(date).db, retains 14, pushes to existing S3-compatible bucket via restic.
Security
- All secrets via k8s secret, env-injected, never logged.
- Modbus write password stored encrypted (AES-GCM with key from secret), decrypted in-memory only.
- HTTPS only 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).
- No external links from UI.
Observability
- Prometheus scrape
/metrics. Key series: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. - Log lines structured JSON; INFO normal, WARN stale data / override, ERROR write failure / token expired.
- Grafana dashboard: 4 panels (overview, prices, decisions, errors). Pre-built JSON committed.
Failure modes + safety
- Modbus unreachable >120s → controller enters safe self-consume, banner alerts, no writes attempted.
- Amber token 401 → cache last known prices, disable PRICE auto mode, banner alerts.
- Solcast/Open-Meteo both fail >2h → disable WEATHER auto mode, PRICE+USAGE still work.
- DB write failure → controller pauses, logs ERROR, banner alerts.
- k8s pod restart → controller reads
mode_state, re-asserts last mode to SigenStor on startup (idempotent). - Battery temperature/fault registers (read from HA register map) → if fault bit set, controller forces self-consume and alerts.
- All writes idempotent; safe to restart at any time.
Testing strategy
- Unit: controller decision tree with synthetic inputs (table-driven).
- Integration: docker-compose stack with
modbus-server(pythonpymodbusfake), fake amber/solcast HTTP servers. Run full controller tick against fixtures. - E2e manual: deploy to k8s, point at real SigenStor, verify dashboard + mode changes for one full diurnal cycle.
- Regression: golden-file test for decision output given canned input.
Out of scope (v1)
- Multi-site / multi-user.
- VPP / grid services.
- EV charger integration.
- Home Assistant add-on packaging (HA is reference only).
- Mobile app (UI is mobile-responsive).
- MPPT / per-string PV telemetry (SigenStor exposes only totals).
Risks
- Sigenergy firmware Modbus register changes between versions → pin firmware in docs, document any upgrade re-verification steps.
- Amber rate limits (low, but cache + respect 429).
- Solcast free tier 50 calls/day — current polling budget ~50/day fits.
- SQLite single-writer — fine for one process; if scaled out later, switch to Postgres (schema portable).
Key files to be produced
cmd/sigenstor/main.go— wire everything.internal/modbus/client.go— TCP client + register map (port of HA register constants).internal/amber/client.go— REST client.internal/solcast/client.go— REST client.internal/controller/controller.go— decision tree.internal/store/sqlite.go— DB layer.internal/api/server.go— chi routes + htmx partials.internal/api/templates/— html templates.internal/api/static/— vendored alpine.js, chart.js, css.deploy/Dockerfile,deploy/helm/...chart,deploy/grafana/dashboard.json.README.md— register map source + commissioning steps.
Decisions summary (locked)
- Local Modbus primary, no cloud mySigen dependency.
- Go single binary, distroless.
- SQLite single-file DB, no external DB.
- htmx server-rendered UI, no SPA.
- Single replica, no HPA.
- PRICE / WEATHER / USAGE modes composable; safety overrides all.
- Decision logged every tick for auditability.
- Controller is idempotent and restart-safe.