Skip to content

feat(cars): the car apart from the charger, in Core - #1650

Merged
frahlg merged 4 commits into
masterfrom
claude/car-core
Oct 16, 2026
Merged

frahlg merged 4 commits into
masterfrom
claude/car-core

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

What

The box keeps cars apart from its chargers (step 2 of design/vehicles/car-entity-2026-10-15.md in the FTW stack workspace; docs/cars.md here). A car belongs to its owner and visits chargers. A charging session is one car, or none, on one charger.

The contract (#1647, srcfl/ftw-webapp#92, srcfl/ftw-app#70) is merged; this PR sits on master after it. Three commits: feat(cars): the car apart from the charger, in Core (both contract and code reviews and the second review squashed in), feat(cars): agents read and ask by car, and the plan push names the car, fix(cars): the reserve holds to the session's car, and a car's goal has a deadline before the replan (the check against #1657), and fix(cars): the plan is pending before a charger shows a new car.

The car runtime (internal/cars)

  • Keeps the owners' cars in one state.db row (cars): id, name, make, VIN, capacity, own charger limit, plug-in assumption, goal, surplus choice, identifiers, sources, the car's level (last reading, source, time, energy since), each plug-in's session, and tombstones.
  • Decides which car each session is, and hands that car's capacity, goal, surplus choice and level to the loadpoint, which keeps the session's energy and the estimate the planner and the controller read. A lock orders the runtime's decisions with the loadpoint calls that carry them out, so a tap and a tick never land out of order.
  • The search-based pairing is gone: the anchor loop, the auto-wake status, the refresh target, the completion check, the planner's start level and the API's car overlay read the session's car's own sources. The two refusals on ambiguity go with them. telemetry.PickBestVehicle*, PlanningVehiclePick, PickVehicleForAnchor, PickVehicleForCompletion, Manager.PlanningStartSoC and Manager.ApplyVehicleProfile have no caller left and are removed.

The level rule

  • A reading from the car stays exact until 1 kWh has reached it, however old. While exact, the level shown, sent and planned from is the car's own number. Fredrik's box on 15 October (tesla-ha, 14 %, nine minutes old, 0 Wh since) now shows soc_source: vehicle.
  • A level read before the car last left a charger is an estimate and does not start the next session, nor does one more than an hour older than the plug-in (master's VehicleAnchorMaxAge): that session starts from the plug-in assumption until the car reports.
  • 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 its session, its car and its level, and what flowed before the flap stays the car's.
  • A level Core adopts from the loadpoint (after a restart, or set while the box asked) keeps its reading's time and the energy since, so it shows as an estimate once more than 1 kWh has flowed.
  • energySinceWh is signed, for V2X; past 1 kWh either way is an estimate.
  • Session energy is credited to the session's car at unplug. A switch moves it, and the car it had gets its level from before the session back.
  • A visitor gets no level; current_soc is left out of /api/loadpoints, the watch's now and FTW Cloud's lp. Its capacity is the charger's vehicle_capacity_wh, so a goal set for it still gets closer.
  • VehicleMaxAge (five minutes) now governs only a car's charging state (wake, goal done).

Which car is on a charger

  1. The charger: OCPP idTag, MAC or eMAID, now reported at every transaction start (before, only when the tag changed), and kept if it arrives before the plug-in. An identity no car lists makes a visitor only once some car lists identifiers; before that it names nobody, so a one-car OCPP box with no vehicles[] profile keeps charging to its goal (OCPP 1.6 sends an idTag at every start). The log leaves the identifier out.
  2. The car's own source: its cable went in within a minute of the plug-in and it does not say it is elsewhere, or it charges at this site (at_site: true) while this charger alone delivers.
  3. The only car that could be here: one car is on one charger at a time. If its source says within the minute that it is elsewhere, a visitor. The usual charger counts the same way: a car Core made from a charger's settings has it as its usual one, and a plug-in there names it while every other car that could be here has a usual charger elsewhere. A two-charger home works as before cars without a tap. A car with no usual charger, as one an owner adds, could be on any charger, and the box asks; this is my reading of "no other car claims it".
  4. A tap after car.which, which goes out once per plug-in after a minute, with the plug-in's session and at most four cars not on another charger; the charger says asking meanwhile, and listeners such as the plan push hear it. A tap names the session; one for an ended plug-in is refused, and so is one naming a car another charger has (E_PRECONDITION, reason elsewhere). "None of these" is a visitor. A tap over the box's own answer is final.
  5. Otherwise a visitor: surplus only, with a goal, level and surplus choice of its own through the charger's routes or car.goal.set/car.soc.set with charger_id.

A later rule corrects an automatic answer only from the charger or the car, never by a worse rule, and only with newer evidence; car_corrected and car_corrected_from show it, and Core logs it.

The planner and #1657's reserve

Takes capacity, level, goal and charge limit from the session's car (evPlanCar). This fixes the old code that read cfg.Loadpoints[].VehicleCapacityWh whatever car was plugged in.

  • Power: loadpoint.LimitPower holds the plan and the reserve (DeadlineNeed) to the lower of the charger's maximum and the car's own maxW, keeping the charger's lowest step when that is above the car's maximum. Before, a Volvo whose charger takes 7.4 kW reserved 11 kW on an 11 kW Easee. The car's phases are already in its maxW, so they bound nothing further; the unread CarPhases is gone.
  • Charge limit: the car's own limit caps the plan and the reserve alike, and only while its level is its own exact reading. The binding carries it as State.CarChargeLimit.
  • Deadline: a goal the car brings went through setScheduleAs, which clears the loadpoint's rolled deadline until the next control tick, and the replan right after a tap or a goal edit planned without the car for up to 30 s while the reserve charged at full power. The runtime now rolls the schedules before every replan and after its first sync, and replans when a sync hands a loadpoint a goal or a surplus choice.
  • A visitor's goal (Fredrik's decision): setting it ends the session's surplus-only charging, because someone who asks for a goal wants it met. The plan and the reserve plan from empty; the level stays unknown and is never shown as a number. Clearing the goal brings surplus-only back.

Moving what exists

On first start: Car 1 from the loadpoint's vehicle_capacity_wh, plugin_soc, loadpoint_schedule:<id> and loadpoint_surplus_only:<id> (Car 2… from further loadpoints), marked boxMinted, each with that loadpoint as its usual charger; each vehicles[] profile becomes a car, and one charger with one profile is one car. A later edit to a profile in config.yaml reaches its car, field by field, leaving what the owner set since. Each vehicle driver binds by VIN, or to the only car, which then takes the VIN and maker; configured vehicle drivers bind before their first reading, so an asleep car can be woken. On the home box, tesla-ha reports make: Tesla and its VIN as its serial. A restart keeps each session's car: the runtime waits for the charger to report, and the first binding keeps a level the loadpoint already trusts. The loadpoint rows keep being written for a rollback. An unreadable cars row is kept as cars:unreadable and the cars are made again.

One release of overlap

/api/loadpoints, POST /target, PUT /schedule, POST /soc, POST /vehicle, loadpoint.soc.set, loadpoint.surplus_only.set, and config.yaml edits of vehicle_capacity_wh, plugin_soc and surplus_only act on the session's car, or with nothing plugged in the car the charger last had or the only car; while the session's car is unknown, on the session alone. A route that arrives after the plug-in but before the tick opens the session and asks the identification order at once, so it never lands on the car the charger last had. POST /vehicle changes the car only after config.yaml holds the size. A config reload keeps a visitor on the charger's battery size. Legacy fields map as the table in docs/cars.md: an estimate is inferred, a manual level inferred, a visitor unknown.

New

  • GET /api/cars, with VIN, identifiers and sources only for callers holding ftw.cars.read; it lists tombstones as removed.
  • The car ops on the session. Arguments follow the box's convention; car.goal.set takes target, ready_by_min, days, finish_at_limit and clear flat. car.add checks every field before it adopts a box-minted car by VIN; a rekey brings an id back from its tombstone. At most 32 cars (E_CAP_EXCEEDED, reason cars) and 64 tombstones. cmd.result carries a brief car or the charger's car when it fits the session's lane 0 bucket: a full brief car is 345 B, so a 256 B session gets the result without it. Unknown car or charger is E_NOT_FOUND; a VIN or identifier another car has, E_DUPLICATE.
  • Removing a car leaves its drivers unbound, so they never move to the car that is left.
  • der.car in hello_ok.caps. The watch's now names each charger's car.
  • car.which notification rule, on by default: until someone taps, a car with a twin charges as a visitor. Its push and FTW Cloud event name the charger.
  • FTW Cloud's lp.session is Core's own plug-in id, the same across a restart, as protocol-cloud.md says; a box without the cars runtime still sends the plug-in count.
  • A car whose own charger takes less than the charger's lowest step keeps that step in the plan rather than none.
  • cars.Runtime.OnChange is the hook for anything that pushes when a session's car changes (for feat(appproto): push the plan to streaming phones when the box replans #1643's plan push).

