From 758470dfd5bc7cb76718adab9e68decd230eac7b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 18:31:43 +0200 Subject: [PATCH] feat(contract): the grid's phase currents and the main fuse as extra fields Field ids 11 to 13 carry the site meter's current on L1, L2 and L3 in tenths of an ampere, signed by the site convention, and 14 the main fuse per phase as their scale. They sit in a new extra_fields block after the frozen set: depth for a node's own view, not the core view. A test keeps their ids and names apart from the frozen set and above it. Co-Authored-By: Claude Opus 5.5 --- .changeset/grid-phase-fields.md | 5 +++++ contract/registry.yaml | 23 +++++++++++++++++++++++ docs/protocol.md | 6 ++++++ tests/registry-contract.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 .changeset/grid-phase-fields.md diff --git a/.changeset/grid-phase-fields.md b/.changeset/grid-phase-fields.md new file mode 100644 index 0000000..26510a2 --- /dev/null +++ b/.changeset/grid-phase-fields.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Name the grid's three phase currents and the main fuse in the shared contract, as field ids 11 to 14 after the frozen set. The box sends them for a node's own view; this app does not draw them yet. diff --git a/contract/registry.yaml b/contract/registry.yaml index 18f3cd5..0cfdaec 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -49,6 +49,29 @@ frozen_fields: # by the site convention: the charger consumes like any other load. 10: { name: ev_w, kind: power, unit: W } +# --------------------------------------------------------------------------- +# Field ids after the frozen set. They travel like the frozen ids, in lane +# 0's delta and in FTW Cloud's live body, under the same rules: integers, +# sent only while the box has the reading, and an id never changes meaning. +# They are depth for a node's own view, not the core view, so an app draws +# Now without them and a box may stop sending one. Lane 0 drops the highest +# id first when a delta does not fit its bucket, so these give way first. +# +# The grid's phase currents come from the site meter in tenths of an ampere, +# signed by the site convention: positive while the phase draws from the +# grid. A meter that gives magnitudes and no power per phase sends positive +# values both ways, so a positive current alone does not prove import. The +# box sends only the phases the site has, each while the meter is online and +# reports it, and their freshness is the grid meter's. grid_fuse_da is the +# main fuse per phase from the box's config, sent with the phases as their +# scale. +# --------------------------------------------------------------------------- +extra_fields: + 11: { name: grid_l1_da, kind: current, unit: dA } + 12: { name: grid_l2_da, kind: current, unit: dA } + 13: { name: grid_l3_da, kind: current, unit: dA } + 14: { name: grid_fuse_da, kind: current, unit: dA } + # --------------------------------------------------------------------------- # Capabilities. A set of names, not version numbers. Absent means the box does # not have it; the UI hides the feature and never crashes. diff --git a/docs/protocol.md b/docs/protocol.md index 48e09ce..f11a741 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -126,6 +126,12 @@ battery power, battery state of charge, load power, and their source ids. Any app, however old, can draw the core view. Freezing them now is free; doing it later is not. +Ids after the frozen set live in the registry's `extra_fields`: depth for a +node's own view, such as the grid's phase currents and the main fuse. They +travel in the same `delta` and follow the same rules, but an app draws the +core view without them and a box may stop sending one. A delta that does not +fit drops the highest id first, so they give way before the core view. + ## Freshness Every reading points at a **source**, not at its own timestamp: diff --git a/tests/registry-contract.test.ts b/tests/registry-contract.test.ts index 87484bc..5284125 100644 --- a/tests/registry-contract.test.ts +++ b/tests/registry-contract.test.ts @@ -283,6 +283,36 @@ describe('frozen field ids', () => { const table = Object.entries(FID).map(([name, id]) => `${id}: ${name.toLowerCase()}`) expect(table.sort()).toEqual(declared.sort()) }) + + /** + * extra_fields share the frozen set's id space on the wire: a delta and + * FTW Cloud's live body key both by id alone. An id or a name in both + * blocks would be two meanings for one number. They also sit above the + * frozen set, because lane 0 drops the highest id first when a delta does + * not fit, and the core view must be the last thing to go. + */ + it('leave the extra fields above the frozen set, with ids and names of their own', () => { + const entries = (name: string) => + block(name).map((line) => { + const m = /^ {2}(\d+): \{ name: ([a-z0-9_]+),/.exec(line) + expect(m, `unreadable ${name} line: ${line}`).not.toBeNull() + return { id: Number(m![1]), name: m![2]! } + }) + const frozen = entries('frozen_fields') + const extra = entries('extra_fields') + expect(extra.length, 'no extra fields were found to check').toBeGreaterThan(0) + + const highestFrozen = Math.max(...frozen.map((f) => f.id)) + const ids = new Set(frozen.map((f) => f.id)) + const names = new Set(frozen.map((f) => f.name)) + for (const f of extra) { + expect(f.id, `${f.name} sits among the frozen ids`).toBeGreaterThan(highestFrozen) + expect(ids.has(f.id), `field id ${f.id} is declared twice`).toBe(false) + expect(names.has(f.name), `field name ${f.name} is declared twice`).toBe(false) + ids.add(f.id) + names.add(f.name) + } + }) }) describe('capabilities the app gates on', () => {