Skip to content

feat(nova): opt-in federation to Sourceful Nova Core - #132

Merged
frahlg merged 1 commit into
masterfrom
129-nova-integration
Apr 20, 2026
Merged

frahlg merged 1 commit into
masterfrom
129-nova-integration

Conversation

@frahlg

@frahlg frahlg commented Apr 19, 2026

Copy link
Copy Markdown
Member

Closes #129.

Summary

  • Adds opt-in internal/nova package that publishes forty-two-watts
    telemetry into Sourceful Nova Core's MQTT ingress with ES256 JWT auth.
  • Adds forty-two-watts nova-claim subcommand for one-time gateway
    registration (POST /gateways/claim) + DER provisioning
    (POST /devices/provision), capturing Nova's der-{uuid7} IDs.
  • New nova: block in config.yaml with validation and sensible defaults.
  • New docs/nova-integration.md covering setup, data mapping, schema
    divergence, 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:

Concept forty-two-watts (clean) Nova legacy wire
Battery charge +W (load) −W
Power / freq w, freq_hz W, Hz
Phase / SoC / temp l1_v, soc, temp_c L1_V, SoC_nom_fract, heatsink_C
PV / EV vocab pv, ev solar, ev_port

internal/nova/adapter.go is a single isolated translator. When novacore
ships the unified schema (follow-up PR), operators flip nova.schema_mode: unified and the adapter is bypassed; once all fleets migrate the file
can be deleted in one go with zero changes to drivers, telemetry store,
or HTTP API.

Key design decisions

  • MQTT, not NATS WebSocket. Matches Nova's gateway ingress (NATS
    MQTT adapter on :1883) and ZAP firmware. NATS WS is for browser clients.
  • JWT-as-password via paho's credentials-provider. Fresh 10-min
    ES256 token on every (re)connect — expired tokens never hit the broker.
  • Clean internal model, adapter at the boundary. Isolates Nova's
    tech debt to one deletable file.
  • DER inference from ts_samples, not new config. nova-claim
    figures out which DER kinds each driver emits by reading the existing
    long-format TS DB — zero additional YAML.
  • Topic sanitization. Any char outside [a-zA-Z0-9_-] in a topic
    segment becomes _ before publish so ep:192.168.1.10:502 doesn't
    create accidental NATS-subject levels.

Identity + topic mapping

  • hardware_id ← state.Device.device_id verbatim (make:serial,
    mac:…, or ep:…).
  • der_name ← {driver_name}-{der_kind} (e.g. ferroamp-battery).
  • der_id ← Nova-generated der-{uuid7}, cached locally in
    state.nova_ders for diagnostics.
  • Gateway identity ← ES256 keypair at cfg.Nova.KeyPath.
  • Topic: gateways/{gw}/devices/{hardware_id}/ders/{der_name}/telemetry/json/v1.

Scope

In scope:

  • Config, keypair, JWT, HTTP client, MQTT publisher, adapter, nova-claim
    CLI, docs, tests (27 new, 477 total passing).

Out of scope (tracked as follow-ups):

  • Lua driver upgrades for lifetime energy counters.
  • Control-topic subscription (Nova → f42w commands).
  • novacore schema-alignment PR (separate repo).
  • Nightly auto-reconcile (today: manual nova-claim --reconcile).

Test plan

  • go test ./... — 477 passing, 27 new
  • go build ./... green
  • End-to-end against a Nova dev instance:
    • forty-two-watts nova-claim --url=… --org=… --site=… --claimer=… succeeds
    • nova_ders rows populated with der-{uuid7} IDs after provision
    • Service restart → publisher connects, topic shows up in Nova with correct payload
    • Battery charging shows −W in Nova dashboard (legacy sign-flip via adapter)
    • Flip schema_mode: unified → adapter bypassed (future; novacore must support it first)

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +172 to +175
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
@frahlg
frahlg force-pushed the 129-nova-integration branch from 24d469f to d93fb92 Compare April 20, 2026 11:33
@frahlg
frahlg merged commit 935bb40 into master Apr 20, 2026
1 check passed
@frahlg
frahlg deleted the 129-nova-integration branch April 20, 2026 12:22
frahlg added a commit that referenced this pull request Sep 25, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nova Core opt-in telemetry integration (MQTT+ES256 JWT)

1 participant