Not in this PR

  • FTW Cloud: lp now carries each charger's car by an opaque id, but the car class, event.cars and the session table's cars are not sent by this PR. They come with step 6 (srcfl/ftw-cloud); cloudlink.ProjectCars is the projection the car class will use. car.which reaches FTW Cloud with its charger and args.session.
  • Rule 2 needs at_site from the vehicle drivers, which none emits yet: until then "plugged in within a minute" counts from any source bound to a car on this box, so a car plugged in elsewhere in the same minute as a plug-in here could be taken for it. device-drivers is next.
  • No wire field carries the plug-in assumption (plugin_soc); the box's page and config.yaml still set it.
  • phases caps nothing yet; the controller follows the charger's phase mode.
  • The design's "cheapest quarters" for a visitor without a goal: with neither a level nor a goal there is no energy to place. Fredrik's call whether to give a visitor a default goal.

Where the design was wrong or silent

  • A level carried across an unplug is not a better start than the plug-in assumption.
  • The design has ev_session:* rows gain the car id; the binding lives in the cars row, keyed by the charger's session id, or by its energy counter when the charger has none.
  • "The car says it" needs a time and a place: "charging here" needs at_site: true, and a car on another charger is never this one's.
  • The design said OCPP identifies a car; Core only heard a card when it changed, so the same card's next plug-in named nobody.
  • One charger plus one OCPP profile would have made two cars and asked at every plug-in.

