Repository navigation
feat(nova): opt-in federation to Sourceful Nova Core - #132
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 24d469fda3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| cfg.Nova.Enabled = true | ||
| cfg.Nova.URL = novaURL | ||
| cfg.Nova.GatewaySerial = gatewaySerial | ||
| cfg.Nova.OrgID = orgID |
There was a problem hiding this comment.
Keep existing org_id when reconcile omits --org
--reconcile explicitly allows running without --org, but this unconditional assignment overwrites cfg.Nova.OrgID with an empty string in that case. The command then saves config.yaml with nova.enabled: true and no org_id, so the next normal service start fails config validation (nova.org_id is required when nova.enabled). Preserve the existing org ID unless a new one is provided.
Useful? React with 👍 / 👎.
| for _, d := range devices { | ||
| for _, kind := range []telemetry.DerType{telemetry.DerMeter, telemetry.DerPV, telemetry.DerBattery, telemetry.DerEV} { | ||
| r := p.tel.Get(d.DriverName, kind) | ||
| if r == nil { |
There was a problem hiding this comment.
Deduplicate devices by driver before publishing readings
This loop iterates all persisted device rows, then fetches telemetry by driver_name. Because state.devices keeps historical device_id rows, a driver identity change can leave multiple rows with the same driver; the same live reading is then published once per stale row, producing duplicate/incorrect telemetry under retired hardware IDs (and potentially provisioning duplicates). Restrict publishing to the latest device row per driver or otherwise dedupe by driver_name first.
Useful? React with 👍 / 👎.
| out.Make = d.Make | ||
| out.Serial = d.Serial | ||
| out.HardwareID = d.DeviceID | ||
| out.TimestampMs = nowMs |
There was a problem hiding this comment.
Stamp payloads with reading time instead of tick time
Using the publish tick timestamp here makes stale data look fresh: if a driver stops updating, the last value is repeatedly emitted with a new ts_ms every publish cycle. That masks telemetry staleness in Nova and can distort downstream analytics/alerts. The payload timestamp should come from r.UpdatedAt (with a fallback only if unset), not the current loop time.
Useful? React with 👍 / 👎.
Adds an opt-in internal/nova package that publishes forty-two-watts
telemetry into Sourceful Nova Core's MQTT ingress with ES256 JWT auth,
plus a nova-claim CLI for one-time gateway registration + DER
provisioning.
Design thesis — clean internal model, adapter at the wire boundary.
Nova's current wire format has schema debt we don't want to import:
opposite battery sign, mixed-case field names (SoC_nom_fract, L1_V,
heatsink_C), and a diverging DER type vocabulary (solar / ev_port).
forty-two-watts keeps its physics-first, snake_case, site-convention
payload internally; a single translator (adapter.go) converts at the
publish boundary. When Nova adopts the unified schema (separate PR),
flipping `nova.schema_mode: unified` bypasses the adapter and it can
be deleted in one go.
Parts:
- internal/nova/{payload,adapter}.go — clean schema + legacy translator
with full sign-flip / type-vocabulary / field-rename coverage.
- internal/nova/{identity,jwt}.go — ES256 keypair gen/load, raw R||S
claim signatures, JWT matching Nova auth-callout's exact format.
- internal/nova/client.go — HTTP client for /gateways/claim and
/devices/provision.
- internal/nova/publisher.go — own paho client (JWT-as-password via
credentials-provider for transparent refresh), 5 s ticker, reads
from telemetry.Store, sanitizes topic segments to avoid dot-in-id
NATS-subject splits.
- internal/state/nova.go + nova_ders table — caches Nova's generated
der-{uuid7} locally for diagnostics; publisher skips unprovisioned
DERs with one-shot WARNs.
- cmd/forty-two-watts/nova_claim.go — subcommand that bootstraps an
ES256 key, calls claim + provision for every locally-registered
device (DER kinds inferred from ts_samples), writes the nova: block
atomically into config.yaml.
- docs/nova-integration.md — operator-facing setup, data mapping,
known schema divergence, migration plan.
- config: new Nova block with validation + defaults.
Runtime is a no-op when nova.enabled is false. All existing packages
untouched — Nova is strictly a read-side consumer of telemetry.Store.
477 tests passing; 27 new tests cover the adapter (sign flip, vocab,
field rename), identity/JWT (byte-compat with Nova's verifier), HTTP
client (claim body shape, provision response parsing), topic
sanitization, device-type inference, and assemble+encode end-to-end.
Follow-up PRs:
- Lua driver upgrades: emit lifetime energy counters (total_import_wh,
total_charge_wh, …) so Nova dashboards get counters not just power.
- novacore schema-alignment proposal: unified snake_case + site-
convention battery sign + pv/ev vocabulary. Lands adapter deletion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24d469f to
d93fb92
Compare
…#1429) * fix(drivers): one id per driver, from the bundled copy to the channel Moves the pin to srcfl/device-drivers#132, where every driver's DRIVER table states the catalog id and version. FTW's identity rule for the old spellings (IdentifiesSameDriver) goes: a bundled copy and a channel artifact are the same driver when their ids are equal. - Catalog tests look drivers up by the catalog ids. - The recovery-snapshot provenance test for esphome_dsmr is gone: the bundled file is now the driver's source, and sync-bundled-drivers.sh --check already binds every bundled file to the pinned commit. - setup.js recognises the Zap driver by its id `zap`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 * docs(release): promote the driver channel as the last stable step drivers-stable only serves installs that read the channel (1.x-3.x) and the stable rows of the Versions list; 0.x runs the drivers its release bundles. Promoting it is one existing command after a stable Core release, not new automation. The proposal to promote it with Core goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 * fix(drivers): pin the merged one-id device-drivers commit Moves the pin to bcf4683, srcfl/device-drivers#132 as merged on main, instead of the branch commit it was tested against before its rebase. The changeset no longer says esphome-dsmr is gone: #132 keeps that catalog entry for sites that installed it. The stable promotion step says what to do when main has moved past drivers-beta. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 * docs(agents): a driver selection survives updates and rollbacks Matches #1428: a newer release driver runs over an early selection without discarding it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 * fix(drivers): pin device-drivers 43710c8 (esphome_dsmr 1.0.5) srcfl/device-drivers#134 puts esphome_dsmr's host API fields on their own lines; FTW reads DRIVER fields line by line and its publication check refused the 1.0.4 file. The bundled set now publishes cleanly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #129.
Summary
internal/novapackage that publishes forty-two-wattstelemetry into Sourceful Nova Core's MQTT ingress with ES256 JWT auth.
forty-two-watts nova-claimsubcommand for one-time gatewayregistration (
POST /gateways/claim) + DER provisioning(
POST /devices/provision), capturing Nova'sder-{uuid7}IDs.nova:block inconfig.yamlwith validation and sensible defaults.docs/nova-integration.mdcovering setup, data mapping, schemadivergence, and the path to unified schema in novacore.
Design thesis — clean model internally, adapter at the wire
Nova's current wire format has schema debt we'd rather not import:
+W(load)−Ww,freq_hzW,Hzl1_v,soc,temp_cL1_V,SoC_nom_fract,heatsink_Cpv,evsolar,ev_portinternal/nova/adapter.gois a single isolated translator. When novacoreships the unified schema (follow-up PR), operators flip
nova.schema_mode: unifiedand the adapter is bypassed; once all fleets migrate the filecan be deleted in one go with zero changes to drivers, telemetry store,
or HTTP API.
Key design decisions
MQTT adapter on :1883) and ZAP firmware. NATS WS is for browser clients.
ES256 token on every (re)connect — expired tokens never hit the broker.
tech debt to one deletable file.
ts_samples, not new config.nova-claimfigures out which DER kinds each driver emits by reading the existing
long-format TS DB — zero additional YAML.
[a-zA-Z0-9_-]in a topicsegment becomes
_before publish soep:192.168.1.10:502doesn'tcreate accidental NATS-subject levels.
Identity + topic mapping
hardware_id←state.Device.device_idverbatim (make:serial,mac:…, orep:…).der_name←{driver_name}-{der_kind}(e.g.ferroamp-battery).der_id← Nova-generatedder-{uuid7}, cached locally instate.nova_dersfor diagnostics.cfg.Nova.KeyPath.gateways/{gw}/devices/{hardware_id}/ders/{der_name}/telemetry/json/v1.Scope
In scope:
CLI, docs, tests (27 new, 477 total passing).
Out of scope (tracked as follow-ups):
nova-claim --reconcile).Test plan
go test ./...— 477 passing, 27 newgo build ./...greenforty-two-watts nova-claim --url=… --org=… --site=… --claimer=…succeedsnova_dersrows populated withder-{uuid7}IDs after provision−Win Nova dashboard (legacy sign-flip via adapter)schema_mode: unified→ adapter bypassed (future; novacore must support it first)🤖 Generated with Claude Code