Repository navigation
feat(cars): the car apart from the charger, in Core - #1650
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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 |
There was a problem hiding this comment.
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 👍 / 👎.
| if car == nil { | ||
| r.mu.Unlock() | ||
| return |
There was a problem hiding this comment.
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 👍 / 👎.
| 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."}) |
There was a problem hiding this comment.
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 👍 / 👎.
| r.mu.Unlock() | ||
| return ErrUnknownCar | ||
| } | ||
| r.rec.Cars = slices.DeleteFunc(r.rec.Cars, func(c *Car) bool { return c.ID == id }) |
There was a problem hiding this comment.
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 👍 / 👎.
cb79e57 to
3162c71
Compare
dd60c34 to
743bf43
Compare
743bf43 to
a9bfc8a
Compare
241c060 to
0af71ed
Compare
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>
0af71ed to
4ed3080
Compare
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>
What
The box keeps cars apart from its chargers (step 2 of
design/vehicles/car-entity-2026-10-15.mdin the FTW stack workspace;docs/cars.mdhere). 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), andfix(cars): the plan is pending before a charger shows a new car.The car runtime (
internal/cars)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.telemetry.PickBestVehicle*,PlanningVehiclePick,PickVehicleForAnchor,PickVehicleForCompletion,Manager.PlanningStartSoCandManager.ApplyVehicleProfilehave no caller left and are removed.The level rule
soc_source: vehicle.VehicleAnchorMaxAge): that session starts from the plug-in assumption until the car reports.energySinceWhis signed, for V2X; past 1 kWh either way is an estimate.current_socis left out of/api/loadpoints, the watch'snowand FTW Cloud'slp. Its capacity is the charger'svehicle_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
vehicles[]profile keeps charging to its goal (OCPP 1.6 sends an idTag at every start). The log leaves the identifier out.at_site: true) while this charger alone delivers.car.which, which goes out once per plug-in after a minute, with the plug-in'ssessionand at most four cars not on another charger; the charger saysaskingmeanwhile, 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, reasonelsewhere). "None of these" is a visitor. A tap over the box's own answer is final.car.goal.set/car.soc.setwithcharger_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_correctedandcar_corrected_fromshow 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 readcfg.Loadpoints[].VehicleCapacityWhwhatever car was plugged in.loadpoint.LimitPowerholds the plan and the reserve (DeadlineNeed) to the lower of the charger's maximum and the car's ownmaxW, 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'sphasesare already in itsmaxW, so they bound nothing further; the unreadCarPhasesis gone.State.CarChargeLimit.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.Moving what exists
On first start: Car 1 from the loadpoint's
vehicle_capacity_wh,plugin_soc,loadpoint_schedule:<id>andloadpoint_surplus_only:<id>(Car 2… from further loadpoints), markedboxMinted, each with that loadpoint as its usual charger; eachvehicles[]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-hareportsmake: Teslaand 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 unreadablecarsrow is kept ascars:unreadableand 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 ofvehicle_capacity_wh,plugin_socandsurplus_onlyact 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 /vehiclechanges 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 indocs/cars.md: an estimate isinferred, a manual levelinferred, a visitorunknown.New
GET /api/cars, with VIN, identifiers and sources only for callers holdingftw.cars.read; it lists tombstones asremoved.car.goal.settakestarget,ready_by_min,days,finish_at_limitandclearflat.car.addchecks 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, reasoncars) and 64 tombstones.cmd.resultcarries 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 isE_NOT_FOUND; a VIN or identifier another car has,E_DUPLICATE.der.carinhello_ok.caps. The watch'snownames each charger's car.car.whichnotification 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.lp.sessionis 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.cars.Runtime.OnChangeis 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
lpnow carries each charger's car by an opaque id, but thecarclass,event.carsand the session table'scarsare not sent by this PR. They come with step 6 (srcfl/ftw-cloud);cloudlink.ProjectCarsis the projection the car class will use.car.whichreaches FTW Cloud with its charger andargs.session.at_sitefrom 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.plugin_soc); the box's page and config.yaml still set it.phasescaps nothing yet; the controller follows the charger's phase mode.Where the design was wrong or silent
ev_session:*rows gain the car id; the binding lives in thecarsrow, keyed by the charger's session id, or by its energy counter when the charger has none.at_site: true, and a car on another charger is never this one's.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 badcar.adddoes 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 sayasking, raisecar.whichonce, 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.whichnames its charger.internal/cars/reserve_test.go, the planner'sSlotDirectiveAtwith no plan): a single car at 80 % by 07:00 keeps its goal;askingand 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:LimitPowerandDeadlineNeedhold to the car's power and limit.internal/appproto);ProjectLPcuts car ids andProjectCarscarries nothing that names a car (internal/cloudlink); the redactor andcars.getbuilt 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 innow.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.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 thatPlanningStartSoCis gone), the vehicle-profile and capacity tests (nowSetCarBinding), 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 verifyon 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 isinternal/driversTestPixiiRecoversAfterTransportOutageandTestPixiiPVSunsetReadingsStayFresh, which fail on this laptop on a clean master checkout too while master's CI passes; this PR does not touch drivers.internal/carsalso passes with-race.Agents (#1646 landed first, so the wiring is here)
agent_argskindcar: an agent names a car incar.goal.setandcar.soc.setby the idcars.getgave it, cut with the agent key; the box resolves it like a charger id (badArgon no match) and runs the op's own checks as theagent.requestarrives, so an owner is never asked about a car or a goal that cannot be.cars.getanswers withcloudlink.ProjectCarsunder 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.ProjectLPcutscarId,carCorrectedFromandchoose, and Core now fills them in itslp: an agent'sloadpoints.getand FTW Cloud'slpname each charger's car by an opaque id.plan.getnames 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.logs.getandconfig.get(agentPorts.names()).The plan push
chargers[].carId(#1643) is the session's car. The plan feed hears of a change only from the planner, afterRequestReplanmarks 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.TestATapReachesAStreamingPhoneWithTheCarholds 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
plugged,at_site), ftw-app (car view, chip, buttons), the box page, ftw-cloud.🤖 Generated with Claude Code