The complete, self-contained rule set for solis. MUST = blocker, SHOULD = fix unless there is a written reason, MAY = allowed. A MUST deviation is valid only if listed in section 12. When code and this file disagree, fix one of them in the same change.
Polls one Solis hybrid inverter over Modbus (TCP or RTU), stores daily energy counters and
status/fault changes in SQLite, computes monthly/yearly/total values itself, and serves a
React dashboard (REST + WebSocket) plus Swagger UI from the same binary (go:embed of
frontend/dist, docs/dist). No subcommand = server; solis backfill = maintenance job.
Shipped as linux/darwin binaries and a multi-arch scratch image on ghcr.io.
make check # ./scripts/pre-commit.sh, check-only (no mockery)
golangci-lint fmt --config .golangci.yml # gofumpt + goimports (prefix github.com/dombyte/solis)
golangci-lint run --config .golangci.yml
go run golang.org/x/tools/cmd/deadcode@v0.51.0 -test ./...
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
go tool mockery # regenerate mocks (.mockery.yaml)
go test -race ./... # as in CI
make assets | build | all | docker # dists; ./solis with ldflags; both; dev image
go run ./cmd [-config <path>] # server (default ./config.yaml)
go run ./cmd backfill --years 0 # maintenance job (app stopped), exits 0/1
go run ./cmd version # build info (also -version)
cd frontend && npm run typecheck && npm run lint && npm run knipmake checkruns the same checks as CI (checks.yml: plus tidy check, mock drift, frontend checks); run it by hand before every merge..golangci.ymlis the source of the lint and size rules — don't relax it without a deviation in section 12.- Tool versions are pinned and bumped deliberately, same locally and in CI.
- Renovate is the only dependency updater.
renovate.jsonMUST track every pinned version (gomod, Dockerfiles, compose, GitHub Actions, npm infrontend/+docs/, regex managers for pinnedgo runtools and the golangci-lintversion:); a change that adds a pin extends it. - Release: SemVer tag
vX.Y.Zonmain→release.ymlreruns the checks, goreleaser (make assetsin its before hooks) builds archives and images (latestonly for stable).
cmd/ main.go (dispatch, signal ctx, exit code, ldflags), serve.go, backfill.go
internal/
app/ composition root: Create* factories, health adapters, logger, config mapping
buildinfo/ build info passed down from cmd
config/ YAML/env config, structural Validate(); domain rules injected by app
period/ Period (day/month/year keys from one captured instant), rollover window
eventbus/ ValuesUpdated, PeriodClosed; Publish never blocks; per-subscriber overflow policy
health/ supervisor, component contract, restart budget, ErrHealthFatal, snapshot
solis/ register table, computed defs, PlanBlocks, Decoder, status/fault maps
modbus/ TCP/RTU client, reconnect loop, Source slot for the poller
poller/ poll loop, DayAttributor (day attribution + rollover), cold-start seeding
aggregation/ PURE period math (no I/O, clock, logging), shared by aggregator + CLI
aggregator/ event-driven runner (debounce/heartbeat/catch-up)
cache/ latest values; publishes change events; ReplaceDomain/Merge
storage/ sole SQLite owner; PollerStore/AggregatorStore/ReadStore; closed periods
history/ history read-model rows
database/ migrations (incl. V3 meta table), backups, retention cleanup
websocket/ subscription protocol, per-client diff, same-origin upgrader
service/ ReadService for HTTP (cache + ReadStore + health snapshot)
http/{httphandler,middleware,router,server}/
maintenance/ CLI jobs: flock, backup, recompute, report
logging/ zerolog constructor (no global logger)
util/ math, data types, Clock, Slot[T], RequireAll; util/clocktest = fake Clock
<pkg>/mocks/ mockery-generated (never hand-edit)
frontend/ React 19 + Vite + Tailwind 4 + zustand + lucide-react + chart.js
docs/ Swagger UI + openapi.yaml
example/ docker-compose (TCP, RTU) and config.yaml templates
- All Go code lives in
internal/, at most 3 levels deep. Nopkg/without the user's confirmation.
Package rules:
- config imports no domain package.
Validate()reports every problem with its field path (poller.interval: must be at least 1s, got 0s; …). Rules owned elsewhere (Modbus address, strictrollover.timeHH:MM, poll timeout vs health grace) areconfig.Rules fromapp.ConfigRules()returning*config.ValidationError.appmaps sections onto each package's ownSettings(internal/app/config_mapping.go). - modbus: transport chosen by
modbus.addressscheme (tcp://host:port,rtu://<device>); exponential-backoff reconnect; construction never fails on an unreachable device (startsrecovering); raw reads + error classification only. Published viautil.Slotso the poller always reads the current client after a restart. - solis: everything Solis-specific; generic code goes to
util; no Modbus client logic. - poller: non-overlapping loop (next poll = interval after previous start); owns day
attribution and rollover; emits
PeriodClosed. - storage: single connection, WAL; enforces closed-period immutability
(
ErrPeriodClosed) and write domains; period sums run in SQL with explicit bounds. - http: handlers in
httphandler(no business logic; they callservice), middleware inmiddleware(never mixed), all routes inrouter/router.go, lifecycle inserver.
- DI (MUST): every dependency comes in through the constructor as a small interface
declared by the consumer (except the shared ones below); nothing is created inside
methods.
internal/appis the only composition root (Create*factories) and, withcmd, the only importer ofconfig. - Required deps: constructors check them with
util.RequireAll(→util.DependencyError); a nil dependency fails startup. No optional components behindif x != nil— a switchable feature is a separate component or subcommand. Missing data (empty cache) returns an explicit typed answer. Functional options MAY carry 3+ optional settings, never dependencies. - No global state: no package-level loggers, singletons, caches or lookup maps. Only
Err…sentinels and the linker-set build info incmd/main.goare allowed.
router → httphandler → service → storage (ReadStore) / cache. Dependencies flow down
only. Background components never reference each other; they meet only through cache,
event bus and health supervisor.
| Package | May import |
|---|---|
| poller | solis, period, eventbus, health (Reporter), storage types, util |
| aggregator | aggregation, solis, period, eventbus, health (Reporter), storage types, util |
| websocket | eventbus, solis, health (Reporter), util |
| cache | eventbus, solis |
| storage | solis, period, history, database/migrations, util |
| history, config | util (config only imported by cmd and app) |
| modbus | stdlib, simonvetter, util (Clock), injected zerolog.Logger — no config/health |
| service | storage (ReadStore only), health, history, period, solis |
| httphandler | service, health, period, solis, buildinfo, util |
| buildinfo | stdlib |
| app | everything, incl. frontend/docs embed packages |
- "storage types" = interfaces, DTOs (
PollWrite,DailyRow, …) and sentinels instorage/interfaces.go; nobody imports*storage.Storage. - Forbidden: poller ↔ aggregator, cache → websocket, httphandler → storage/modbus, anything → cmd/app.
- Shared exported interfaces (same contract for several consumers):
storage.{PollerStore,AggregatorStore,ReadStore,KeyLookup},eventbus.{Publisher,Subscriber},health.{Component,Reporter},util.{Clock,Timer,Ticker}. Every interface has a mockery mock.
| Writer | May write | Via |
|---|---|---|
| poller | daily_values (non-net keys, max of the open day), error_data (on change), its own cache keys |
PollerStore, cache.ReplaceDomain |
| aggregator | monthly/yearly/total values, net daily rows, baselines/freeze marks in meta, computed cache keys |
AggregatorStore, cache.Merge |
| maintenance CLI | computed tables + baselines, only with the app stopped (flock) | storage + aggregation |
| everyone else | nothing | read-only interfaces |
- Storage rejects net keys from the poller and non-net keys from the aggregator, and any
write to a closed period (
ErrPeriodClosed). ReplaceDomainandMergetouch disjoint keys; neither wipes the other.- Values are stored at full precision; rounding to 2 decimals only in JSON.
Every goroutine has an owner that starts, stops and waits for it. Waiting loops use the
injected util.Clock; channels document their owner (sender closes) and full behaviour.
Single supervisor: internal/health. Restartable components (modbus, poller, aggregator,
WebSocket hub) implement:
Start(ctx context.Context) error // error only for unrecoverable conditions
Stop() error // idempotent, graceful, bounded
State() health.State // Healthy | Recovering | Failed
LastBeat() time.Time // atomic, updated by the component's own loop- Built only by a
health.Factorycalled by the supervisor; receives itshealth.Reporterin the constructor and pushes (rare) state transitions through it. NoRestart(); no component restarts another. Restart = Stop → factory → Start. - Beat from the loop, not only after work: idle aggregator/hub still beat; the Modbus reconnect loop beats on every attempt and while in backoff. Initial grace after (re)start = 5× poll interval.
- Ordinary failures (poll timeout, read error, lost connection) are handled internally
(log, back off, retry) as
Recoveringand never count as restarts. - Restarts are at least one sweep apart. The failure counter resets only once a restarted instance kept beating for the healthy grace (latest beat ≥ grace after its start).
ErrHealthFatal(cancels root ctx viacontext.WithCancelCause) when: restart budget (3) exhausted; a restart'sStop()exceededStopTimeout(never start a successor next to a hung instance); aWatched non-restartable part (storage, cache, event bus, HTTP server) failed.- Constants, not config: restart budget 3, aggregator debounce 4× / heartbeat 5× poll interval, reset threshold 10 %.
mainbuilds a signal context (SIGINT/SIGTERM). Single-phase shutdown: producers first, consumers drain, eachStop()bounded, one hard deadlineapp.ShutdownTimeout(30 s); no second-signal force mode. Backup/cleanup jobs are joined before storage closes.- Exit 1 on
ErrHealthFatal, startup error, panic orapp.ErrShutdownTimeout; exit 0 on signal. The app never restarts itself; the container runtime does (restart: unless-stopped).log.Fatal/os.Exitonly inmain.
- Errors: wrap with
%wand a package prefix (storage: save day %s: %w); sentinels for branching,…Errortypes when callers need data. Never swallow (_ = f()needs a//nolintwith reason). Log once, where the error is handled. No panic. - Logging: injected
zerolog.Loggerscoped withcomponent; context fields, not formatted strings;debugper cycle,infolifecycle,warnrecovered,errorneeds attention. Never log secrets. - Style: gofumpt + goimports; lines ≤ 100, functions ≤ 40 lines/statements, gocyclo
< 8, ≤ 5 params, no magic numbers (all lint-enforced). Stdlib naming (
ID,URL, one-letter receivers, verb + noun functions). Doc comments on packages and exported types (MUST) and functions (SHOULD); inline comments explain why.
- ❌ No Modbus reads from the HTTP path (no
?direct=true, noPollNow). In v2 this made HTTP wait behind the sequential-only inverter (0.6 → 1.5 s) and the flag leaked through some paths. Only the poller talks to the inverter. - ❌ No
time.Now()mid-run in poller/aggregator: capture once, derive aperiod.Period, use the injectedutil.Clock. - ❌ No writes outside your write domain (section 5).
- ❌ No locks on top of the Modbus library — simonvetter serializes requests; lock only state you own (e.g. reconnect).
- Fetch only what you need: query per key with SQL bounds (v2 filtered all rows in Go, ~14× slower).
- I/O functions take the caller's
ctx(nocontext.Background()belowcmd/app); SQL uses…Contextvariants. - Dangerous behaviour belongs in CLI jobs, never in config toggles.
- Routes (
router.SetupRoutes(Deps)):/health,/api/keys,/api/data/{key},/api/version,/ws,/docs/, SPA. No CORS middleware. - Handlers return
http.Handlerand takehttphandler.HandlerDeps(Service= handler-declaredReadService,Errors= central*ErrorMapper,Clock,Timeout=app.timeout,Build). They parse, callService.Data/Keys/Health, map errors, write JSON. Input is validated at this boundary or inservice. - Middleware (
Recover(log),Logger(log),SecurityHeaders) isfunc(http.Handler) http.Handler. - One error shape (
WriteError(w, status, msg), documented indocs/src/openapi.yaml):{ "error": "Bad Request", "message": "…", "code": 400 }. service.NewReadService(service.Deps{Store, Cache, Health, Registry, Decoder, Log}), all required; never gets a Modbus client, poller or aggregator.- Static files come from
fs.FSinrouter.Deps(Frontend,Docs):apppassesfrontend.Dist()/docs.Dist(), tests anfstest.MapFS. A tree withoutindex.htmlis not mounted (appwarns).go:embed all:dist*also matches the committeddist.md, so Go builds/tests/lint work without npm; release builds always runmake assets.
One version for binary, UI and docs: ldflags (Version, Commit, BuildDate,
GoVersion) → buildinfo.Info → solis version, startup log and GET /api/version;
the frontend and Swagger UI get VITE_APP_VERSION from the same source (make,
goreleaser, Dockerfile). No version.json. The frontend polls /api/version; a different version shows the update
banner. dev on either side never reports an update.
GET /health reads only the supervisor snapshot, never blocks on a component.
- 200
okordegraded(+ details).degraded= a restartable component's first failure, or a restarted instance not yet proven healthy. - 503 (+ failed component, reason) when a restarted instance failed or went silent again
(until one kept beating for the healthy grace), the budget is exhausted, a non-restartable
part failed, or the snapshot is older than 3 sweeps. Never
okwith a dead subsystem.
→ { "type": "subscribe", "keys": ["pv_total_power", "solis_status"] }
← { "type": "snapshot", "values": { "pv_total_power": { "value": 5230, "timestamp": "…", "unit": "W" } } }
← { "type": "update", "ts": "…", "values": { "pv_total_power": { "value": 5102.5 } } }
← { "type": "update", "ts": "…", "values": {}, "removed": ["battery_power_signed"] }
→ { "type": "unsubscribe", "keys": ["solis_status"] }
← { "type": "error", "code": "unknown_keys", "keys": ["foo"] }
→ { "type": "ping" } ← { "type": "pong" }- Only changed, subscribed keys are pushed, diffed per client by
value+status_decoded(timestamps change every poll). Pushes are coalesced (~75 ms) into one frame with onets.removed= subscribed keys gone from the cache (ReplaceDomainevents carry them). - Unknown keys never drop the connection. Frontend pings every 20 s and on tab focus; reconnects if no frame arrives within 5 s.
- Same-origin upgrader (Origin host == Host header, scheme ignored). No history over WS.
- Limits: 256 clients (257th closed with 1013), 256 keys per frame.
- New UI registers need no new endpoint: define the register in
solis, clients subscribe.
Viper YAML (./config.yaml, gitignored; path via -config) with SOLIS_ env overrides
(SOLIS_MODBUS_ADDRESS). Order: defaults → file → env → validate once at startup.
example/config.yaml documents every key; README lists only the common ones with defaults.
- Every key has a default in
setDefaults(zero values too) — viper only applies env overrides to known keys;TestSetDefaults_CoversEveryKeyenforces it. An invalid env override is an error, never ignored. - Durations need a unit (
30s,1y;d,w,yadded); bare numbers are rejected.poller.interval,app.timeout≥ 1 s;modbus.slave_id1–247; block delays ≥ 0. - Cross-field:
poller.poll_timeout + modbus.timeout< 3 ×poller.interval(healthy grace; a read in flight can overrunpoll_timeoutbymodbus.timeout). - Timezone only from
TZ(time.Local), never config; pin it in docker-compose. - Secrets (if ever needed) come from env vars or secret files, never the committed config.
- Removed in v3 (ignored with a warning):
app.serve_only, theaggregatorsection (usebackfill),storage.monthly_retention,storage.yearly_retention,storage.enable_migrations(migrations always run).
solis.Register{
Key: "grid_power", Name: "Grid Power",
Address: 33130, // 0 = computed/derived, never polled
DataType: solis.Int32, // register count derived from the type
Scale: 1, Unit: "W",
Store: solis.StoreNone, // none | daily | monthly | yearly | total | status
}Store:none= cache only,status=error_dataon change, others = matching table. Switch onreg.Store; never addStabilityorIsXxxRegisterlists.energyKinds()lists the eight energy series once; each gets a polled daily register plus computed monthly/yearly/total registers and daily→level edges.- Computed registers (monthly/yearly/total/net,
Net: true= export − import, latest-value rule) have no address — add an edge or net pair. Derived live values (e.g.battery_power_signed, needs two registers) come fromDecoder.Deriveafter a full poll. solis.NewRegistry()validates the table (unique keys/addresses, validStore, consistent edges) and fails startup otherwise.PlanBlocksmerges nearby addresses up to 125 registers per read; the plan is 3 reads, pinned byTestBlockPlanGolden(update it when addresses change). Prefer an alternate address inside an existing block over a new round-trip (grid_powerat 33130, not 33263).- Decode:
Decoder.Decode(reg, raw, pollStart)/DecodeBlock(block, blockRaw, pollStart), thenDerive(values, pollStart); full precision. - Grid power sign (positive = export) and battery direction are verified on the device; if
firmware changes them, flip in
Decoder.Derive, not in the UI.
- Runs against the DB and exits 0/1; never starts HTTP, poller or hub.
- Recomputes monthly + yearly for the current year plus N closed years (default 0) with the
same
aggregationfunctions as the live aggregator; refreshes the total baseline when a closed year is touched. - Refuses while the app runs: exclusive
flocknext to the DB (server holds a shared one). Always backs up first, verified withPRAGMA integrity_check(database.CreateBackup); no backup → no write → exit 1. - Refuses periods whose daily rows retention deleted (meta
purged_before); with any purge,--years N > 0fails rather than write partial sums. - Periods starting before the oldest daily row are skipped (stored value kept, reported).
- Baseline refresh sums daily rows from the cutover year's 1 January through the baseline year (as the live aggregator folds); nothing is refreshed while the baseline year precedes the cutover.
--forcerecomputes periods without complete daily history (partial sums) and rebuilds the baseline from all daily rows; thepurged_beforerefusal still applies.- Output: backup path; one line per row (
monthly pv_energy_monthly 2026-08 412.30 kWh -> 409.87 kWh,n/aif no row); oneskippedline per incomplete period; a summary (recomputed / unchanged / lower / skipped).
- Live data only via WebSocket: components call
useSubscription(keys); the client ref-counts keys and re-subscribes on reconnect. History via REST. - Register metadata:
src/lib/config/data.ts(single UI source of truth). useMobile()= coarse pointer. The power-flow diagram uses the mobile variant only for coarse pointer and width < 768 px (tablets get desktop).- Power-flow (
src/components/dashboard/flow/):model.ts,FlowDesktop.tsx,FlowMobile.tsxare the layout reference; lucide-react icons; colors only from CSS tokens insrc/index.css(light + dark), never hex; edge animation via CSS keyframes (on/off, fixed speed, respectsprefers-reduced-motion), not rAF + state. - Directions: PV → inverter; inverter → household/backup; grid export out / import in;
battery_power_signed > 0= charging. Zero = shown as 0, edge stops; missing data = node (or whole diagram) grayed out.
- linux and darwin only: the server needs
flock; elsewheremaintenance.AcquireSharedfails and the server does not start. - LAN app without auth: the WS origin check does not stop DNS rebinding and REST has no CORS. Never expose it to the internet without an authenticating reverse proxy (which also terminates TLS and rate-limits).
- Every outgoing call (Modbus) has a timeout.
- testify (
require/assert) + mockery mocks in<pkg>/mocks(never hand-edited; CI fails on drift). Table-driven,t.Parallel()where possible, always-race. - No network, real clock or sleeps:
util/clocktestdrives debounce, heartbeat, graces, rollover and backoff. Real-device tests go behind//go:build integration. - New code comes with tests, bug fixes with a regression test. Coverage > 90 % per
package;
cmd/appare covered by a startup test. - Rollover/attribution and
periodtests cover midnight and DST (Europe/Berlin); storage tests cover closed-period rejection and write-domain guards; HTTP tests usehttptest+ the mockeryReadServicemock.
metatable (only schema addition): cutover date, closed-period watermarks (closed:daily:<key>,closed:netdaily,closed:monthly,closed:yearly), total baselines (baseline:<key>),baseline_year, retention watermarkpurged_before. At cutover: daily/net-daily closed = today − 2; monthly = month before that day's month; yearly = year before that day's year (a cutover on the 1st keeps the previous month open while its last day is still writable).- Cutover month/year keep inverter values: they stay open, and the difference between
each stored monthly/yearly value and its daily sum is stored as
offset:<level>:<period>:<key>, added on top of the growing daily sum.backfillwrites the pure daily sum and deletes the offset. - Totals are app-lifetime: daily rows plus a baseline folded in at each year close; pre-cutover inverter totals are not carried over.
- Retention: monthly/yearly rows follow
storage.daily_retention(only frozen years are deleted). Intentional; keep a longer retention for more history. - Catch-up: every aggregator run finalises and freezes any closed, unfrozen month/year;
PeriodClosed(once per rollover window, on the first closing key or a forced close) only makes it happen sooner. - Net values are computed from the same run's export/import values, never read back from the cache.
- Day attribution: after a counter reset the "new day" is the day whose local midnight is nearest the rollover time. Per-key bases live in memory, seeded from the DB at start. The inverter resets all daily counters together: a key that cannot show its own reset (no base or base 0) follows a reset another key confirmed in the same window, and is held (not written) from the opening day's midnight until then, so post-reset energy is never counted twice. After a confirmed reset any decrease on the closing day is the reset. Seeding returns day closes missed while down; closes are written even while Modbus is disconnected.
- Embedded web assets: archives and image ship only the binary; dists are build inputs.
- simonvetter returns
[]byte: words are converted by hand (~10–20 ns/read), negligible next to network latency.
- Branch
type/description+ PR (CI green before merge); the maintainer MAY commit tomaindirectly aftermake check. Regular merges; squash/rebase only on request. Several small changes MAY share one batch PR: local--no-ffbranches merged into one pushed batch branch. - Commits (MUST):
<type>(<scope>)!: <imperative lowercase subject>with typesfeat,fix,perf,refactor,docs,chore,build,ci,test,style(<type>(deps)for bumps). The changelog is built from subjects. - Scopes:
ui,api,ws,health,poller,aggregator,storage,modbus,solis,config,backfill,docker,release,ci,deps. != breaks upgrading users (REST/WS protocol, removed config or migration paths, changed runtime requirements); a footer alone is not enough.
feat(ui): show the energy cards as a swipe carousel on phones
refactor(storage)!: remove V1/V2 migrations and refuse pre-V2 databases
| Rule | Deviation | Reason |
|---|---|---|
Go tools pinned in go run commands |
mockery is a tool directive in go.mod (go tool mockery) |
a pinned go run checks ~20 modules against sum.golang.org on every CI run; a checksum-DB hiccup failed the mock drift job. Bump: go get -tool github.com/vektra/mockery/v3@vX.Y.Z |
Backlog (gaps between code and these rules): none open.