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', () => {