Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/normalize-telemetry-keys.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions go/internal/drivers/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
87 changes: 87 additions & 0 deletions go/internal/drivers/telemetry_keys.go
Original file line number Diff line number Diff line change
@@ -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",

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 Restrict the lifetime-energy alias to PV telemetry

When nova.schema_mode: unified is enabled, this unconditional alias mislabels EV charger energy: ctek.lua, ctek_v2.lua, and ctek_hybrid.lua emit lifetime_wh on type:"ev" readings, but the rewrite populates DerTelemetry.TotalGenerationWh, which is a PV-only field, and unified encoding serializes it verbatim. Those EV reports therefore advertise lifetime consumed energy as PV generation; inspect the reading type before applying this alias or introduce an EV-specific destination.

Useful? React with 👍 / 👎.

}

// 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
}
94 changes: 94 additions & 0 deletions go/internal/drivers/telemetry_keys_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}