-
Notifications
You must be signed in to change notification settings - Fork 7
fix(drivers): map emit keys onto the names the Nova payload reads #668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+191
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
nova.schema_mode: unifiedis enabled, this unconditional alias mislabels EV charger energy:ctek.lua,ctek_v2.lua, andctek_hybrid.luaemitlifetime_whontype:"ev"readings, but the rewrite populatesDerTelemetry.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 👍 / 👎.