From 9433bdcf4b199b1a7710919c4bf1499f8d6a323b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:33:34 +0200 Subject: [PATCH] registry: cars apart from chargers, their ops and car.which A car belongs to its owner and visits chargers. The registry names the car, its level and goal, the ops that set them, the session a tap names, how the box knows which car is on a charger, and the car.which push. Co-Authored-By: Claude Opus 5.5 --- .changeset/registry-car.md | 5 ++ contract/push-catalogue.yaml | 11 ++++ contract/registry.yaml | 105 ++++++++++++++++++++++++++++++++ src/lib/notify/kinds.ts | 2 + src/lib/protocol/contract.ts | 2 + src/lib/protocol/messages.ts | 10 +++ src/lib/sim/box.ts | 27 +++++++- tests/registry-contract.test.ts | 49 +++++++++++++++ 8 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 .changeset/registry-car.md diff --git a/.changeset/registry-car.md b/.changeset/registry-car.md new file mode 100644 index 0000000..ecd150e --- /dev/null +++ b/.changeset/registry-car.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The registry names cars apart from chargers: the car's fields, where its level comes from, how the box knows which car is on a charger, the car ops and the `car.which` push. A box that sends `car.which` gets its notification toggle; nothing else in the app changes yet. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 4c7f3b9..0c9e8c5 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -86,3 +86,14 @@ events: # anything, including give others access. title: A new owner body: "A phone was paired as an owner of your box. If that was not you, remove it in the app." + - kind: car.which + # When a car is plugged in and the box cannot tell which of its owners' + # cars it is: no charger identity, no word from the car within a minute, + # and more than one car. Once per plug-in. The event names the charger, + # the plug-in's session, and at most four cars not on another charger, + # by opaque id. The phone shows one button per car with the names from + # its own table and a "None of these" button; a tap sends + # session.car.set with the session. With more than four cars it offers + # no buttons and opens FTW. + title: Which car is plugged in? + body: "Tap the car so the box plans for its level and goal." diff --git a/contract/registry.yaml b/contract/registry.yaml index dfa8a85..10941ba 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -116,6 +116,11 @@ capabilities: # answers the agent.* messages and approval.key.set (docs/agents.md in # srcfl/ftw). Absent means the app offers no Access list. - access.agents + # The box keeps cars apart from chargers: each car has its own level, goal + # and sources, and a charger names the car on it (docs/cars.md in + # srcfl/ftw). The box answers the car ops and session.car.set. Absent + # means the charger is the car, as before, and the app draws no car list. + - der.car # --------------------------------------------------------------------------- # Scopes. One object axis and a verb: .. @@ -141,6 +146,8 @@ scopes: - { name: ftw.goals.request, desc: Ask an owner to change a durable goal } # Reserved: no op asks for an update yet, and no role but owner carries it. - { name: ftw.update.request, desc: Ask an owner to install a version } + - { name: ftw.cars.read, desc: See the owner's cars with their VINs and identifiers and sources } + - { name: ftw.cars.write, desc: Keep the owner's list of cars } roles: owner: @@ -218,6 +225,19 @@ ops: - { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery } - { name: loadpoint.soc.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Correct the car's current charge level } - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Charge the car from surplus PV only } + # Cars (docs/cars.md in srcfl/ftw). A car's goal and level are a phone's + # to set, like a charger's today, and a watch makes the one tap. The list + # of cars is the owner's alone: ftw.cars.write is in no role but owner. + # An agent may ask for a car's goal or level as for a charger's. + # The loadpoint ops above stay for one release and act on the car the + # charger names. Arguments follow the box's convention: snake_case, id for + # what the op acts on, a level as a fraction from 0 to 1. + - { name: car.goal.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Set how and when a car charges } + - { name: car.soc.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Set a car's level by hand } + - { name: session.car.set, scope: ftw.dispatch.write, desc: Say which car is on a charger } + - { name: car.add, scope: ftw.cars.write, desc: Add a car to the owner's list } + - { name: car.update, scope: ftw.cars.write, desc: Change what the box knows about a car } + - { name: car.remove, scope: ftw.cars.write, desc: Remove a car from the owner's list } # --------------------------------------------------------------------------- # Dispatch modes. @@ -298,6 +318,10 @@ errors: # An approval whose atMs is too far from the box's wall clock. args.boxMs # is the box's clock, so the phone can sign again with the right time. - { code: E_CLOCK_SKEW, retryable: true, desc: The approval's time is too far from the box's } + # Cars' refusals (docs/cars.md in srcfl/ftw). An unknown car or charger is + # E_NOT_FOUND above. E_DUPLICATE is not E_CONFLICT: asking again with the + # same VIN or identifier gets the same answer. + - { code: E_DUPLICATE, retryable: false, desc: Another car already has that VIN or identifier } # --------------------------------------------------------------------------- # Error codes the client raises for itself. @@ -354,6 +378,7 @@ resolutions: # the words the app shows an owner for the value. cap names the grant's cap # that bounds it as well. kind says how to read the value: # loadpoint the opaque charger id the agent read from loadpoints.get +# car the opaque car id the agent read from cars.get # mode a key in modes # enum one of values # number, power, duration a number in unit @@ -378,6 +403,22 @@ agent_args: loadpoint.surplus_only.set: - { name: id, kind: loadpoint, required: true, label: Charger } - { name: surplus_only, kind: bool, required: true, label: Charge from solar only } + # A car's own ops name the car by id, or the car on a charger by + # charger_id, which is how a visitor's session takes a goal or a level. + # target, ready_by_min, days and finish_at_limit together replace the goal. + car.goal.set: + - { name: id, kind: car, label: Car } + - { name: charger_id, kind: loadpoint, label: Charger } + - { name: target, kind: ratio, min: 0, max: 1, label: The level to reach } + - { name: ready_by_min, kind: number, min: 0, max: 1439, label: Ready by, in minutes after midnight } + - { name: days, kind: number, min: 0, max: 127, label: Weekdays } + - { name: finish_at_limit, kind: bool, label: Charge on to the car's own limit } + - { name: clear, kind: bool, label: Remove the goal } + - { name: surplus_only, kind: bool, label: Charge from solar only } + car.soc.set: + - { name: id, kind: car, label: Car } + - { name: charger_id, kind: loadpoint, label: Charger } + - { name: soc, kind: ratio, required: true, min: 0, max: 1, label: The car's charge level } # --------------------------------------------------------------------------- # Reads an agent may send, and the scope each needs (docs/agents.md in @@ -399,3 +440,67 @@ reads: - { msg: logs.get, scope: ftw.logs.read, desc: A log excerpt the box redacts } - { msg: config.get, scope: ftw.config.read, desc: A configuration section the box redacts } - { msg: diag.run, scope: ftw.diag.run, desc: A check that changes nothing } + +# --------------------------------------------------------------------------- +# Cars. A car belongs to its owner, not to the home, and visits chargers. The +# box keeps the cars of its owners, so local operation needs no cloud. Until +# owners are principals of their own, every phone with the owner role shares +# one list. A charging session is one car on one charger from plug-in to +# unplug. docs/cars.md in srcfl/ftw is the wire and the rules. +# +# car_fields are a car's names on the wire. A name with a dot is a field of +# the object before the dot. kind is the value's type and unit what a number +# counts. The box's HTTP routes spell each name in snake_case; the ops' +# arguments are in docs/cars.md. Ages count from when the box sent them, +# never from its wall clock. FTW Cloud's car class carries the level and the +# goal and nothing that names the car. +# --------------------------------------------------------------------------- +car_fields: + - { name: id, kind: text, unit: null, desc: 128 bits as 32 lowercase hex digits that the owner's app mints and every box keeps } + - { name: boxMinted, kind: bool, unit: null, desc: The box minted the id when it made the car from its old settings } + - { name: ownerId, kind: text, unit: null, desc: Reserved for the owner the car belongs to. Empty until owners are principals } + - { name: name, kind: text, unit: null, desc: The owner's name for the car } + - { name: make, kind: text, unit: null, desc: The maker when a source or the owner gave one } + - { name: vin, kind: text, unit: null, desc: The vehicle identification number that binds a source to the car } + - { name: capacityWh, kind: energy, unit: Wh, desc: Usable battery capacity } + - { name: maxW, kind: power, unit: W, desc: The most the car's own charger takes. 0 when unknown } + - { name: phases, kind: count, unit: null, desc: The phases the car's own charger uses. 0 when unknown } + - { name: socPermille, kind: ratio, unit: permille, desc: The level. The car's own number while socEstimate is false. Absent when the box has none } + - { name: socSource, kind: enum, unit: null, desc: Where the level comes from. One of car_soc_sources } + - { name: socAgeS, kind: age, unit: s, desc: Seconds since the car reported the level or someone set it } + - { name: energySinceWh, kind: energy, unit: Wh, desc: Energy delivered to the car since then. Negative when a V2X session took energy out } + - { name: socEstimate, kind: bool, unit: null, desc: The box counts the level as an estimate } + - { name: chargeLimitPermille, kind: ratio, unit: permille, desc: The charge limit the car reported with its level } + - { name: goal, kind: object, unit: null, desc: What the car should reach and when. Absent when none } + - { name: goal.targetPermille, kind: ratio, unit: permille, desc: The level to reach } + - { name: goal.readyByMin, kind: minutes, unit: min, desc: Ready time in minutes after midnight in the box's time zone } + - { name: goal.days, kind: mask, unit: null, desc: ISO weekdays with Monday in bit 0. 0 is once at the next ready time and 127 every day } + - { name: goal.finishAtLimit, kind: bool, unit: null, desc: Charge on to the car's own limit rather than to targetPermille } + - { name: surplusOnly, kind: bool, unit: null, desc: The car charges from surplus PV only wherever it is plugged in } + - { name: identifiers, kind: text_list, unit: null, desc: RFID tags and MAC addresses and eMAIDs that name the car to a charger } + - { name: sources, kind: text_list, unit: null, desc: The vehicle drivers on this box that report the car's level. Each box has its own } + +# Where a level comes from, best first after vehicle. A newer reading from +# the car beats a level someone set. assumed is a known car's plug-in default +# and never a visitor's: a car the box cannot name gets no level at all. A +# level read before the car last left a charger is an estimate. +car_soc_sources: [vehicle, manual, assumed] + +# How the box knows which car is on a charger. charger is the charger's own +# word (OCPP idTag or MAC or eMAID). car is the car's own source. only is the +# one car the box knows. tap is someone choosing. asking means the box raised +# car.which and waits: a tap or the charger or the car ends it, and so does +# the unplug. none is a visitor: no rule answered, the charger named a car the +# box does not know, the only car said it is not here, or a tap said none of +# these. The order of the rules is in docs/cars.md. +car_identified_by: [charger, car, only, tap, asking, none] + +# What a charger carries about its car, beside its own fields. Empty while +# nothing is plugged in. +charger_car_fields: + - { name: session, kind: text, unit: null, desc: This plug-in's id. A tap names it and is refused once it has changed } + - { name: carId, kind: text, unit: null, desc: The car on the charger. Empty when the box does not know which } + - { name: carIdentifiedBy, kind: enum, unit: null, desc: How the box knows. One of car_identified_by } + - { name: carCorrected, kind: bool, unit: null, desc: A better rule replaced the car the box first named } + - { name: carCorrectedFrom, kind: text, unit: null, desc: The car the box named before that } + - { name: choose, kind: text_list, unit: null, desc: While asking the cars a tap may pick. At most four } diff --git a/src/lib/notify/kinds.ts b/src/lib/notify/kinds.ts index 24e1b31..3f18afa 100644 --- a/src/lib/notify/kinds.ts +++ b/src/lib/notify/kinds.ts @@ -24,6 +24,7 @@ export const KINDS = [ 'agent.expiring', 'approval.key.set', 'owner.added', + 'car.which', ] as const export type PushKind = (typeof KINDS)[number] @@ -56,4 +57,5 @@ export const KIND_LABELS: Record = { 'agent.expiring': "When an assistant's or Sourceful's access is about to end", 'approval.key.set': 'When a phone sets up the key that approves access', 'owner.added': 'When a phone is paired as an owner', + 'car.which': 'When the box asks which car is plugged in', } diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts index d27c4d5..7b7069b 100644 --- a/src/lib/protocol/contract.ts +++ b/src/lib/protocol/contract.ts @@ -34,6 +34,8 @@ export const SCOPES = [ 'ftw.dispatch.hold', 'ftw.goals.request', 'ftw.update.request', + 'ftw.cars.read', + 'ftw.cars.write', ] as const export type Scope = (typeof SCOPES)[number] diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index 7ada730..0a7ee9c 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -489,6 +489,13 @@ export const OP_LOADPOINT_HOLD = 'loadpoint.hold' export const OP_LOADPOINT_BOOST = 'loadpoint.boost' export const OP_LOADPOINT_SOC_SET = 'loadpoint.soc.set' export const OP_LOADPOINT_SURPLUS_ONLY_SET = 'loadpoint.surplus_only.set' +/** Cars (docs/cars.md in srcfl/ftw). A box offers them with `der.car`. */ +export const OP_CAR_GOAL_SET = 'car.goal.set' +export const OP_CAR_SOC_SET = 'car.soc.set' +export const OP_SESSION_CAR_SET = 'session.car.set' +export const OP_CAR_ADD = 'car.add' +export const OP_CAR_UPDATE = 'car.update' +export const OP_CAR_REMOVE = 'car.remove' export interface Guard { fid: Fid @@ -587,6 +594,9 @@ const RETRYABLE: Record = { // The approval's atMs is too far from the box's clock; the phone signs // again with args.boxMs, so asking again can succeed. E_CLOCK_SKEW: true, + // Cars' refusals (docs/cars.md in srcfl/ftw): asking again gets the same + // answer. E_NOT_FOUND is above. + E_DUPLICATE: false, // Not from the box. The session layer raises this one itself out of // api.end{truncated: true} — see contract/registry.yaml, client_errors. E_RESPONSE_TOO_LARGE: false, diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index 8d331de..f5f3a64 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -47,6 +47,12 @@ import { OP_LOADPOINT_BOOST, OP_LOADPOINT_SOC_SET, OP_LOADPOINT_SURPLUS_ONLY_SET, + OP_CAR_GOAL_SET, + OP_CAR_SOC_SET, + OP_SESSION_CAR_SET, + OP_CAR_ADD, + OP_CAR_UPDATE, + OP_CAR_REMOVE, carriesOverSession, isRetryable, } from '$lib/protocol/messages' @@ -241,8 +247,27 @@ const OP_SCOPES: Record = { [OP_LOADPOINT_BOOST]: 'ftw.dispatch.write', [OP_LOADPOINT_SOC_SET]: 'ftw.dispatch.write', [OP_LOADPOINT_SURPLUS_ONLY_SET]: 'ftw.dispatch.write', + [OP_CAR_GOAL_SET]: 'ftw.dispatch.write', + [OP_CAR_SOC_SET]: 'ftw.dispatch.write', + [OP_SESSION_CAR_SET]: 'ftw.dispatch.write', + [OP_CAR_ADD]: 'ftw.cars.write', + [OP_CAR_UPDATE]: 'ftw.cars.write', + [OP_CAR_REMOVE]: 'ftw.cars.write', } +/** + * The car ops. The simulated box keeps no cars and does not claim der.car, + * so it answers them as a box from before cars does. + */ +const CAR_OPS: readonly string[] = [ + OP_CAR_GOAL_SET, + OP_CAR_SOC_SET, + OP_SESSION_CAR_SET, + OP_CAR_ADD, + OP_CAR_UPDATE, + OP_CAR_REMOVE, +] + const CAPS = [ 'status.core', 'status.phases', @@ -714,7 +739,7 @@ export class SimBox { // An op the box has never heard of is refused by name, exactly as the // box refuses it — falling through to some default behaviour would let // an app typo pass every test here and fail against every real box. - if (!(cmd.op in OP_SCOPES)) { + if (!(cmd.op in OP_SCOPES) || CAR_OPS.includes(cmd.op)) { this.#cmdResult(cmd.cmdId, 'rejected', { code: 'E_UNKNOWN_OP', args: { op: cmd.op } }) return } diff --git a/tests/registry-contract.test.ts b/tests/registry-contract.test.ts index a59f4c4..7be8c6c 100644 --- a/tests/registry-contract.test.ts +++ b/tests/registry-contract.test.ts @@ -315,6 +315,55 @@ describe('frozen field ids', () => { }) }) +describe('cars', () => { + /** + * The car's names are the box's and the phone's before this app draws a + * car, so the check here is the block's shape: one reading per line, one + * name per meaning, and a dotted name only under an object the block + * declares. Box and phone read the same lines back against their structs. + */ + const fields = (name: string) => + block(name).map((line) => { + const m = /^ {2}- \{ name: ([a-zA-Z.]+), kind: ([a-z_]+), unit: (\S+), desc: [^,:]+ \}$/.exec( + line + ) + expect(m, `unreadable ${name} line: ${line}`).not.toBeNull() + return { name: m![1]!, kind: m![2]! } + }) + + /** An inline list such as `car_soc_sources: [vehicle, manual]`. */ + const list = (name: string) => { + const m = new RegExp(`^${name}: \\[([a-z, ]+)\\]$`, 'm').exec(registry) + expect(m, `contract/registry.yaml has no ${name}: [ … ] line`).not.toBeNull() + return m![1]!.split(',').map((s) => s.trim()) + } + + it('name each car and charger field once, nested only under an object', () => { + for (const block of ['car_fields', 'charger_car_fields']) { + const declared = fields(block) + expect(declared.length, `no ${block} were found to check`).toBeGreaterThan(0) + const names = declared.map((f) => f.name) + expect(new Set(names).size, `a name in ${block} is declared twice`).toBe(names.length) + for (const { name } of declared) { + const parent = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : null + if (parent === null) continue + expect( + declared.find((f) => f.name === parent)?.kind, + `${name} sits under ${parent}, which is not an object in ${block}` + ).toBe('object') + } + } + }) + + it('give each enum a list of its own', () => { + for (const name of ['car_soc_sources', 'car_identified_by']) { + const values = list(name) + expect(values.length, `${name} is empty`).toBeGreaterThan(0) + expect(new Set(values).size, `${name} repeats a value`).toBe(values.length) + } + }) +}) + describe('capabilities the app gates on', () => { /** * Every gate names its capability in one `CAP_` constant beside a comment