From 41cf5f2b107d11e26d9b437a6065d1f3815c0a5c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 25 Jul 2026 18:37:55 +0200 Subject: [PATCH] fix(drivers): map emit keys onto the names the Nova payload reads nova.DerTelemetry unmarshals a driver's emit blob directly, so a key it does not recognise is dropped without an error and the value never leaves the gateway. Catalog drivers emit import_wh, export_wh, charge_wh, discharge_wh, lifetime_wh and hz. DerTelemetry reads total_import_wh, total_export_wh, total_charge_wh, total_discharge_wh, total_generation_wh and freq_hz. None of those six has ever reached Nova from a catalog driver. Our own zap.lua works around it by emitting both names for every affected field, which is why the gap went unnoticed. Normalise at the emit boundary instead, so every driver is fixed at once. The canonical @srcful/data-models spellings map onto the same internal names, so srcfl/device-drivers can convert its catalog without a further host change. An alias never overwrites a key the driver set under the target name, and the driver's original keys are kept because the UI and API surface a reading verbatim. A parse failure returns the input untouched rather than losing a reading. Signed-off-by: Fredrik Ahlgren --- .changeset/normalize-telemetry-keys.md | 5 ++ go/internal/drivers/host.go | 5 ++ go/internal/drivers/telemetry_keys.go | 87 ++++++++++++++++++++ go/internal/drivers/telemetry_keys_test.go | 94 ++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 .changeset/normalize-telemetry-keys.md create mode 100644 go/internal/drivers/telemetry_keys.go create mode 100644 go/internal/drivers/telemetry_keys_test.go diff --git a/.changeset/normalize-telemetry-keys.md b/.changeset/normalize-telemetry-keys.md new file mode 100644 index 00000000..44fd9d0d --- /dev/null +++ b/.changeset/normalize-telemetry-keys.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Map driver emit keys onto the names the Nova payload reads. Catalog drivers emit `import_wh`, `export_wh`, `charge_wh`, `discharge_wh`, `lifetime_wh` and `hz`, while `DerTelemetry` reads `total_import_wh`, `total_export_wh`, `total_charge_wh`, `total_discharge_wh`, `total_generation_wh` and `freq_hz` — so those values never left the gateway. The canonical `@srcful/data-models` spellings map onto the same names, so the driver catalog can convert without a further host change. diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 11714122..fc397d6a 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -495,6 +495,11 @@ func (h *HostEnv) emitTelemetry(rawJSON []byte) error { if err := telemetry.ValidateReading(t, rawW, soc); err != nil { return fmt.Errorf("emit_telemetry: %w", err) } + // Rewrite alias keys onto the names the Nova payload reads, before the + // blob is buffered or stored. Without this a driver's energy counters and + // frequency never leave the gateway. + rawJSON = normalizeTelemetryKeys(rawJSON) + if h.bufferPollTelemetry(rawJSON) { return nil } diff --git a/go/internal/drivers/telemetry_keys.go b/go/internal/drivers/telemetry_keys.go new file mode 100644 index 00000000..b5b7a826 --- /dev/null +++ b/go/internal/drivers/telemetry_keys.go @@ -0,0 +1,87 @@ +package drivers + +import "encoding/json" + +// Driver emit tables, the Nova payload and @srcful/data-models grew three +// different spellings for the same measurement. nova.DerTelemetry unmarshals +// the driver's emit blob directly, so a key it does not recognise is dropped +// without an error and the value never leaves the gateway. +// +// That is not hypothetical. Catalog drivers emit import_wh, export_wh, +// charge_wh, discharge_wh, lifetime_wh and hz, while DerTelemetry reads +// total_import_wh, total_export_wh, total_charge_wh, total_discharge_wh, +// total_generation_wh and freq_hz. FTW's own zap.lua works around it by +// emitting both names for every such field. +// +// Normalising here fixes that for every driver at once, and lets +// srcfl/device-drivers move to the canonical @srcful/data-models spellings +// without a second round of host changes. +// +// The target name is the one nova.DerTelemetry reads. An alias never +// overwrites a key the driver already set under the target name. +var telemetryKeyAliases = map[string]string{ + // Canonical @srcful/data-models spellings. + "W": "w", + "Hz": "freq_hz", + "SoC_nom_fract": "soc", + "temperature_C": "temp_c", + "total_import_Wh": "total_import_wh", + "total_export_Wh": "total_export_wh", + "total_charge_Wh": "total_charge_wh", + "total_discharge_Wh": "total_discharge_wh", + "total_generation_Wh": "total_generation_wh", + "L1_V": "l1_v", + "L2_V": "l2_v", + "L3_V": "l3_v", + "L1_A": "l1_a", + "L2_A": "l2_a", + "L3_A": "l3_a", + "L1_W": "l1_w", + "L2_W": "l2_w", + "L3_W": "l3_w", + + // Shorter spellings the catalog grew, which DerTelemetry never read. + "hz": "freq_hz", + "import_wh": "total_import_wh", + "export_wh": "total_export_wh", + "charge_wh": "total_charge_wh", + "discharge_wh": "total_discharge_wh", + "lifetime_wh": "total_generation_wh", +} + +// normalizeTelemetryKeys rewrites known aliases onto the names the Nova +// payload reads. The original keys are kept: the UI and API surface a +// reading's data verbatim, and removing a key a driver emitted would change +// what an operator sees. +// +// On any parse problem the input is returned untouched; emitTelemetry has +// already validated the envelope by this point and a normalisation failure +// must never lose a reading. +func normalizeTelemetryKeys(raw []byte) []byte { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return raw + } + + changed := false + for alias, target := range telemetryKeyAliases { + value, ok := fields[alias] + if !ok { + continue + } + if _, taken := fields[target]; taken { + continue // the driver set the target name itself; it wins + } + fields[target] = value + changed = true + } + if !changed { + return raw + } + + out, err := json.Marshal(fields) + if err != nil { + return raw + } + return out +} diff --git a/go/internal/drivers/telemetry_keys_test.go b/go/internal/drivers/telemetry_keys_test.go new file mode 100644 index 00000000..fc9d6b2a --- /dev/null +++ b/go/internal/drivers/telemetry_keys_test.go @@ -0,0 +1,94 @@ +package drivers + +import ( + "encoding/json" + "testing" +) + +func decode(t *testing.T, raw []byte) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// The catalog's spellings were silently dropped by nova.DerTelemetry, so a +// site running a catalog driver reported no energy counters and no frequency. +func TestCatalogSpellingsReachTheNovaNames(t *testing.T) { + in := []byte(`{"type":"meter","w":1000,"hz":50.1,"import_wh":4500,"export_wh":120}`) + got := decode(t, normalizeTelemetryKeys(in)) + + for name, want := range map[string]float64{ + "freq_hz": 50.1, + "total_import_wh": 4500, + "total_export_wh": 120, + } { + if got[name] != want { + t.Errorf("%s: got %v, want %v", name, got[name], want) + } + } + // The driver's own keys stay, because the UI shows a reading verbatim. + if got["hz"] != 50.1 || got["import_wh"] != float64(4500) { + t.Error("original keys must be preserved") + } +} + +func TestBatteryAndPvCountersReachTheNovaNames(t *testing.T) { + in := []byte(`{"type":"battery","w":-500,"charge_wh":1200,"discharge_wh":900}`) + got := decode(t, normalizeTelemetryKeys(in)) + if got["total_charge_wh"] != float64(1200) || got["total_discharge_wh"] != float64(900) { + t.Errorf("battery counters not mapped: %v", got) + } + + in = []byte(`{"type":"pv","w":-2000,"lifetime_wh":30000}`) + got = decode(t, normalizeTelemetryKeys(in)) + if got["total_generation_wh"] != float64(30000) { + t.Errorf("lifetime_wh not mapped: %v", got) + } +} + +// srcfl/device-drivers is converting to these; they must land on the same +// internal names so no second host change is needed. +func TestCanonicalSpellingsReachTheNovaNames(t *testing.T) { + in := []byte(`{"type":"meter","W":1234,"Hz":50,"L1_V":230.5,"total_import_Wh":99}`) + got := decode(t, normalizeTelemetryKeys(in)) + + for name, want := range map[string]float64{ + "w": 1234, + "freq_hz": 50, + "l1_v": 230.5, + "total_import_wh": 99, + } { + if got[name] != want { + t.Errorf("%s: got %v, want %v", name, got[name], want) + } + } +} + +// A driver that already sets the target name must keep its own value; the +// alias must never overwrite it. +func TestExistingTargetKeyWins(t *testing.T) { + in := []byte(`{"type":"meter","w":1,"import_wh":5,"total_import_wh":7}`) + got := decode(t, normalizeTelemetryKeys(in)) + if got["total_import_wh"] != float64(7) { + t.Errorf("alias overwrote the driver's own value: %v", got["total_import_wh"]) + } +} + +// FTW's own drivers emit both names already. Normalising must be a no-op for +// them rather than reordering or dropping anything. +func TestBothNamesPresentIsUnchanged(t *testing.T) { + in := []byte(`{"type":"meter","w":1,"import_wh":5,"total_import_wh":5,"hz":50,"freq_hz":50}`) + if string(normalizeTelemetryKeys(in)) != string(in) { + t.Error("payload that already carries both names should pass through untouched") + } +} + +func TestMalformedInputIsReturnedUnchanged(t *testing.T) { + in := []byte(`not json`) + if string(normalizeTelemetryKeys(in)) != string(in) { + t.Error("a parse failure must never lose the reading") + } +}