From 1bc27b9f1cf4f744017a8dd46dd26117e98a5030 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:35:58 +0200 Subject: [PATCH 1/2] feat(contract): cars apart from chargers A car belongs to its owner and visits chargers. The registry copy 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 car.which. The generator emits the car names for Go; go/protocol carries Car, CarGoal and ChargerCar; FTW Cloud's wire gains the car class, the charger's car fields and car ids cut per grant. docs/cars.md is the contract. Co-Authored-By: Claude Opus 5.5 --- .changeset/car-contract.md | 5 + contract/push-catalogue.yaml | 11 + contract/registry.yaml | 105 +++++++ docs/cars.md | 285 ++++++++++++++++++ docs/protocol-cloud.md | 77 ++++- go/internal/apiauth/contract_gen.go | 2 + .../appproto/gencontract/gencontract.go | 47 +++ go/protocol/cars.go | 134 ++++++++ go/protocol/cars_test.go | 90 ++++++ go/protocol/cloudwire/allowlist.go | 11 +- go/protocol/cloudwire/allowlist_test.go | 28 ++ go/protocol/cloudwire/messages.go | 44 +++ go/protocol/cloudwire/push.go | 3 + .../cloudwire/testdata/cloud-vectors.json | 50 +++ go/protocol/cloudwire/vectors_test.go | 57 +++- go/protocol/cloudwire/wire.go | 7 + go/protocol/contract_gen.go | 96 ++++++ go/protocol/messages.go | 22 ++ go/protocol/push_gen.go | 2 + 19 files changed, 1063 insertions(+), 13 deletions(-) create mode 100644 .changeset/car-contract.md create mode 100644 docs/cars.md create mode 100644 go/protocol/cars.go create mode 100644 go/protocol/cars_test.go diff --git a/.changeset/car-contract.md b/.changeset/car-contract.md new file mode 100644 index 000000000..2aec165ee --- /dev/null +++ b/.changeset/car-contract.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The contract 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, the `car.which` push and FTW Cloud's `car` class with an opaque car id. docs/cars.md describes them. The box does not keep cars yet. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 4c7f3b9f5..0c9e8c5ad 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 dfa8a8521..10941bada 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/docs/cars.md b/docs/cars.md new file mode 100644 index 000000000..85b758630 --- /dev/null +++ b/docs/cars.md @@ -0,0 +1,285 @@ +# Cars + +A car belongs to its owner, not to the home. It visits chargers. The box +keeps the cars of its owners, so it needs no cloud to know them. + +This is the contract. A box that lists `der.car` in `hello_ok.caps` keeps +cars as below; one without it treats each charger as its car, as before. + +| Thing | What it is | What it owns | +|---|---|---| +| Car | A DER whose owner is a person | Id, name, make, VIN, capacity, its own charger's limits, level, goal, surplus-only, identifiers, level sources | +| Charger | A loadpoint at the site | Plug, power, phases, limits, charge now, boost, the site's battery rule | +| Charging session | One car on one charger, plug-in to unplug | Its id, the car or none, how the box knows it, energy delivered to that car | + +The names are in `contract/registry.yaml`: `car_fields`, +`car_soc_sources`, `car_identified_by`, `charger_car_fields`, the `der.car` +capability, the scopes `ftw.cars.read` and `ftw.cars.write`, the car ops, +the refusals `E_NOT_FOUND` and `E_DUPLICATE`, and the `car.which` push kind. +The Go types are in [`go/protocol/cars.go`](../go/protocol/cars.go). + +## Owners and ids + +- Until owners are principals of their own, every phone with the owner role + shares one list of cars. `ownerId` is reserved for the owner a car belongs + to and stays empty until then. +- A car's id is 128 random bits as 32 lowercase hex digits. **The owner's app + mints it**, and `car.add` requires it, so the car is the same on each box + the owner has. Adding an id the box already has updates that car. +- The box mints ids only when it makes cars from its old settings, and marks + those cars `boxMinted`. The app adopts such a car by sending + `car.update` with `new_id`, or by adding a car with the same VIN. +- A VIN or an identifier that another car already has is refused with + `E_DUPLICATE`. +- A removed car leaves a tombstone: `GET /api/cars` lists its id under + `removed`, so an app copying lists between boxes does not bring it back. + Adding the id again is the owner's word and brings it back. +- The app reads the box's list after each hello and copies a new or changed + car to each of the owner's boxes. Every field travels except `sources`, + which are the box's own: Home Assistant at home, the maker's service + anywhere. A source is bound to a car by VIN, or by choice on that box. + +## Who sees what + +| Read | Who | What | +|---|---|---| +| `GET /api/cars` with `ftw.cars.read` | The owner role | Every field | +| `GET /api/cars` without it | Viewers and watches | `id`, `name`, the level fields, `goal`, `surplusOnly` | +| FTW Cloud's `car` class | Phones with `ftw.live.read` | Opaque `id`, the level fields, `goal`, `surplusOnly` | + +`car.add`, `car.update` and `car.remove` need `ftw.cars.write`, which only +the owner role carries. They change no hardware, so they need no passkey +step-up. No car op waits for the dispatch gate: a stale meter must not stop +the tap that names a car. + +## The level + +A car's level is its last reading plus the energy delivered to that car +since, × 0.9 / capacity. + +- **Sources** (`socSource`): `vehicle` is a reading from the car, from a + vehicle driver or OCPP 2.0.1 `NotifyEVChargingNeeds`. `manual` is a level + someone set. `assumed` is a known car's plug-in default. +- **A newer reading from the car beats a level someone set.** An older one + does not. +- **A reading stays exact until energy flows.** The box marks the level as an + estimate (`socEstimate`) once more than 1 kWh has reached the car since the + reading. Time alone does not age it: 14 % with nothing delivered is still + 14 % hours later. +- **While the level is exact, `socPermille` is the car's own number** (or + the person's). A number the car never said is never shown as the car's. + The planner starts from the same number. +- **A level read before the car last left a charger is an estimate**, and it + does not start the next session: the car may have driven. Nor does a + reading more than an hour older than the plug-in. That session starts + from the car's plug-in assumption, `assumed`, until the car reports again, + as every session did before cars. +- An unplug counts once the charger has said so for 30 seconds, or names a + new session. A cable that flaps for one reading keeps the session and its + level. +- An assumed level is always an estimate. +- `socAgeS` is the seconds since the car reported the level or someone set + it, counted at send, never from the box's wall clock. `energySinceWh` is + what has flowed since; a V2X session makes it negative. +- An app can say "14 % · from the car, 9 min ago" or + "≈ 22 % · from the car 16:31 + 6 kWh". +- **A visitor gets no level.** A car the box cannot name has none, and the + box never guesses one. +- A car's charging state is live for five minutes (`VehicleMaxAge`). Only a + live state wakes the car or tells the box a goal is done. + +## Which car is on a charger + +At each plug-in the box asks, in this order (`carIdentifiedBy`): + +1. `charger`: the charger names the car by OCPP idTag, MAC or eMAID, and a + car lists that identifier. An identifier no car lists makes the session a + visitor's (`none`) only when some car lists identifiers: the charger has + then said it is another car. While no car lists any, the identifier is no + answer and the box goes on to rule 2. An OCPP charger sends an idTag at + every start, often the same one for every car. +2. `car`: one of the car's sources saw its cable go in within a minute of + the plug-in and does not say it is elsewhere (`at_site` false), or says + it is charging at this site (`at_site` true) while this charger alone + delivers. A source says so with `plugged` and `at_site` in its `vehicle` + emit, or with a charging state that needs a cable. Two cars saying so is + no answer. +3. `only`: exactly one car could be here: the box knows one car that is not + on another charger, and it has not said it is somewhere else. The car's + usual charger counts the same way: a car the box made from a charger's + old settings has that charger as its usual one, and a plug-in there names + it when no other car is on it as its usual charger and the car is not on + another charger. If the car's source says, within a minute of the + plug-in, `at_site` false or that its cable is out, the session is a + visitor's (`none`). One car is on one charger at a time: rules 2 and 3 + skip a car another session has. +4. `tap`, after `asking`: with more than one car, the box waits a minute for + rules 1 and 2, then raises `car.which` once per plug-in and says + `asking`. A tap sends `session.car.set`. A tap, the charger or the car + ends `asking`, and so does the unplug. +5. `none`: a visitor. A tap that picks none of the cars gives `none` too. + +- The choice lives in Core and ends at unplug. It survives a restart when + the charger's own session id, or its energy counter, shows the same + plug-in. Until the charger has reported after a restart, the box keeps the + session as it was. +- The charger's word counts from each transaction's start, and it may come + before the box has seen the plug-in; it then waits up to a minute for it. +- **A tap names the plug-in.** `session` is the plug-in's id; a tap sends + it, and the box refuses a tap for a plug-in that has ended + (`E_PRECONDITION`, reason `session`). +- **A later, better rule may correct an automatic choice**, as when the car + reports in after the box assumed the only car. Only the charger or the car + corrects, never by a worse rule, and only with evidence newer than the + choice. The box then sets `carCorrected` and `carCorrectedFrom` for the + rest of the session, and logs it. +- **A tap is final once the box had answered by itself**: someone who taps + over the charger's, the car's or the only car's answer knows better. A tap + made while the box was asking can still be corrected by the charger or the + car. A later tap replaces an earlier one. +- **Moving a session to another car moves its energy.** The new car gets the + energy delivered since its own reading or since the plug-in, whichever is + later. The car it had goes back to its level from before the session, or + keeps a reading its own source made during it. The box plans again. + +## The plan + +The plan is made per session, from the session's car: its level, its +capacity, its goal and its surplus-only choice. Planned power is held to the +lower of the charger's maximum and the car's own (`maxW`). The charge limit +the car reports caps the plan only while the level is the car's own and +still exact. `phases` is the owner's note for now: the controller follows +the charger's phase mode. + +- **One goal per car**, used wherever the car is plugged in. `readyByMin` is + in the box's time zone. +- **Surplus-only is the car's choice.** The site's battery rule + (`surplus_unlock_bat_soc`) is the charger's: it rides + `PUT /api/loadpoints/{id}/schedule` as before, and no car op carries it. +- **A session whose car is unknown** (`asking` or `none`) has a goal, a level + and a surplus choice of its own, until unplug. Its capacity is the + charger's `vehicle_capacity_wh`, so energy delivered still counts toward a + goal, though the box shows no level. `car.goal.set` and + `car.soc.set` with `charger_id` set them, and so do the charger routes; + neither ever touches a car. Until then the car charges from surplus only, + with no level. A goal set for it plans from empty, and the car stops when + it is full. The design's "or the cheapest quarters" without a goal is not + built: with neither a level nor a goal there is no amount of energy to + place. + +## Ops + +All are `cmd` frames. Arguments follow the box's convention: snake_case, `id` +for what the op acts on, a level as a fraction from 0 to 1. + +| Op | Args | Effect | +|---|---|---| +| `car.goal.set` | `id`, or `charger_id` for a session whose car is unknown; `target` (a fraction) and `ready_by_min`, with `days` and `finish_at_limit`; `clear`; `surplus_only` | `target` and `ready_by_min` replace the goal, `clear: true` removes it, `surplus_only` sets the choice. What is absent stays as it was | +| `car.soc.set` | `id` or `charger_id`; `soc` | Sets the level as `manual`, now | +| `session.car.set` | `id` (the charger), `session`, `car_id` | Names the car on the charger. An empty `car_id` makes the session a visitor's | +| `car.add` | `id`, which the app mints, and the car's fields without the level | Adds a car, or updates the one with that id | +| `car.update` | `id`, the fields that change; `new_id` for a `boxMinted` car | Changes a car | +| `car.remove` | `id` | Removes a car and keeps its tombstone. A session on it becomes a visitor's | + +`cmd.result` carries `car`, the car's id, level, goal and surplus choice as +the box holds them after the op, or `charger`, the charger's car after +`session.car.set`; `car.soc.set` also reads the level back in `observed`, as +a fraction. A result rides lane 0, so the app reads the rest of a car from +`GET /api/cars`. A car has at most eight identifiers and eight sources, of at +most 64 characters each. Refusals after the ack: +`E_NOT_FOUND` for an unknown car or charger, `E_PRECONDITION` with reason +`session` for a plug-in that has ended, `E_UNAVAILABLE` with reason +`unplugged` when nothing is plugged in, and `E_DUPLICATE`. A box without cars +answers `E_UNAVAILABLE` with `subsystem: cars`. + +## car.which + +The box raises it once per plug-in, as above. The event names the charger, +the plug-in's `session` in its args, and at most four cars that are not on +another charger. The phone shows one button per car, with the names from its +own table, and "None of these", which sends `session.car.set` with an empty +`car_id`. With more than four cars it shows no buttons and opens FTW. The +phone refreshes its table when a car is added. The notification rule is on +by default: until someone taps, the car charges as a visitor. + +## Where cars appear + +- `GET /api/cars` lists the cars, as above, and `removed`. + `GET /api/loadpoints` gives each charger `session`, `car_id`, + `car_identified_by`, `car_corrected`, `car_corrected_from` and `choose`. + HTTP routes spell each registry name in snake_case; a test holds the two + spellings together. +- The watch's `now` gives each charger `carId`, `carIdentifiedBy`, + `carCorrected`, and the car's `socSource`, `socAgeS` and `socEstimate` + beside `socPermille`; while the box asks, also `session` and `choose`. + To fit the 512 B frame, car ids there are their 16 bytes, the 32 hex + digits decoded, and `carCorrectedFrom` is left out. A charger asking with + four cars takes about 180 B; two chargers asking at once with four cars + each do not fit, and the last is left out with TRUNC, as for any `now`. +- FTW Cloud's `lp` carries the same charger fields with opaque car ids, and + its `car` class carries each car's level and goal and nothing that names + it ([protocol-cloud.md](protocol-cloud.md)). + +## One release of overlap + +Apps already shipped know only chargers. For one release the charger routes +and ops act on the session's car: + +- `GET /api/loadpoints` shows the car's level as `current_soc`; +- `POST /api/loadpoints/{id}/target` and `PUT /api/loadpoints/{id}/schedule` + set that car's goal and surplus-only choice; +- `loadpoint.soc.set` and `POST /api/loadpoints/{id}/soc` set its level; +- `loadpoint.surplus_only.set` sets its surplus-only choice; +- `POST /api/loadpoints/{id}/vehicle` sets its capacity. + +With nothing plugged in, they act on the car the charger last had, or the +only car. While the session's car is unknown they act on the session alone, +never on a car. So do edits of `vehicle_capacity_wh`, `plugin_soc` and +`surplus_only` in config.yaml, by hand or on the box's settings page: they +change the car the charger shows. + +The old fields map so that a shipped app never shows an estimate as the +car's own number: + +| The car | Legacy `soc_source` | Legacy `current_soc` | `vehicle_soc`, `vehicle_soc_age_s` | Cloud `lp.socAgeS` | +|---|---|---|---|---| +| `vehicle`, exact | `vehicle` | The car's number | The car's number and its age | Its age | +| `vehicle`, estimate | `inferred` | The estimate | The car's number and its age | null | +| `manual` | `inferred` | The level | absent | null | +| `assumed` | `assumed` | The assumption | absent | null | +| A visitor's | `unknown` | absent | absent | null, and `socPermille` null | + +`completed` is no longer sent; an app keeps reading it from older boxes. + +Goals map both ways between `goal` and the charger's `schedule`: + +| `goal` | `schedule` | +|---|---| +| `days` 0 (once) | `recurring` false | +| `days` 127 (every day) | `recurring` true, `days` 0 | +| any other `days` | `recurring` true, the same `days` | +| `readyByMin` | `time_of_day_min`, and `time_of_day_min_utc` as today's UTC minute | +| `targetPermille` | `soc` as a fraction | +| `finishAtLimit` | `finish_at_vehicle_limit` | + +## Moving what exists + +On first start with no cars, the box makes them from what it has. Nothing +the owner set is lost. + +- **Car 1** takes the loadpoint's `vehicle_capacity_wh` and `plugin_soc`, + the goal from `loadpoint_schedule:` (mapped as above) and surplus-only + from `loadpoint_surplus_only:`. A box with more loadpoints makes Car 2 + and on from each of the others. These cars are `boxMinted`, and the + loadpoint each came from is its usual charger (rule 3). +- **Each profile** in `vehicles[]` becomes a car with its identifiers, + capacity, target and surplus-only, and an edit to the profile in + config.yaml reaches its car. One charger and one profile is one car: + the profile gives Car 1 its name, identifiers and capacity, and Car 1 + keeps the goal and choices the charger showed. +- **Each vehicle driver** that reports a VIN binds to the car with that VIN. + When there is one car, every vehicle driver binds to it, and gives it its + VIN and maker when the car has none. A driver the owner takes off a car + stays off, and so do the drivers of a car the owner removes. +- The loadpoint's own rows keep being written, so a box rolled back to a + release before cars finds its goal and choices where they were. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index abfc648c0..cad168358 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -137,6 +137,7 @@ Envelope = { t: text, b: bstr, sig: bstr } | `grantKey` | 32 random bytes in the grant record. A new grant draws a new one | | Opaque source id | First 8 bytes of HMAC-SHA256(`grantKey`, `src\0` ‖ driver name), base64url without padding | | Opaque loadpoint id | First 8 bytes of HMAC-SHA256(`grantKey`, `lp\0` ‖ loadpoint id), base64url without padding | +| Opaque car id | First 8 bytes of HMAC-SHA256(`grantKey`, `car\0` ‖ car id), base64url without padding. The car id is the same on each of the owner's boxes; the opaque id is not, so the service cannot follow a car from one home to another | A home id comes only from the key that signed `hello` on this connection, never from a body field. @@ -280,15 +281,15 @@ list still show what kinds of equipment a site has. | `id` | text | Opaque loadpoint id | | `src` | text | Opaque source id of the charger, whose freshness the loadpoint follows | | `plugged` | bool | A car is connected | -| `socPermille` | uint or null | Car charge level, null when unplugged | -| `socSource` | text | `vehicle`, `inferred` or `assumed`; empty when unplugged | +| `socPermille` | uint or null | Car charge level, null when unplugged and for a visitor's car, whose level nobody knows | +| `socSource` | text | `vehicle` (the car's own number, exact), `inferred` (an estimate, or a level someone set), `assumed`, or `unknown` (a visitor's car); empty when unplugged | | `socAgeS` | uint or null | Seconds since the car last reported the level, at send. Null unless `socSource` is `vehicle` | | `targetPermille` | uint or null | The level to reach | | `readyByMs` | int or null | Wall time the car should be ready | | `recurring` | bool | The target comes from a weekly schedule | | `powerW` | int | Charging power, positive while charging | | `sessionWh` | uint | Energy delivered this session | -| `session` | text | The number of plug-ins at this loadpoint since the box started, as text; empty when unplugged. With `boot` it names one plug-in. Core has no session id yet | +| `session` | text | This plug-in's id, the same across a restart of the box; empty when unplugged. A box from before cars sends the number of plug-ins since it started, which names one plug-in only with `boot` | | `manual` | Manual or null | Manual charging state, null when none | | `commandedW` | int or null | Power last commanded | | `commandedReason` | text | Stable code for that command | @@ -299,6 +300,20 @@ list still show what kinds of equipment a site has. | `nextStartMs` | int or null | Wall time the next planned charge starts | | `nextEndMs` | int or null | Wall time it ends | | `nextWh` | uint or null | Energy it plans to deliver | +| `carId` | text, optional | Opaque car id of the car on the charger. Absent while nothing is plugged in or the box does not know which car it is | +| `carIdentifiedBy` | text, optional | How the box knows: `charger`, `car`, `only`, `tap`, `asking` or `none` ([docs/cars.md](cars.md)) | +| `carCorrected` | bool, optional | A better rule replaced the car the box named first | +| `carCorrectedFrom` | text, optional | Opaque car id of the car the box named before that | +| `choose` | [text], optional | While `asking`: opaque ids of the cars a tap may pick, at most four, none on another charger | + +With cars ([docs/cars.md](cars.md)), the level fields of a Loadpoint +(`socPermille`, `socSource`, `socAgeS`, `targetPermille`, `readyByMs`, +`recurring`, `surplusOnly`) are views of the session's car, kept for apps +that know only chargers. The join is `carId` to the `car` class's `id`, and +where the two disagree the `car` class wins. A box drops the views two +releases after the one that first sends `car`, and from then lists +`lp.bare` in `hello.caps`: its Loadpoints carry no level fields. A box from +before cars never lists it. **Manual** @@ -338,6 +353,45 @@ carries exact values, and live field 10 carries the site's charging power at every period. Without the deadband, a simulated house with a car charging at 11 kW sent 12.7 MB a day. +### `car`: `ftw.live.read` + +The level and goal of each of the owners' cars on this box +([docs/cars.md](cars.md)). Nothing here names a car: the phone reads the +name, maker, VIN, identifiers and sources from the box over Noise. + +| Field | Type | Meaning | +|---|---|---| +| `cars` | [Car] | One per car the box keeps | + +**Car** + +| Field | Type | Meaning | +|---|---|---| +| `id` | text | Opaque car id | +| `socPermille` | uint or null | The car's level, null when the box has none. The car's or a person's own number while `socEstimate` is false | +| `socSource` | text | `vehicle`, `manual` or `assumed`; empty without a level | +| `socAgeS` | uint or null | Seconds since the car reported the level or someone set it, at send | +| `energySinceWh` | int | Energy delivered to the car since then; negative when a V2X session took energy out | +| `socEstimate` | bool | The box counts the level as an estimate | +| `chargeLimitPermille` | uint or null | The charge limit the car reported with its level | +| `goal` | CarGoal or null | What the car should reach and when | +| `surplusOnly` | bool | The car charges from surplus PV only | + +**CarGoal** + +| Field | Type | Meaning | +|---|---|---| +| `targetPermille` | uint | The level to reach | +| `readyByMin` | uint | Ready time in minutes after midnight in the box's time zone | +| `days` | uint | ISO weekday mask, Monday in bit 0. 0 is once, at the next ready time, and 127 every day | +| `finishAtLimit` | bool | Charge on until the car stops at its own limit | + +The box sends `car` when a car's level, source, goal or preference changes, +at most once per period like `lp`, and in full every 15 minutes and after a +`resync`, as `lp`. The change test ignores `socAgeS` and counts +`energySinceWh` as changed at 100 Wh, as `lp` counts `sessionWh`. A service +that does not know the class drops it, as for any unknown `t`. + ### `prices`: `ftw.live.read` | Field | Type | Meaning | @@ -396,10 +450,12 @@ The grid ceiling the plan defends is not sent. | `args` | map text→(text or int) | The catalogue's placeholders, except names | | `src` | text | Opaque source id, when the event concerns a device | | `lp` | text | Opaque loadpoint id, when the event concerns a loadpoint | +| `cars` | [text], optional | Opaque car ids, for `car.which`: the cars the phone offers one button each, in the box's order. At most four; with more cars it is absent and the phone opens FTW. `args.session` is the plug-in's `session`, which the tap names | -No argument carries a driver or device name. Where the catalogue text has a -name placeholder, such as `{name}` in `driver.offline`, the box sends `src` -and the phone fills in the name from its table. The box sends an event only +No argument carries a driver, device or car name. Where the catalogue text +has a name placeholder, such as `{name}` in `driver.offline`, the box sends +`src` and the phone fills in the name from its table. `car.which` names its +cars the same way. The box sends an event only when its own rules (enabled, cooldown) allow. Delivery is best effort. ### `tiles`: `ftw.history.read` @@ -562,7 +618,7 @@ back `box.unreachable` until the box returns or an hour passes. - `plan` goes only when `decisionId` changes, `prices` only when its hash changes, `tiles` when a bucket closes, and `event` at once. - Every 15 minutes, and after a `resync`, the box sends a full `live` with - `sources` and `fieldSources`, and a full `lp`. + `sources` and `fieldSources`, a full `lp` and a full `car`. - **Heartbeat.** The box sends `hb` whenever neither `live` nor `hb` has left for H, where H = clamp(the smallest `staleAfterMs` among sources ÷ 2, 2 s, 15 s), and 15 s when there are no sources. It sends no `hb` while a @@ -750,12 +806,13 @@ Noise session, in the app protocol's `hello_ok.cloud`. | `boot` | bstr, 16 B | The uplink's current `boot`. It counts from the same Core start as `hello_ok.clock.uptimeMs`, which anchors rule U | | `sources` | map text→text | Opaque source id to driver name | | `loadpoints` | map text→text | Opaque loadpoint id to loadpoint id | +| `cars` | map text→text, optional | Opaque car id to car id. Absent from a box without cars | - The box sends `cloud` while it is linked. It leaves it out when it holds no `cloud.key`, while the link waits for a purge, and in floor mode. Without it the phone reaches the box through the relay only. -- `sources` and `loadpoints` are cut with the grant's `grantKey`, so they are - empty while the readable copy is off. +- `sources`, `loadpoints` and `cars` are cut with the grant's `grantKey`, so + they are empty while the readable copy is off. - The tables name the drivers and loadpoints the box knows at `hello`. One added later has no name on the phone until its next session with the box. - The box lists `cloud.link` in `hello_ok.caps` when its build can run the @@ -1267,6 +1324,8 @@ copied them here and to the app. | `reads` | The reads an agent may send and the scope each needs | `registry.yaml`, new block | | `E_GRANT_EXPIRED`, `E_OP_NOT_GRANTED`, `E_CAP_EXCEEDED`, `E_RATE_LIMITED`, `E_NEEDS_OWNER`, `E_BAD_SIGNATURE`, `E_NO_APPROVAL_KEY`, `E_NOT_FOUND`, `E_CLOCK_SKEW` | Agents' refusals | `registry.yaml`, `errors` | | `agent.added`, `agent.request`, `agent.expiring`, `approval.key.set`, `owner.added` | Push kinds, sent to every owner | `push-catalogue.yaml` | +| `car.which` | Push kind: the box asks which car is on a charger ([docs/cars.md](cars.md)) | `push-catalogue.yaml` | +| `car_soc_sources`, `car_identified_by` | Lists: where a car's level comes from, and how the box knows which car is on a charger | `registry.yaml`, new blocks | Class names, field names, close codes and the signing tags are not registry names. They live here and in `go/protocol`, held by golden vectors in both diff --git a/go/internal/apiauth/contract_gen.go b/go/internal/apiauth/contract_gen.go index 50c9fc966..94a6f8b23 100644 --- a/go/internal/apiauth/contract_gen.go +++ b/go/internal/apiauth/contract_gen.go @@ -53,6 +53,8 @@ var RoleScopes = map[string][]string{ "ftw.dispatch.hold", "ftw.goals.request", "ftw.update.request", + "ftw.cars.read", + "ftw.cars.write", }, RoleRelease: { "ftw.health.read", diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index 8d1e84029..fc409debf 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -82,6 +82,19 @@ type Registry struct { RetentionDays int `yaml:"retention_days"` Source string `yaml:"source"` } `yaml:"resolutions"` + // Cars: their field names, the two enums and what a charger carries + // about its car (docs/cars.md). + CarFields []carField `yaml:"car_fields"` + CarSoCSources []string `yaml:"car_soc_sources"` + CarIdentifiedBy []string `yaml:"car_identified_by"` + ChargerCarFields []carField `yaml:"charger_car_fields"` +} + +// carField is one entry of car_fields or charger_car_fields. +type carField struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Desc string `yaml:"desc"` } // field is one entry of frozen_fields or extra_fields. @@ -122,6 +135,7 @@ func Generate(raw []byte) ([]byte, error) { writeSourceStates(&b, reg) writeResolutions(&b, reg) writeRegistryModes(&b, reg) + writeCars(&b, reg) return format.Source(b.Bytes()) } @@ -515,6 +529,39 @@ func writeRegistryModes(b *bytes.Buffer, reg Registry) { b.WriteString("}\n") } +// writeCars emits the car's enums as constants and its field names as lists. +// The names are struct tags in go/protocol, read back against these lists by +// TestCarStructsAreTheRegistrys, as the ops are against the command table. +func writeCars(b *bytes.Buffer, reg Registry) { + b.WriteString("\n// Where a car's level comes from: the registry's car_soc_sources.\n") + b.WriteString("const (\n") + for _, s := range reg.CarSoCSources { + fmt.Fprintf(b, "\tCarSoC%s = %q\n", goName(s), s) + } + b.WriteString(")\n\n") + + b.WriteString("// How the box knows which car is on a charger: the registry's\n") + b.WriteString("// car_identified_by.\n") + b.WriteString("const (\n") + for _, s := range reg.CarIdentifiedBy { + fmt.Fprintf(b, "\tCarIdentifiedBy%s = %q\n", goName(s), s) + } + b.WriteString(")\n\n") + + writeNames := func(name, what string, fields []carField) { + fmt.Fprintf(b, "// %s is every name in the registry's %s. A dotted name is a\n", name, what) + b.WriteString("// field of the object before the dot.\n") + fmt.Fprintf(b, "var %s = []string{\n", name) + for _, f := range fields { + fmt.Fprintf(b, "\t%q, // %s\n", f.Name, f.Desc) + } + b.WriteString("}\n") + } + writeNames("CarFields", "car_fields", reg.CarFields) + b.WriteString("\n") + writeNames("ChargerCarFields", "charger_car_fields", reg.ChargerCarFields) +} + // errName turns E_CMD_EXPIRED into ErrCmdExpired. func errName(code string) string { return "Err" + goName(strings.ToLower(strings.TrimPrefix(code, "E_"))) diff --git a/go/protocol/cars.go b/go/protocol/cars.go new file mode 100644 index 000000000..90d8b4ccb --- /dev/null +++ b/go/protocol/cars.go @@ -0,0 +1,134 @@ +package protocol + +// Cars. A car belongs to its owner, not to the home, and visits chargers. The +// box keeps the cars of its owners; a charging session is one car on one +// charger from plug-in to unplug. docs/cars.md is the normative text, and +// the field names are the registry's car_fields and charger_car_fields, +// read back by TestCarStructsAreTheRegistrys. +// +// The cbor names are the session's. The json names are the box's HTTP +// routes, which spell every registry name in snake_case. + +// Car is one car as the box keeps it. +type Car struct { + // ID is 128 bits as 32 lowercase hex digits. The owner's app mints it, + // and it is the same on every box the owner has. + ID string `cbor:"id" json:"id"` + // BoxMinted marks a car the box made from its old settings, with an id + // of its own. The owner's app adopts it with car.update new_id, or by + // adding a car with the same VIN. + BoxMinted bool `cbor:"boxMinted,omitempty" json:"box_minted,omitempty"` + // OwnerID is reserved for the owner the car belongs to. Until owners + // are principals, every phone with the owner role shares one list. + OwnerID string `cbor:"ownerId,omitempty" json:"owner_id,omitempty"` + Name string `cbor:"name" json:"name"` + Make string `cbor:"make,omitempty" json:"make,omitempty"` + // VIN binds a vehicle driver that reports the same VIN to this car. + VIN string `cbor:"vin,omitempty" json:"vin,omitempty"` + CapacityWh int64 `cbor:"capacityWh" json:"capacity_wh"` + // MaxW and Phases are the car's own charger. Zero means unknown, and a + // session then takes the charger's limits alone. + MaxW int64 `cbor:"maxW,omitempty" json:"max_w,omitempty"` + Phases int64 `cbor:"phases,omitempty" json:"phases,omitempty"` + + // SocPermille is the level. While SocEstimate is false it is the + // number the car or a person gave; after, it is that number plus the + // energy delivered to the car since, × efficiency / capacity. Absent + // when the box has none. + SocPermille *int64 `cbor:"socPermille,omitempty" json:"soc_permille,omitempty"` + // SocSource is where that reading came from, one of CarSoC*. + SocSource string `cbor:"socSource,omitempty" json:"soc_source,omitempty"` + // SocAgeS is the seconds since the car reported the level or someone set + // it, at send. Never the box's wall clock, which may be wrong. + SocAgeS *int64 `cbor:"socAgeS,omitempty" json:"soc_age_s,omitempty"` + // EnergySinceWh is the energy delivered to this car since then; + // negative when a V2X session took energy out. + EnergySinceWh int64 `cbor:"energySinceWh" json:"energy_since_wh"` + // SocEstimate is true once more than the box's limit of energy has + // flowed since the reading, for a level read before the car last left + // a charger, and always for an assumed level. A reading with nothing + // delivered since stays exact for as long as that holds. + SocEstimate bool `cbor:"socEstimate" json:"soc_estimate"` + ChargeLimitPermille *int64 `cbor:"chargeLimitPermille,omitempty" json:"charge_limit_permille,omitempty"` + + // Goal is one per car, used wherever the car is plugged in. + Goal *CarGoal `cbor:"goal,omitempty" json:"goal,omitempty"` + // SurplusOnly is the car's preference: it charges from surplus PV only. + SurplusOnly bool `cbor:"surplusOnly" json:"surplus_only"` + + // Identifiers are the RFID tags, MAC addresses and eMAIDs a charger may + // name the car by. + Identifiers []string `cbor:"identifiers,omitempty" json:"identifiers,omitempty"` + // Sources are the vehicle drivers on this box that report the car's + // level. They are the box's own and never copied to another box: Home + // Assistant at home, the maker's service anywhere. + Sources []string `cbor:"sources,omitempty" json:"sources,omitempty"` +} + +// CarGoal is what a car should reach and when. +type CarGoal struct { + TargetPermille int64 `cbor:"targetPermille" json:"target_permille"` + // ReadyByMin is minutes after midnight in the box's time zone. + ReadyByMin int64 `cbor:"readyByMin" json:"ready_by_min"` + // Days is an ISO weekday mask, Monday in bit 0. Zero is once, at the + // next ready time, and 127 every day. A loadpoint schedule's days reads + // 0 as every day when recurring; docs/cars.md maps the two. + Days uint8 `cbor:"days" json:"days"` + // FinishAtLimit charges on until the car stops at its own limit. + FinishAtLimit bool `cbor:"finishAtLimit" json:"finish_at_limit"` +} + +// ChargerCar is what a charger carries about the car on it, beside its own +// fields: on GET /api/loadpoints and, with opaque ids, in FTW Cloud's lp +// class. The watch's NowLoadpoint carries all but CarCorrectedFrom. Empty +// while nothing is plugged in. +type ChargerCar struct { + // Session is this plug-in's id. session.car.set names it, and the box + // refuses a tap for a plug-in that has ended. + Session string `cbor:"session,omitempty" json:"session,omitempty"` + // CarID is the session's car, empty when the box does not know which. + CarID string `cbor:"carId,omitempty" json:"car_id,omitempty"` + // CarIdentifiedBy is one of CarIdentifiedBy*. + CarIdentifiedBy string `cbor:"carIdentifiedBy,omitempty" json:"car_identified_by,omitempty"` + // CarCorrected is set when a better rule replaced the car the box named + // first, and CarCorrectedFrom is that car, for the rest of the session, + // so the correction is shown rather than silent. + CarCorrected bool `cbor:"carCorrected,omitempty" json:"car_corrected,omitempty"` + CarCorrectedFrom string `cbor:"carCorrectedFrom,omitempty" json:"car_corrected_from,omitempty"` + // Choose is, while the box asks, the cars a tap may pick: at most four, + // none of them on another charger. + Choose []string `cbor:"choose,omitempty" json:"choose,omitempty"` +} + +// Operations on cars. Their scopes are in contract/registry.yaml `ops`. +// Arguments follow the box's convention: snake_case, `id` for what the op +// acts on, a level as a fraction from 0 to 1. +const ( + // OpCarGoalSet sets how and when a car charges. Args: id (the car) or + // charger_id (a visitor's session); target, a fraction from 0 to 1, and + // ready_by_min, with days and finish_at_limit, which together replace + // the goal; clear, true to remove it; and surplus_only. The goal and + // surplus_only are each optional: one that is absent stays as it was. + OpCarGoalSet = "car.goal.set" + // OpCarSoCSet sets a car's level by hand. Args: id or charger_id, and + // soc, a fraction from 0 to 1. + OpCarSoCSet = "car.soc.set" + // OpSessionCarSet says which car is on a charger: the one tap. Args: id + // (the charger), session (the plug-in's id) and car_id. An empty car_id + // makes the session a visitor's. + OpSessionCarSet = "session.car.set" + // OpCarAdd adds a car. Args: id, which the owner's app mints, and the + // car's own fields without the level. An id the box has is an update. + OpCarAdd = "car.add" + // OpCarUpdate changes a car. Args: id and the fields that change; + // new_id re-keys a car the box minted to the id the owner's app holds. + OpCarUpdate = "car.update" + // OpCarRemove removes a car. Args: id. A session on it becomes a + // visitor's, and the box keeps the id as removed. + OpCarRemove = "car.remove" +) + +// CarEstimateAfterWh is how much energy may flow after a reading before the +// box counts the level as an estimate. It replaces a time limit: a reading +// with nothing delivered since is as true after nine minutes as after one. +const CarEstimateAfterWh = 1000 diff --git a/go/protocol/cars_test.go b/go/protocol/cars_test.go new file mode 100644 index 000000000..412633c89 --- /dev/null +++ b/go/protocol/cars_test.go @@ -0,0 +1,90 @@ +package protocol + +import ( + "reflect" + "slices" + "strings" + "testing" + "unicode" +) + +// tags lists a struct's wire names, a nested struct's as "outer.inner", and +// the fields of an embedded struct as its own. +func tags(t *testing.T, typ reflect.Type, prefix string) (cbor, json []string) { + t.Helper() + for i := range typ.NumField() { + f := typ.Field(i) + if f.Anonymous { + c, j := tags(t, f.Type, prefix) + cbor, json = append(cbor, c...), append(json, j...) + continue + } + c := strings.Split(f.Tag.Get("cbor"), ",")[0] + j := strings.Split(f.Tag.Get("json"), ",")[0] + cbor = append(cbor, prefix+c) + json = append(json, j) + inner := f.Type + for inner.Kind() == reflect.Pointer { + inner = inner.Elem() + } + if inner.Kind() == reflect.Struct { + c2, j2 := tags(t, inner, prefix+c+".") + cbor, json = append(cbor, c2...), append(json, j2...) + } + } + return cbor, json +} + +// snake turns socPermille into soc_permille, the HTTP routes' spelling. +func snake(s string) string { + var b strings.Builder + for i, r := range s { + if unicode.IsUpper(r) { + if i > 0 { + b.WriteByte('_') + } + r = unicode.ToLower(r) + } + b.WriteRune(r) + } + return b.String() +} + +// The car's names are the registry's, both ways, and the HTTP spelling is +// the same names in snake_case, so nobody writes a car's field twice. +func TestCarStructsAreTheRegistrys(t *testing.T) { + for _, tc := range []struct { + typ reflect.Type + want []string + }{ + {reflect.TypeOf(Car{}), CarFields}, + {reflect.TypeOf(ChargerCar{}), ChargerCarFields}, + } { + cbor, json := tags(t, tc.typ, "") + if !slices.Equal(cbor, tc.want) { + t.Errorf("%s: cbor names %v, registry %v", tc.typ.Name(), cbor, tc.want) + } + for i, c := range cbor { + leaf := c[strings.LastIndex(c, ".")+1:] + if json[i] != snake(leaf) { + t.Errorf("%s.%s: json name %q, want %q", tc.typ.Name(), c, json[i], snake(leaf)) + } + } + } +} + +// The watch's now carries the charger's car fields except the corrected +// car's id, and the car's level fields; a 512 B frame has no room for more. +func TestNowLoadpointNamesItsCar(t *testing.T) { + cbor, _ := tags(t, reflect.TypeOf(NowLoadpoint{}), "") + for _, name := range ChargerCarFields { + if has := slices.Contains(cbor, name); has != (name != "carCorrectedFrom") { + t.Errorf("NowLoadpoint has %s: %v", name, has) + } + } + for _, name := range []string{"socPermille", "socSource", "socAgeS", "socEstimate"} { + if !slices.Contains(cbor, name) { + t.Errorf("NowLoadpoint has no %s", name) + } + } +} diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 3c9e35f9f..e3b0e782d 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -23,15 +23,23 @@ var Fields = map[string][]string{ "targetPermille", "readyByMs", "recurring", "powerW", "sessionWh", "session", "manual", "commandedW", "commandedReason", "surplusOnly", "gridDeferred", "planPending", "boost", "nextStartMs", "nextEndMs", "nextWh", + "carId", "carIdentifiedBy", "carCorrected", "carCorrectedFrom", "choose", }, "Manual": {"state", "sinceAgeMs", "requestedW", "commandedW", "reason"}, "Feedback": {"kind", "src", "status", "reason", "severity", "evidence", "fresh", "actualW", "ageMs"}, + MsgCar: {"cars"}, + "Car": { + "id", "socPermille", "socSource", "socAgeS", "energySinceWh", "socEstimate", + "chargeLimitPermille", "goal", "surplusOnly", + }, + "CarGoal": {"targetPermille", "readyByMin", "days", "finishAtLimit"}, + MsgPrices: {"zone", "currency", "t0Ms", "slotMs", "spotMinor", "totalMinor"}, MsgHB: {"clock", "srcRev", "liveSeq", "lpSeq", "ages"}, - MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp"}, + MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp", "cars"}, MsgRoster: {"rosterSeq", "ttlS", "devices", "grant", "pending", "noise"}, "Device": {"id", "key", "role", "scopes", "epoch", "tun", "agent"}, @@ -61,6 +69,7 @@ var Bodies = map[string]any{ MsgHello: Hello{}, MsgLive: Live{}, MsgLP: LP{}, + MsgCar: Cars{}, MsgPrices: Prices{}, MsgHB: HB{}, MsgEvent: Event{}, diff --git a/go/protocol/cloudwire/allowlist_test.go b/go/protocol/cloudwire/allowlist_test.go index e78dace54..c89364f22 100644 --- a/go/protocol/cloudwire/allowlist_test.go +++ b/go/protocol/cloudwire/allowlist_test.go @@ -167,3 +167,31 @@ func TestFieldsMatchTheProtocolDocument(t *testing.T) { } } } + +// The car class carries exactly these of the registry's car fields: the +// level and the goal. An allowlist, so a field added to the car does not +// leave the house until someone decides it may. Nothing that names a car or +// says what it is (name, maker, VIN, capacity, identifiers, sources, owner) +// is among them. +func TestTheCarClassCarriesOnlyTheLevelAndTheGoal(t *testing.T) { + allowed := []string{"id", "socPermille", "socSource", "socAgeS", "energySinceWh", "socEstimate", + "chargeLimitPermille", "goal", "surplusOnly"} + if got := sorted(Fields["Car"]); !slices.Equal(got, sorted(allowed)) { + t.Errorf("car class %v, allowed %v", got, sorted(allowed)) + } + for _, f := range append(allowed, "goal.targetPermille", "goal.readyByMin", "goal.days", "goal.finishAtLimit") { + if !slices.Contains(protocol.CarFields, f) { + t.Errorf("%s is not a registry car field", f) + } + } + for _, f := range Fields["CarGoal"] { + if !slices.Contains(protocol.CarFields, "goal."+f) { + t.Errorf("CarGoal.%s is not a registry goal field", f) + } + } + for _, f := range protocol.ChargerCarFields { + if !slices.Contains(Fields["Loadpoint"], f) { + t.Errorf("Loadpoint has no %s", f) + } + } +} diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index d99a2aab3..004c99710 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -25,6 +25,7 @@ const ( MsgHB = "hb" MsgRoster = "roster" MsgBye = "bye" + MsgCar = "car" // MsgNotice tells the owners' phones that something changed who may // reach the box (docs/agents.md, Push kinds). It carries the kind and // nothing else, and the box sends it whenever it is linked, copy or not. @@ -42,6 +43,7 @@ var Downlink = []string{MsgChallenge, MsgHelloOK, MsgWatch, MsgResync, MsgPurged var ClassScope = map[string]string{ MsgLive: protocol.ScopeLiveRead, MsgLP: protocol.ScopeLiveRead, + MsgCar: protocol.ScopeLiveRead, MsgPrices: protocol.ScopeLiveRead, MsgPlan: protocol.ScopePlanRead, MsgHealth: protocol.ScopeHealthRead, @@ -218,6 +220,48 @@ type Loadpoint struct { NextStartMs *int64 `cbor:"nextStartMs"` NextEndMs *int64 `cbor:"nextEndMs"` NextWh *int64 `cbor:"nextWh"` + // The car on the charger, by opaque car id, and how the box knows it + // (docs/cars.md). Absent while nothing is plugged in, and from a box + // without cars. The level fields above are views of this car for one + // release; the car class is the truth. + CarID string `cbor:"carId,omitempty"` + CarIdentifiedBy string `cbor:"carIdentifiedBy,omitempty"` + CarCorrected bool `cbor:"carCorrected,omitempty"` + CarCorrectedFrom string `cbor:"carCorrectedFrom,omitempty"` + Choose []string `cbor:"choose,omitempty"` +} + +// Cars is the car class: the level and goal of each of the owners' cars on +// this box. Nothing in it names a car: the id is opaque, and the phone reads +// the name, maker, VIN and sources from the box over Noise. +type Cars struct { + Header + Cars []Car `cbor:"cars"` +} + +// Car is one car's level and goal. +type Car struct { + // ID is the opaque car id. + ID string `cbor:"id"` + SocPermille *int64 `cbor:"socPermille"` + SocSource string `cbor:"socSource"` + // SocAgeS is the seconds since the car reported the level or someone + // set it, at send. Null without a level. + SocAgeS *int64 `cbor:"socAgeS"` + // EnergySinceWh is negative when a V2X session took energy out. + EnergySinceWh int64 `cbor:"energySinceWh"` + SocEstimate bool `cbor:"socEstimate"` + ChargeLimitPermille *int64 `cbor:"chargeLimitPermille"` + Goal *CarGoal `cbor:"goal"` + SurplusOnly bool `cbor:"surplusOnly"` +} + +// CarGoal is protocol.CarGoal, as the car class carries it. +type CarGoal struct { + TargetPermille int64 `cbor:"targetPermille"` + ReadyByMin int64 `cbor:"readyByMin"` + Days uint8 `cbor:"days"` + FinishAtLimit bool `cbor:"finishAtLimit"` } // Manual is a manual charging hold. diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go index 39aa7c5dc..e5a977727 100644 --- a/go/protocol/cloudwire/push.go +++ b/go/protocol/cloudwire/push.go @@ -65,6 +65,9 @@ type Event struct { Src string `cbor:"src,omitempty"` // LP is the opaque loadpoint id of the loadpoint it concerns. LP string `cbor:"lp,omitempty"` + // Cars are opaque car ids, in the box's order: for car.which, the cars + // the phone offers one button each. + Cars []string `cbor:"cars,omitempty"` } // PushRegister tells the service a device's APNs token for one kind of push. diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index 229614604..52a05b70a 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -29,6 +29,16 @@ "kind": "lp", "name": "garage", "id": "j3HZORNCWHs" + }, + { + "kind": "car", + "name": "5f0c1e2d3a4b5c6d7e8f90a1b2c3d4e5", + "id": "FNKibitfeU0" + }, + { + "kind": "car", + "name": "0123456789abcdef0123456789abcdef", + "id": "Dm4Qg3Dz3bc" } ], "messages": [ @@ -261,6 +271,46 @@ "sig": "f2f15bfcafd92e077fb41218fb5eca3735d45484cab4074541f0908b8e6a1107a31f8bf3caf605405bd7cbd02963ff4466a40de915aa9d9af96166e5fc5965a7", "envelope": "a36162590256aa637365710e64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7de80676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a7626964686f4b47696f36536c636b65795820a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a06374756ef464726f6c6567737570706f7274656167656e74a5636f7073816e6c6f6164706f696e742e686f6c646463617073a3646d617857191ce865686f6c6453190384667065724d696e066673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964783268747470733a2f2f636c6f75642e6674772e656e657267792f636c69656e74732f736f7572636566756c2d737570706f72746b6578706972657341744d731b000001a17720ca006565706f6368016673636f706573806770656e64696e678068757074696d654d731a000128e069726f7374657253657107617466726f73746572637369675840f2f15bfcafd92e077fb41218fb5eca3735d45484cab4074541f0908b8e6a1107a31f8bf3caf605405bd7cbd02963ff4466a40de915aa9d9af96166e5fc5965a7", "frame": "0101000002aba36162590256aa637365710e64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7de80676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a7626964686f4b47696f36536c636b65795820a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a06374756ef464726f6c6567737570706f7274656167656e74a5636f7073816e6c6f6164706f696e742e686f6c646463617073a3646d617857191ce865686f6c6453190384667065724d696e066673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964783268747470733a2f2f636c6f75642e6674772e656e657267792f636c69656e74732f736f7572636566756c2d737570706f72746b6578706972657341744d731b000001a17720ca006565706f6368016673636f706573806770656e64696e678068757074696d654d731a000128e069726f7374657253657107617466726f73746572637369675840f2f15bfcafd92e077fb41218fb5eca3735d45484cab4074541f0908b8e6a1107a31f8bf3caf605405bd7cbd02963ff4466a40de915aa9d9af96166e5fc5965a7" + }, + { + "name": "lp car asking", + "signer": "box", + "t": "lp", + "body": "a7637365710f64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7de8068666565646261636b8068757074696d654d731a000128e06a636f6e74726f6c52657619032d6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46663686f6f7365826b464e4b69626974666555306b446d34516733447a336263666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553f6696e657874456e644d73f669726561647942794d73f669726563757272696e67f46973657373696f6e57680069736f63536f7572636567756e6b6e6f776e6a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65f66b737572706c75734f6e6c79f56c677269644465666572726564f46e7461726765745065726d696c6c65f66f6361724964656e74696669656442796661736b696e676f636f6d6d616e646564526561736f6e7070765f737572706c75735f7061757365", + "signedInputSha256": "895b15e9dd2992642f09e346998147e6c89162a1c6a84e2c1fe9aba9349f9a82", + "sig": "fa1b746e25aa7f02deb3fbc543e233315f26f31e0325f4921fb0d5a9ef01e249c129184c88d23396bcc1dc3193bdf78fa2f5805760dd9bea3d05137343bbe420", + "envelope": "a361625901bda7637365710f64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7de8068666565646261636b8068757074696d654d731a000128e06a636f6e74726f6c52657619032d6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46663686f6f7365826b464e4b69626974666555306b446d34516733447a336263666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553f6696e657874456e644d73f669726561647942794d73f669726563757272696e67f46973657373696f6e57680069736f63536f7572636567756e6b6e6f776e6a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65f66b737572706c75734f6e6c79f56c677269644465666572726564f46e7461726765745065726d696c6c65f66f6361724964656e74696669656442796661736b696e676f636f6d6d616e646564526561736f6e7070765f737572706c75735f70617573656174626c70637369675840fa1b746e25aa7f02deb3fbc543e233315f26f31e0325f4921fb0d5a9ef01e249c129184c88d23396bcc1dc3193bdf78fa2f5805760dd9bea3d05137343bbe420", + "frame": "01010000020ea361625901bda7637365710f64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7de8068666565646261636b8068757074696d654d731a000128e06a636f6e74726f6c52657619032d6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46663686f6f7365826b464e4b69626974666555306b446d34516733447a336263666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553f6696e657874456e644d73f669726561647942794d73f669726563757272696e67f46973657373696f6e57680069736f63536f7572636567756e6b6e6f776e6a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65f66b737572706c75734f6e6c79f56c677269644465666572726564f46e7461726765745065726d696c6c65f66f6361724964656e74696669656442796661736b696e676f636f6d6d616e646564526561736f6e7070765f737572706c75735f70617573656174626c70637369675840fa1b746e25aa7f02deb3fbc543e233315f26f31e0325f4921fb0d5a9ef01e249c129184c88d23396bcc1dc3193bdf78fa2f5805760dd9bea3d05137343bbe420" + }, + { + "name": "car", + "signer": "box", + "t": "car", + "body": "a5637365711064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361727382a96269646b464e4b696269746665553064676f616ca46464617973187f6a726561647942794d696e1901a46d66696e69736841744c696d6974f46e7461726765745065726d696c6c6519032067736f634167655319021c69736f63536f757263656776656869636c656b736f63457374696d617465f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46d656e6572677953696e6365576800736368617267654c696d69745065726d696c6c65190320a96269646b446d34516733447a33626364676f616cf667736f634167655319778869736f63536f757263656776656869636c656b736f63457374696d617465f56b736f635065726d696c6c6519026c6b737572706c75734f6e6c79f56d656e6572677953696e6365576800736368617267654c696d69745065726d696c6c65f66677616c6c4d731b000001a130d7de8a68757074696d654d731a000128ea", + "signedInputSha256": "855da2d9302e228ac2acc0c4d900d6a3074675c8b5fb1350c2942bce438b9fc4", + "sig": "7ee438570fc0f1072201594add376235e687cb87168f882c88e94e1e087b595be184505d295ecc901cb8c8a424a8fa3df0b65b28b1c2352a7ecfc4cb0b53ae01", + "envelope": "a36162590177a5637365711064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361727382a96269646b464e4b696269746665553064676f616ca46464617973187f6a726561647942794d696e1901a46d66696e69736841744c696d6974f46e7461726765745065726d696c6c6519032067736f634167655319021c69736f63536f757263656776656869636c656b736f63457374696d617465f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46d656e6572677953696e6365576800736368617267654c696d69745065726d696c6c65190320a96269646b446d34516733447a33626364676f616cf667736f634167655319778869736f63536f757263656776656869636c656b736f63457374696d617465f56b736f635065726d696c6c6519026c6b737572706c75734f6e6c79f56d656e6572677953696e6365576800736368617267654c696d69745065726d696c6c65f66677616c6c4d731b000001a130d7de8a68757074696d654d731a000128ea6174636361726373696758407ee438570fc0f1072201594add376235e687cb87168f882c88e94e1e087b595be184505d295ecc901cb8c8a424a8fa3df0b65b28b1c2352a7ecfc4cb0b53ae01", + "frame": "0101000001c9a36162590177a5637365711064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361727382a96269646b464e4b696269746665553064676f616ca46464617973187f6a726561647942794d696e1901a46d66696e69736841744c696d6974f46e7461726765745065726d696c6c6519032067736f634167655319021c69736f63536f757263656776656869636c656b736f63457374696d617465f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46d656e6572677953696e6365576800736368617267654c696d69745065726d696c6c65190320a96269646b446d34516733447a33626364676f616cf667736f634167655319778869736f63536f757263656776656869636c656b736f63457374696d617465f56b736f635065726d696c6c6519026c6b737572706c75734f6e6c79f56d656e6572677953696e6365576800736368617267654c696d69745065726d696c6c65f66677616c6c4d731b000001a130d7de8a68757074696d654d731a000128ea6174636361726373696758407ee438570fc0f1072201594add376235e687cb87168f882c88e94e1e087b595be184505d295ecc901cb8c8a424a8fa3df0b65b28b1c2352a7ecfc4cb0b53ae01" + }, + { + "name": "event car which", + "signer": "box", + "t": "event", + "body": "ac626c706b6a33485a4f524e435748736274731b000001a130d7de306373657111637372636b4455595757516d6e2d58676461726773a16773657373696f6e703764316630633261396233653466353064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06463617273826b464e4b69626974666555306b446d34516733447a336263646b696e64696361722e77686963686677616c6c4d731b000001a130d7de94676576656e7449646c643268705932686a5958497868736576657269747964696e666f68757074696d654d731a000128f4", + "signedInputSha256": "e576ea700f4abd28075bef81b88d28414de2979912ea333d3503ec45a31cb27d", + "sig": "70a21cc4e288f019d1ffeb59456f8555b07c9f450f6182e6d2cddb5dcca37c31a4a3465c5080c429d6f01063c571edf44e4012dd0198b6bdc1fdfcc6ca3e4b88", + "envelope": "a3616258d4ac626c706b6a33485a4f524e435748736274731b000001a130d7de306373657111637372636b4455595757516d6e2d58676461726773a16773657373696f6e703764316630633261396233653466353064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06463617273826b464e4b69626974666555306b446d34516733447a336263646b696e64696361722e77686963686677616c6c4d731b000001a130d7de94676576656e7449646c643268705932686a5958497868736576657269747964696e666f68757074696d654d731a000128f46174656576656e7463736967584070a21cc4e288f019d1ffeb59456f8555b07c9f450f6182e6d2cddb5dcca37c31a4a3465c5080c429d6f01063c571edf44e4012dd0198b6bdc1fdfcc6ca3e4b88", + "frame": "010100000127a3616258d4ac626c706b6a33485a4f524e435748736274731b000001a130d7de306373657111637372636b4455595757516d6e2d58676461726773a16773657373696f6e703764316630633261396233653466353064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06463617273826b464e4b69626974666555306b446d34516733447a336263646b696e64696361722e77686963686677616c6c4d731b000001a130d7de94676576656e7449646c643268705932686a5958497868736576657269747964696e666f68757074696d654d731a000128f46174656576656e7463736967584070a21cc4e288f019d1ffeb59456f8555b07c9f450f6182e6d2cddb5dcca37c31a4a3465c5080c429d6f01063c571edf44e4012dd0198b6bdc1fdfcc6ca3e4b88" + }, + { + "name": "lp car tapped", + "signer": "box", + "t": "lp", + "body": "a7637365711264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7f20868666565646261636b8068757074696d654d731a00013c686a636f6e74726f6c52657619032e6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46563617249646b464e4b6962697466655530666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553190221696e657874456e644d73f669726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e57680069736f63536f757263656776656869636c656a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f6361724964656e7469666965644279637461706f636f6d6d616e646564526561736f6e64706c616e", + "signedInputSha256": "daabad621023f8fb32b67179de6f06ec1ed7b72dd9c83de533aa3b625fa7b439", + "sig": "89b4a6faa2af341cdc4da9ad0247aa2135f40e1aec94554cf1855392d22412cb2191b2a594084e4490faada200f68abbdcdd80b7b0bc3e7fb4ff65e5572f3fcf", + "envelope": "a361625901ada7637365711264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7f20868666565646261636b8068757074696d654d731a00013c686a636f6e74726f6c52657619032e6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46563617249646b464e4b6962697466655530666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553190221696e657874456e644d73f669726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e57680069736f63536f757263656776656869636c656a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f6361724964656e7469666965644279637461706f636f6d6d616e646564526561736f6e64706c616e6174626c7063736967584089b4a6faa2af341cdc4da9ad0247aa2135f40e1aec94554cf1855392d22412cb2191b2a594084e4490faada200f68abbdcdd80b7b0bc3e7fb4ff65e5572f3fcf", + "frame": "0101000001fea361625901ada7637365711264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7f20868666565646261636b8068757074696d654d731a00013c686a636f6e74726f6c52657619032e6a6c6f6164706f696e747381b8186269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f46563617249646b464e4b6962697466655530666d616e75616cf6666e6578745768f666706f776572570067706c7567676564f56773657373696f6e703764316630633261396233653466353067736f6341676553190221696e657874456e644d73f669726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e57680069736f63536f757263656776656869636c656a636f6d6d616e64656457f66b6e65787453746172744d73f66b706c616e50656e64696e67f46b736f635065726d696c6c65188c6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f6361724964656e7469666965644279637461706f636f6d6d616e646564526561736f6e64706c616e6174626c7063736967584089b4a6faa2af341cdc4da9ad0247aa2135f40e1aec94554cf1855392d22412cb2191b2a594084e4490faada200f68abbdcdd80b7b0bc3e7fb4ff65e5572f3fcf" } ] } diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index 60e199a7f..88b4f8713 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -65,6 +65,12 @@ type vectorSigned struct { func i64(v int64) *int64 { return &v } +// The two cars the vectors carry, by their 128-bit ids. +const ( + vectorCarTesla = "5f0c1e2d3a4b5c6d7e8f90a1b2c3d4e5" + vectorCarVolvo = "0123456789abcdef0123456789abcdef" +) + // vectorBodies are the bodies the vectors freeze, in order. func vectorBodies(grantKey []byte, boxPK []byte) []struct { name, signer, t string @@ -79,6 +85,8 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { inverter := SourceID(grantKey, "sungrow") charger := SourceID(grantKey, "easee") garage := LoadpointID(grantKey, "garage") + tesla := CarID(grantKey, vectorCarTesla) + volvo := CarID(grantKey, vectorCarVolvo) return []struct { name, signer, t string @@ -216,6 +224,45 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { }, Grant: Grant{Scopes: []string{}, Purposes: []string{}, Controller: ControllerSourceful}, }}, + // Cars (docs/cars.md). Two cars on one charger, neither named: the + // box asks, the lp says so and offers the two, the car class + // carries both levels, and car.which names the plug-in's session. + {"lp car asking", "box", MsgLP, LP{ + Header: h(15, 76_000), ControlRev: 813, + Loadpoints: []Loadpoint{{ + ID: garage, Src: charger, Plugged: true, SocSource: "unknown", + PowerW: 0, SessionWh: 0, Session: "7d1f0c2a9b3e4f50", CommandedReason: "pv_surplus_pause", + SurplusOnly: true, CarIdentifiedBy: protocol.CarIdentifiedByAsking, Choose: []string{tesla, volvo}, + }}, + Feedback: []Feedback{}, + }}, + {"car", "box", MsgCar, Cars{ + Header: h(16, 76_010), + Cars: []Car{ + {ID: tesla, SocPermille: i64(140), SocSource: protocol.CarSoCVehicle, SocAgeS: i64(540), + EnergySinceWh: 0, ChargeLimitPermille: i64(800), + Goal: &CarGoal{TargetPermille: 800, ReadyByMin: 420, Days: 0x7f}}, + // Read before it last left a charger: an estimate, whatever + // flowed since. + {ID: volvo, SocPermille: i64(620), SocSource: protocol.CarSoCVehicle, SocAgeS: i64(30_600), + EnergySinceWh: 0, SocEstimate: true, SurplusOnly: true}, + }, + }}, + {"event car which", "box", MsgEvent, Event{ + Header: h(17, 76_020), EventID: "d2hpY2hjYXIx", Ts: i64(wall + 76_020 - 60_000 - 100), + Kind: protocol.PushCarWhich, Severity: SeverityInfo, Args: map[string]string{"session": "7d1f0c2a9b3e4f50"}, + Src: charger, LP: garage, Cars: []string{tesla, volvo}, + }}, + {"lp car tapped", "box", MsgLP, LP{ + Header: h(18, 81_000), ControlRev: 814, + Loadpoints: []Loadpoint{{ + ID: garage, Src: charger, Plugged: true, SocPermille: i64(140), SocSource: "vehicle", + SocAgeS: i64(545), TargetPermille: i64(800), ReadyByMs: i64(1_791_867_600_000), + Recurring: true, Session: "7d1f0c2a9b3e4f50", CommandedReason: "plan", + CarID: tesla, CarIdentifiedBy: protocol.CarIdentifiedByTap, + }}, + Feedback: []Feedback{}, + }}, } } @@ -242,10 +289,14 @@ func TestGoldenVectors(t *testing.T) { ServiceKey: vectorKey{D: hex.EncodeToString(service.D.FillBytes(make([]byte, 32))), PK: hex.EncodeToString(servicePK)}, GrantKey: hex.EncodeToString(grantKey), } - for _, id := range []vectorID{{"src", "p1meter", ""}, {"src", "sungrow", ""}, {"src", "easee", ""}, {"lp", "garage", ""}} { - if id.Kind == "src" { + for _, id := range []vectorID{{"src", "p1meter", ""}, {"src", "sungrow", ""}, {"src", "easee", ""}, {"lp", "garage", ""}, + {"car", vectorCarTesla, ""}, {"car", vectorCarVolvo, ""}} { + switch id.Kind { + case "src": id.ID = SourceID(grantKey, id.Name) - } else { + case "car": + id.ID = CarID(grantKey, id.Name) + default: id.ID = LoadpointID(grantKey, id.Name) } out.IDs = append(out.IDs, id) diff --git a/go/protocol/cloudwire/wire.go b/go/protocol/cloudwire/wire.go index af2152f7e..cfed1c85c 100644 --- a/go/protocol/cloudwire/wire.go +++ b/go/protocol/cloudwire/wire.go @@ -389,6 +389,13 @@ func LoadpointID(grantKey []byte, loadpoint string) string { return opaqueID(grantKey, "lp\x00", loadpoint) } +// CarID is a car's opaque id, cut the same way with "car\0" from its +// 128-bit id. The id is the same on each of the owner's boxes; the opaque id +// differs per grant, so the service cannot follow a car from home to home. +func CarID(grantKey []byte, car string) string { + return opaqueID(grantKey, "car\x00", car) +} + func opaqueID(key []byte, tag, name string) string { mac := hmac.New(sha256.New, key) mac.Write([]byte(tag)) diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index 02d6ee5fb..10f80a1d7 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -128,6 +128,7 @@ const ( CapCloudTun = "cloud.tun" CapCmdExchange = "cmd.exchange" CapAccessAgents = "access.agents" + CapDerCar = "der.car" ) // AllCapabilities is every capability name the registry defines. @@ -152,6 +153,7 @@ var AllCapabilities = []string{ CapCloudTun, CapCmdExchange, CapAccessAgents, + CapDerCar, } // Scopes are an object and a verb. Roles are a projection over these, @@ -189,6 +191,10 @@ const ( ScopeGoalsRequest = "ftw.goals.request" // ScopeUpdateRequest — Ask an owner to install a version. ScopeUpdateRequest = "ftw.update.request" + // ScopeCarsRead — See the owner's cars with their VINs and identifiers and sources. + ScopeCarsRead = "ftw.cars.read" + // ScopeCarsWrite — Keep the owner's list of cars. + ScopeCarsWrite = "ftw.cars.write" ) // WriteScopes is every scope that changes something, now or once an @@ -200,6 +206,7 @@ var WriteScopes = []string{ ScopeDispatchHold, ScopeGoalsRequest, ScopeUpdateRequest, + ScopeCarsWrite, } // Roles, as the registry names them. apiauth holds what each carries. @@ -258,6 +265,18 @@ var RegistryOps = map[string]string{ "loadpoint.soc.set": "ftw.dispatch.write", // loadpoint.surplus_only.set — Charge the car from surplus PV only. "loadpoint.surplus_only.set": "ftw.dispatch.write", + // car.goal.set — Set how and when a car charges. + "car.goal.set": "ftw.dispatch.write", + // car.soc.set — Set a car's level by hand. + "car.soc.set": "ftw.dispatch.write", + // session.car.set — Say which car is on a charger. + "session.car.set": "ftw.dispatch.write", + // car.add — Add a car to the owner's list. + "car.add": "ftw.cars.write", + // car.update — Change what the box knows about a car. + "car.update": "ftw.cars.write", + // car.remove — Remove a car from the owner's list. + "car.remove": "ftw.cars.write", } // RegistryAgentOps maps each op an agent may name to the scope that opens @@ -270,12 +289,29 @@ var RegistryAgentOps = map[string]string{ "loadpoint.hold": "ftw.dispatch.hold", "loadpoint.soc.set": "ftw.goals.request", "loadpoint.surplus_only.set": "ftw.goals.request", + "car.goal.set": "ftw.goals.request", + "car.soc.set": "ftw.goals.request", } // RegistryAgentArgs is what an agent may put in each op it can name. The // box refuses any other key, kind or range, in a cmd and in an // agent.request, as it arrives. var RegistryAgentArgs = map[string][]AgentArg{ + "car.goal.set": { + {Name: "id", Kind: "car", Unit: "", Required: false, Cap: "", Label: "Car"}, + {Name: "charger_id", Kind: "loadpoint", Unit: "", Required: false, Cap: "", Label: "Charger"}, + {Name: "target", Kind: "ratio", Unit: "", Min: bound(0), Max: bound(1), Required: false, Cap: "", Label: "The level to reach"}, + {Name: "ready_by_min", Kind: "number", Unit: "", Min: bound(0), Max: bound(1439), Required: false, Cap: "", Label: "Ready by"}, + {Name: "days", Kind: "number", Unit: "", Min: bound(0), Max: bound(127), Required: false, Cap: "", Label: "Weekdays"}, + {Name: "finish_at_limit", Kind: "bool", Unit: "", Required: false, Cap: "", Label: "Charge on to the car's own limit"}, + {Name: "clear", Kind: "bool", Unit: "", Required: false, Cap: "", Label: "Remove the goal"}, + {Name: "surplus_only", Kind: "bool", Unit: "", Required: false, Cap: "", Label: "Charge from solar only"}, + }, + "car.soc.set": { + {Name: "id", Kind: "car", Unit: "", Required: false, Cap: "", Label: "Car"}, + {Name: "charger_id", Kind: "loadpoint", Unit: "", Required: false, Cap: "", Label: "Charger"}, + {Name: "soc", Kind: "ratio", Unit: "", Min: bound(0), Max: bound(1), Required: true, Cap: "", Label: "The car's charge level"}, + }, "loadpoint.hold": { {Name: "id", Kind: "loadpoint", Unit: "", Required: true, Cap: "", Label: "Charger"}, {Name: "power_w", Kind: "power", Unit: "W", Min: bound(0), Required: false, Cap: "maxW", Label: "Charging power"}, @@ -379,6 +415,8 @@ const ( ErrNotFound = "E_NOT_FOUND" // ErrClockSkew — The approval's time is too far from the box's. ErrClockSkew = "E_CLOCK_SKEW" + // ErrDuplicate — Another car already has that VIN or identifier. + ErrDuplicate = "E_DUPLICATE" ) // ErrorRetryable reports whether the app should offer a retry for a code. @@ -407,6 +445,7 @@ var ErrorRetryable = map[string]bool{ ErrNoApprovalKey: false, ErrNotFound: false, ErrClockSkew: true, + ErrDuplicate: false, } // SourceState says whether a device is answering. It is orthogonal to the @@ -456,3 +495,60 @@ var RegistryModeTiers = map[string]string{ "planner_self": "hidden", "planner_cheap": "hidden", } + +// Where a car's level comes from: the registry's car_soc_sources. +const ( + CarSoCVehicle = "vehicle" + CarSoCManual = "manual" + CarSoCAssumed = "assumed" +) + +// How the box knows which car is on a charger: the registry's +// car_identified_by. +const ( + CarIdentifiedByCharger = "charger" + CarIdentifiedByCar = "car" + CarIdentifiedByOnly = "only" + CarIdentifiedByTap = "tap" + CarIdentifiedByAsking = "asking" + CarIdentifiedByNone = "none" +) + +// CarFields is every name in the registry's car_fields. A dotted name is a +// field of the object before the dot. +var CarFields = []string{ + "id", // 128 bits as 32 lowercase hex digits that the owner's app mints and every box keeps + "boxMinted", // The box minted the id when it made the car from its old settings + "ownerId", // Reserved for the owner the car belongs to. Empty until owners are principals + "name", // The owner's name for the car + "make", // The maker when a source or the owner gave one + "vin", // The vehicle identification number that binds a source to the car + "capacityWh", // Usable battery capacity + "maxW", // The most the car's own charger takes. 0 when unknown + "phases", // The phases the car's own charger uses. 0 when unknown + "socPermille", // The level. The car's own number while socEstimate is false. Absent when the box has none + "socSource", // Where the level comes from. One of car_soc_sources + "socAgeS", // Seconds since the car reported the level or someone set it + "energySinceWh", // Energy delivered to the car since then. Negative when a V2X session took energy out + "socEstimate", // The box counts the level as an estimate + "chargeLimitPermille", // The charge limit the car reported with its level + "goal", // What the car should reach and when. Absent when none + "goal.targetPermille", // The level to reach + "goal.readyByMin", // Ready time in minutes after midnight in the box's time zone + "goal.days", // ISO weekdays with Monday in bit 0. 0 is once at the next ready time and 127 every day + "goal.finishAtLimit", // Charge on to the car's own limit rather than to targetPermille + "surplusOnly", // The car charges from surplus PV only wherever it is plugged in + "identifiers", // RFID tags and MAC addresses and eMAIDs that name the car to a charger + "sources", // The vehicle drivers on this box that report the car's level. Each box has its own +} + +// ChargerCarFields is every name in the registry's charger_car_fields. A dotted name is a +// field of the object before the dot. +var ChargerCarFields = []string{ + "session", // This plug-in's id. A tap names it and is refused once it has changed + "carId", // The car on the charger. Empty when the box does not know which + "carIdentifiedBy", // How the box knows. One of car_identified_by + "carCorrected", // A better rule replaced the car the box first named + "carCorrectedFrom", // The car the box named before that + "choose", // While asking the cars a tap may pick. At most four +} diff --git a/go/protocol/messages.go b/go/protocol/messages.go index 2a1572716..8479b8dc4 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -188,6 +188,9 @@ type CloudSession struct { Sources map[string]string `cbor:"sources"` // Loadpoints maps each opaque loadpoint id to the loadpoint id. Loadpoints map[string]string `cbor:"loadpoints"` + // Cars maps each opaque car id to the car's id. Absent from a box + // without cars. + Cars map[string]string `cbor:"cars,omitempty"` } // HintAppUpdate tells the app it is behind. It is a hint, not an error: the @@ -355,6 +358,20 @@ type NowLoadpoint struct { SocPermille *int64 `cbor:"socPermille,omitempty"` // PowerW is the charger's draw, positive while charging. PowerW int64 `cbor:"powerW"` + // The car on the charger (docs/cars.md): SocPermille is its level, and + // these say where the level came from and how the box knows the car. + // While the box asks, Session names the plug-in a tap answers and Choose + // lists the cars a tap may pick. To fit lane 0, car ids travel here as + // their 16 bytes, the 32 hex digits decoded: an asking charger with four + // cars takes about 180 B of the 512 B frame. + SocSource string `cbor:"socSource,omitempty"` + SocAgeS *int64 `cbor:"socAgeS,omitempty"` + SocEstimate bool `cbor:"socEstimate,omitempty"` + Session string `cbor:"session,omitempty"` + CarID []byte `cbor:"carId,omitempty"` + CarIdentifiedBy string `cbor:"carIdentifiedBy,omitempty"` + CarCorrected bool `cbor:"carCorrected,omitempty"` + Choose [][]byte `cbor:"choose,omitempty"` } // -------------------------------------------------------------------------- @@ -424,6 +441,11 @@ type CmdResult struct { State string `cbor:"state"` Observed *Observed `cbor:"observed,omitempty"` Error *ErrorBody `cbor:"error,omitempty"` + // Car is the car as the box holds it after a car op, and Charger the + // charger's car after session.car.set (docs/cars.md): read back, never + // the request's echo. + Car *Car `cbor:"car,omitempty"` + Charger *ChargerCar `cbor:"charger,omitempty"` } type Observed struct { diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index 80cc24182..d2249d072 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -23,6 +23,7 @@ const ( PushAgentExpiring = "agent.expiring" PushApprovalKeySet = "approval.key.set" PushOwnerAdded = "owner.added" + PushCarWhich = "car.which" ) // PushSentence is one lock-screen sentence, exactly as the app wrote it. @@ -49,4 +50,5 @@ var PushSentences = map[string]PushSentence{ PushAgentExpiring: {Title: "Access ends tomorrow", Body: "An assistant's or Sourceful's access to your box runs out in a day. Renew it in the app if it should go on."}, PushApprovalKeySet: {Title: "A phone can now approve access", Body: "A phone set up the key it uses to approve access to your box. If that was not you, remove the phone in the app."}, PushOwnerAdded: {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."}, + PushCarWhich: {Title: "Which car is plugged in?", Body: "Tap the car so the box plans for its level and goal."}, } From a515d7d638ee5e6d689bcf403ecf40f174ac6e81 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:53:03 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(cars):=20the=20second=20review=20?= =?UTF-8?q?=E2=80=94=20rule=201=20with=20no=20identifiers,=20the=20usual?= =?UTF-8?q?=20charger,=20flaps=20and=20old=20readings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An identifier no car lists makes a visitor only once some car lists identifiers. A car the box made from a charger has it as its usual charger. An unplug counts after 30 seconds; a reading more than an hour older than the plug-in does not start the session. car.goal.set takes its goal flat, as the box's other ops and agent_args do. Results, caps and refusals as Core answers them. Co-Authored-By: Claude Opus 5.5 --- docs/cars.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/cars.md b/docs/cars.md index 85b758630..47dc3e6ed 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -108,8 +108,9 @@ At each plug-in the box asks, in this order (`carIdentifiedBy`): on another charger, and it has not said it is somewhere else. The car's usual charger counts the same way: a car the box made from a charger's old settings has that charger as its usual one, and a plug-in there names - it when no other car is on it as its usual charger and the car is not on - another charger. If the car's source says, within a minute of the + it when no other car that could be here claims the charger, that is when + each of them has a usual charger elsewhere. A car with none, as one an + owner added since, could be on any charger, and the box asks. If the car's source says, within a minute of the plug-in, `at_site` false or that its cable is out, the session is a visitor's (`none`). One car is on one charger at a time: rules 2 and 3 skip a car another session has. @@ -185,12 +186,16 @@ for what the op acts on, a level as a fraction from 0 to 1. the box holds them after the op, or `charger`, the charger's car after `session.car.set`; `car.soc.set` also reads the level back in `observed`, as a fraction. A result rides lane 0, so the app reads the rest of a car from -`GET /api/cars`. A car has at most eight identifiers and eight sources, of at -most 64 characters each. Refusals after the ack: -`E_NOT_FOUND` for an unknown car or charger, `E_PRECONDITION` with reason -`session` for a plug-in that has ended, `E_UNAVAILABLE` with reason -`unplugged` when nothing is plugged in, and `E_DUPLICATE`. A box without cars -answers `E_UNAVAILABLE` with `subsystem: cars`. +`GET /api/cars`; in a session whose lane 0 is 256 B a full car does not fit, +and the result comes without `car` or `charger`. A box keeps at most 32 +cars and remembers the last 64 it removed. A car has at most eight +identifiers and eight sources, of at most 64 characters each. Refusals after +the ack: `E_NOT_FOUND` for an unknown car or charger, `E_PRECONDITION` with +reason `session` for a plug-in that has ended or `elsewhere` for a tap +naming a car another charger has, `E_UNAVAILABLE` with reason `unplugged` +when nothing is plugged in, `E_DUPLICATE`, and `E_CAP_EXCEEDED` with reason +`cars` for a car past the 32. A box without cars answers `E_UNAVAILABLE` +with `subsystem: cars`. ## car.which