Tests

  • internal/cars, second review: an idTag on a box whose cars list none names the only car and keeps its goal, an unknown card once cars list theirs is a visitor; a profile edit reaches its car; a removed car's driver stays unbound; a reload keeps a visitor's capacity; a bad car.add does not adopt, a tap may not take a car on another charger, a rekey clears the tombstone, the caps; an adopted level keeps its reading's time; an old reading does not start a session; a flap keeps the session and its energy; the usual charger names its car and a car added since makes the box ask, which tells listeners; a route before the tick acts on the session.
  • internal/cars: the home box becomes Car 1 bound to tesla-ha with the same goal; two cars on one Easee say asking, raise car.which once, and a tap sets the Volvo's capacity, goal and surplus choice; a mid-session switch moves the energy and restores the old car's level; a nine-minute-old reading with 0 Wh since stays "from the car"; the level rule as a table; a visitor never gets a level; a tap for an ended plug-in; "none of these"; at most four cars offered; ids, adoption, duplicates and tombstones; an unknown card, the only car elsewhere, a final tap; one car on one charger; a restart before the charger reports; the first start while charging; config edits; the site rule alone; profiles; the ops; an unreadable row. Run with -race.
  • cmd/ftw: the plan's inputs follow the tapped car (capacity, level, goal, the car's own 7.4 kW); a car below the charger's lowest step; FTW Cloud's session id.
  • internal/notifications: car.which names its charger.
  • The reserve (internal/cars/reserve_test.go, the planner's SlotDirectiveAt with no plan): a single car at 80 % by 07:00 keeps its goal; asking and a visitor with no goal get none; a tap to the Volvo with no control tick after it gets the reserve at most 7.4 kW; a switch from the Tesla to the Volvo takes the Volvo's capacity and level; a switch to a visitor clears it at once; a visitor's goal is met from empty with the level unknown, and clearing it brings surplus-only back; the car's charge limit caps the need; a steady session does not replan each tick. Checked to fail without the roll and without the car's limit. internal/loadpoint: LimitPower and DeadlineNeed hold to the car's power and limit.
  • Agents and the plan push: an agent reads the cars by its own ids, asks for a car's goal by its id (unknown, raw and malformed ids and goals refused as the request arrives; the right car changes; the outcome names no car), and reads a plan whose chargers and cars go by its ids while a phone's do not (internal/appproto); ProjectLP cuts car ids and ProjectCars carries nothing that names a car (internal/cloudlink); the redactor and cars.get built from the real runtime name no car, and a tap reaches a streaming phone pending with the car (cmd/ftw).
  • internal/appproto: the flat goal and its bad arguments, elsewhere, a full brief car's bytes and the 256 B session; a watch makes the tap; only an owner keeps the list; refusals, an ended session, bad arguments; a visitor's own goal; a box without cars; an asking charger's bytes in now.
  • internal/api: the soc source mapping, the car's own number while exact, a visitor's level left out, the redacted list, the battery size route.
  • Rewritten with intent, not deleted: the guest-card test (now two: no identifiers listed, and identifiers listed), the harness's unplug (now held for 30 s), the telemetry picker tests (now VehicleObservations), the planning-level tests and fix(loadpoint): accept newer delayed car charge readings #1648's new anchor-order test (both read the loadpoint's level now that PlanningStartSoC is gone), the vehicle-profile and capacity tests (now SetCarBinding), the completion test that refused a second charger, the refresh-driver test, the API soc-source and planning-level tests, the notification defaults test.
  • make verify on 4ed3080 (master 4a1d81d): 68 Go packages pass, and vet, build, the protocol and cloudlink boundaries, compose migration, container boundary, release workflow and native solver checks are clean. The exception is internal/drivers TestPixiiRecoversAfterTransportOutage and TestPixiiPVSunsetReadingsStayFresh, which fail on this laptop on a clean master checkout too while master's CI passes; this PR does not touch drivers. internal/cars also passes with -race.

Agents (#1646 landed first, so the wiring is here)

  • agent_args kind car: an agent names a car in car.goal.set and car.soc.set by the id cars.get gave it, cut with the agent key; the box resolves it like a charger id (badArg on no match) and runs the op's own checks as the agent.request arrives, so an owner is never asked about a car or a goal that cannot be.
  • cars.get answers with cloudlink.ProjectCars under the agent key: each car's level, source, age, goal and surplus choice, and no name, maker, VIN, identifier or source. The same projection is ready for FTW Cloud's car class.
  • cloudlink.ProjectLP cuts carId, carCorrectedFrom and choose, and Core now fills them in its lp: an agent's loadpoints.get and FTW Cloud's lp name each charger's car by an opaque id.
  • An agent's command outcome names the car and the charger's car by its own ids, with the brief car's level and goal only.
  • An agent's plan.get names chargers and cars by its own ids. Before, it carried the box's charger names, as feat(agents): enforce agent and support access in Core #1646 left it.
  • The redactor takes the cars' names, VINs, identifiers and sources out of logs.get and config.get (agentPorts.names()).
  • docs/agents.md lists the car ops and drops "cars.get until the car store".

The plan push

chargers[].carId (#1643) is the session's car. The plan feed hears of a change only from the planner, after RequestReplan marks the plan pending. So the runtime asks for a replan before it hands the loadpoints a new car, and again after the schedules roll; the second supersedes the first. A phone therefore never sees the new car beside the old plan as if that were current. TestATapReachesAStreamingPhoneWithTheCar holds the tap's replan in its read of the loadpoints, as a real solve takes seconds, and asks that the first plan naming the Volvo be pending and arrive within the feed's 100 ms settle plus 100 ms; it passed 50 runs with -race. Its first version failed in CI because its replan failed at once without prices and had ended before the feed's folded push was written.

Paired PRs

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T18:28:59.421148Z a1ca78c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1ca78c6d5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// setCapacityLocked changes how delivered energy turns into a level, and
// keeps the level the session shows now, as a config reload does.
func setCapacityLocked(lp *loadpointRuntime, capacityWh float64) bool {
lp.VehicleCapacityWh = capacityWh

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve fallback capacity for cars without a size

When a visitor or a newly added car with no capacity_wh is bound, b.CapacityWh is zero and this overwrites the loadpoint's internal capacity. Although snapshot reports a 60 kWh fallback, Observe and SeedSessionLevel calculate with the internal zero, so delivered energy never advances the session SoC; every replan can therefore start an unknown-capacity car from the same level and repeatedly over-allocate charging. Treat zero as the documented fallback/default rather than installing zero into the runtime.

Useful? React with 👍 / 👎.

Comment thread go/internal/cars/ops.go
Comment on lines +279 to +281
if car == nil {
r.mu.Unlock()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain charger SoC until the session car is bound

If OCPP NotifyEVChargingNeeds reports PresentSoC at session startup before the next control tick has resolved s.CarID, this branch discards the reading. ChargerIdentified only records the identifier and also waits for that tick, so even when the charger has already named a known car, the subsequent sync seeds the loadpoint from the car's old/assumed level and overwrites the SoC that main.go just anchored. Store the charger reading on the pending session or resolve the identified car before dropping it.

Useful? React with 👍 / 👎.

Comment thread go/internal/api/loadpoint_vehicle.go Outdated
Comment on lines +26 to +30
if s.deps.Cars != nil {
// The size is the car's (docs/cars.md); the config keeps it too, so
// a box rolled back to before cars finds it where it was.
if err := s.deps.Cars.SetViewCarCapacity(id, body.CapacityWh); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Battery size could not be saved. The previous size is still in use."})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate and persist the charger before mutating its car

This persists the car capacity before confirming that the loadpoint exists in Cfg and before SaveConfig succeeds. Consequently, a request for an unknown charger can mutate the sole car and then return 404, and a configuration write failure returns 500 claiming the previous size remains in use even though the cars row and live planner already use the new size. Defer this mutation until after charger validation and successful configuration persistence, or roll it back on failure.

Useful? React with 👍 / 👎.

Comment thread go/internal/cars/ops.go
r.mu.Unlock()
return ErrUnknownCar
}
r.rec.Cars = slices.DeleteFunc(r.rec.Cars, func(c *Car) bool { return c.ID == id })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep removed cars' sources explicitly unbound

When a car with bound vehicle sources is removed, those source names disappear with the car but are not added to rec.Unbound. On the next telemetry tick, autoBindLocked can therefore attach them to the sole remaining car (for example when that car has no VIN), causing the deleted car's SoC and identity to steer the remaining car. Record the removed car's sources as unbound just as UpdateCar does when an owner removes a source.

AGENTS.md reference: AGENTS.md:L20-L21

Useful? React with 👍 / 👎.

frahlg and others added 3 commits October 16, 2026 07:37
The box keeps its owners' cars in one state.db row and decides which car
each charging session is: the charger's identity, the car's own report
within a minute, the only or usual car, one tap after car.which, or a
visitor. The session's car gives the loadpoint its capacity, goal,
surplus choice and level, and the planner reads them. A car reading
stays exact until 1 kWh has reached it. The search-based pairing and its
refusals on ambiguity are gone. The loadpoint routes and ops act on the
session's car for one release. Migration makes Car 1 from the loadpoint
and a car from each vehicles[] profile; nothing the owner set is lost.

docs/cars.md is the contract; two reviews shaped it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1646 landed first, so this does the wiring left to whichever came
second. An agent names a car in car.goal.set and car.soc.set by the id
cars.get gave it (agent_args kind car), and the box checks the car and
the goal as the request arrives. cars.get answers with the car class
under the agent key: levels and goals, nothing that names a car. The
redactor takes the cars' names, VINs, identifiers and sources out of
logs and config. loadpoints.get and FTW Cloud's lp carry each charger's
car by an opaque id, and an agent's plan.get and command outcomes name
chargers and cars by its own ids.

The plan's chargers carry the session's car. A change of car replans
before it tells listeners, so a phone sees the new car on its charger at
once, with the plan pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as a deadline before the replan

#1657's reserve took its power from the charger and its target from the
goal alone: a Volvo whose own charger takes 7.4 kW reserved 11 kW on an
Easee. LimitPower moves into loadpoint, and DeadlineNeed and the plan
both hold to the lower of the charger and the car, keeping the charger's
lowest step, and to the car's charge limit while its level is exact. The
car's binding carries that limit; its phases bound nothing beyond its
own rating, so the unread CarPhases goes.

A goal the car brings cleared the loadpoint's rolled deadline until the
next control tick, and the replan right after a tap planned without the
car. The runtime rolls the schedules before every replan and after its
first sync, and replans when a sync hands a loadpoint a goal or a
surplus choice.

A visitor's goal ends its surplus-only charging: someone who asks for a
goal wants it met, and the plan and the reserve plan from empty while
the level stays unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tap test failed in CI with the Volvo's first plan not pending. The
order was right: the feed hears of a change only from the planner, after
RequestReplan marks the plan pending. But the feed folds changes within
its 100 ms settle, and the test's replan failed at once without prices,
so it had ended before the folded push was written. The test now holds
the tap's replan in its read of the loadpoints, as a real solve takes
seconds, and still asks that the first plan naming the Volvo be pending.

A real gap remained: the loadpoint showed the new car a moment before
the replan request marked the plan pending, and a push written then
showed the car beside the old plan as current. The runtime now asks for
a replan before it hands the loadpoints a change and again after the
schedules roll; the second supersedes the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant