Repository navigation
feat(appproto): push the plan to streaming phones when the box replans - #1643
Conversation
The app read the car's plan only when the car started or stopped drawing, so a car plugged in for a night charge kept the old plan on the phone while the box's own page showed the new one. The planner now says when what a plan reader sees moves: a published plan, or the newest replan ending without one. One feed per box waits 250 ms, so a change that replans twice is one push, skips while a newer plan is being made, and sends the session plan to every streaming phone whose role may read the plan. The plan carries each car's windows, plug state, pending/outdated and shortfall, read the way GET /api/loadpoints reads them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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: ca632c1bb9
ℹ️ 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".
| // it when the replacement fails. A later retry must publish a new | ||
| // plan before dispatch resumes; queuing work alone is not enough. | ||
| s.failedReplanGeneration = request.generation | ||
| changed = s.planChanged |
There was a problem hiding this comment.
Mark failed-replan pushes as stale
When the newest replan fails within mpc.MaxPlanAge of the previous plan, this callback pushes a change even though sendPlan still builds the payload from Plans.Latest(), and planFrom marks staleness only by plan age. The phone therefore receives the old slots with stale: false while Core has stopped dispatching that plan; sites without configured chargers do not even receive a per-car Outdated hint. Pass the PlanSnapshot().Outdated state into the wire plan before sending the failure notification.
AGENTS.md reference: AGENTS.md:L22-L23
Useful? React with 👍 / 👎.
| if h.cfg.PlanCars != nil { | ||
| body.Cars = h.cfg.PlanCars.PlanCars(h.cfg.Clock.Now()) | ||
| } |
There was a problem hiding this comment.
Bound car data before retrying slot truncation
For configurations with sufficiently many loadpoints, windows, or long loadpoint IDs, Cars alone can exceed the largest 16 KiB bulk bucket. The loop below only truncates Slots, so it eventually reaches one slot and returns an error without ever reducing the oversized car payload; an unsolicited push is silently lost, while a plan.get error propagates from Handle and closes the app session. Cap or progressively trim the car/window data as part of the same size-fitting loop.
Useful? React with 👍 / 👎.
…r beside it A car is its own entity apart from the charger (#1647), and in that contract cars means real cars. The plan's per-charger share is now chargers/PlanCharger, keyed by the loadpoint id, with an optional carId: the session's car, empty until Core keeps cars. Whatever applies session.car.set requests a replan, and the plan follows as every replan does. The vector pins carId too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app read the car's plan from /api/loadpoints only when the car started or stopped drawing, so a car plugged in for a night charge kept the old plan on the phone while the box's page showed the new one. srcfl/ftw#1643 pushes the session plan when the box replans, with each car's windows, cable, pending/outdated and shortfall. The app takes them at once: Now's strip, the plan view, the car view and PlanShare (the widgets and the watch) move with the push, and a cable that moved is read once for the rest of the car. A box whose plan comes without cars is read each quarter while a car is plugged in, and every 15 s while it says the plan is being made or for 30 s after FTW Cloud says a cable moved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de it srcfl/ftw#1643 now sends chargers/PlanCharger keyed by the loadpoint id, with an optional carId (the session's car, empty until Core keeps cars), since in the car contract (srcfl/ftw#1647) cars means real cars. The vector Core pins carries carId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1643 (S2, S3, N1, N3, N4, N6, N8, N9; S1 for the app): - A charger's confirmed cable (charging.connected) requests a replan at once, past the reactive check's 10 s and its 30 s gap. A hold enters no plan, and goals and schedules already request their own replans. - The planner tells its listeners when a replan starts, not only when a plan lands or a replan fails, so the phone hears the cable and pending at once; while pending the windows stay those the box acts on. OnPlanChange appends. - The feed sends the first change at once and a burst inside the 100 ms settle as one follow-up, each session on its own goroutine. - A session reads and sends its plan under one lock, so the newest lands last, and skips a push of the plan it already has. - The far windows give way after the far slots, never the session. - Each charger carries gridDeferred and goalRev, so the push is enough for the car view and a goal changed elsewhere is read once. Measured in TestAPlugInReachesAStreamingPhoneAsAPlan with the production reactive check, the plug-in right after a replan: cable on the phone in ~2 ms, the plan with its windows in ~102 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app read the car's plan from /api/loadpoints only when the car started or stopped drawing, so a car plugged in for a night charge kept the old plan on the phone while the box's page showed the new one. srcfl/ftw#1643 pushes the session plan when the box replans, with each car's windows, cable, pending/outdated and shortfall. The app takes them at once: Now's strip, the plan view, the car view and PlanShare (the widgets and the watch) move with the push, and a cable that moved is read once for the rest of the car. A box whose plan comes without cars is read each quarter while a car is plugged in, and every 15 s while it says the plan is being made or for 30 s after FTW Cloud says a cable moved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de it srcfl/ftw#1643 now sends chargers/PlanCharger keyed by the loadpoint id, with an optional carId (the session's car, empty until Core keeps cars), since in the car contract (srcfl/ftw#1647) cars means real cars. The vector Core pins carries carId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able Review of app#73 (S1, N13) and the paired Core changes in srcfl/ftw#1643: - Each charger on the plan now carries gridDeferred and goalRev. The car view takes gridDeferred from the push; a goal revision that moves (a goal changed on the box's page or another phone) reads the car once. - While a new plan is made the push says pending and keeps the windows the box goes on acting on, so Now's strip does not blink. - A read that was on the wire while a newer push came keeps the push's cable as well as its plan. - An older box is asked every 15 s for a minute after a cable moved: it notices a cable within 10 s, or 40 s after its own replan. - The demo box says pending when its replan starts, as Core now does, and can hold an answer back (answerDelayMs) to test a stale read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* iPhone: the plan reaches the car the moment the box replans The app read the car's plan from /api/loadpoints only when the car started or stopped drawing, so a car plugged in for a night charge kept the old plan on the phone while the box's page showed the new one. srcfl/ftw#1643 pushes the session plan when the box replans, with each car's windows, cable, pending/outdated and shortfall. The app takes them at once: Now's strip, the plan view, the car view and PlanShare (the widgets and the watch) move with the push, and a cable that moved is read once for the rest of the car. A box whose plan comes without cars is read each quarter while a car is plugged in, and every 15 s while it says the plan is being made or for 30 s after FTW Cloud says a cable moved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * iPhone: read the plan's share by charger, with the session's car beside it srcfl/ftw#1643 now sends chargers/PlanCharger keyed by the loadpoint id, with an optional carId (the session's car, empty until Core keeps cars), since in the car contract (srcfl/ftw#1647) cars means real cars. The vector Core pins carries carId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * iPhone: the push is enough for the car view; a stale read keeps the cable Review of app#73 (S1, N13) and the paired Core changes in srcfl/ftw#1643: - Each charger on the plan now carries gridDeferred and goalRev. The car view takes gridDeferred from the push; a goal revision that moves (a goal changed on the box's page or another phone) reads the car once. - While a new plan is made the push says pending and keeps the windows the box goes on acting on, so Now's strip does not blink. - A read that was on the wire while a newer push came keeps the push's cable as well as its plan. - An older box is asked every 15 s for a minute after a cable moved: it notices a cable within 10 s, or 40 s after its own replan. - The demo box says pending when its replan starts, as Core now does, and can hold an answer back (answerDelayMs) to test a stale read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
handler.go: keep both. An agent subscribes to its decided requests and never joins the plan feed; it reads the plan by asking, so its reads stay counted and in the audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Fredrik plugged his car in at home. The box's own page showed the new charging plan; the iPhone app did not. The app read the car's plan only when the car started or stopped drawing power, the box sent its
planmessage only when asked or right after a command, and the planner noticed a plug-in only at its reactive check: every 10 s, and 30 s after its last replan.What this changes
charging.connected) requests a replan, past the reactive check and its gap (cmd/ftw/plan_edges.go). The event already waits 2 s after an unplug, so a flapping cable does not storm the optimizer, and a newer request replaces one still waiting. Goals, schedules, levels and solar-only already request their own replans. A hold enters no plan (the spec builder never reads it), so it does not replan.mpc.Service.OnPlanChange(now a list) is told when a replan starts and the plan turns pending, when a plan is published (before the diagnostic write), and when the newest replan ends without one. Not when a replaced request ends.appproto.PlanFeedsends the first change at once, each session on its own goroutine. Changes inside the next 100 ms are one follow-up. No periodic timer, only that one wait after a push.writePlanreads and sends underplanMu, so the newest plan lands last, and skips a push of the plan the session already has (hash without the uptime). Only streaming sessions whose role carriesftw.plan.read, as the grant stands now, get pushes; a viewer, a demoted phone, an unsubscribed session and a watch exchange get nothing.Plan.chargers(PlanCharger): per charger its loadpoint id, an optionalcarId(the session's car, empty until Core keeps cars), plug state,pending,outdated,gridDeferred,goalRev(moves when the goal, ready time, schedule or solar-only choice changes, wherever), the windows ahead, the total and the shortfall. Read from the same snapshot and windows asGET /api/loadpoints(api.MaxPlanWindows). While a new plan is made the windows are those the box goes on acting on, marked pending; the HTTP route empties them. Older apps ignore the field.carsmeans real cars. feat(cars): the car apart from the charger, in Core #1650 replans through itsReplanhook when the session's car changes, and the push follows.Measured
Laptop, loopback, a real
ftwclouddbuilt from srcfl/ftw-cloud#15, the relay unreachable. The box ran in process with this branch's planner (Go DP), loadpoint manager on the event bus, plug-in replan, plan feed, app uplink, cloud link and tunnel, and servedGET /api/loadpoints. Core's production reactive check stayed on (10 s, 30 s gap). The app was FTWKit'sAppModel(srcfl/ftw-app#73) paired through FTW Cloud with Now's asks on. "On screen" meansLoadpointsModelhas the car plugged in with its windows andPlanShare.entry(Now's strip, the plan view, the widget record the watch gets) has the charge. The harness is not in either repo.One read of
/api/loadpointsper plug-in, after the plan was on screen. The Go DP solves this plan in a millisecond; on a Pi with Energyplan the plan lands when the solve ends, and the cable and "pending" still land at once.TestAPlugInReachesAStreamingPhoneAsAPlan(this PR) plugs in right after a replan, inside the 30 s gap: cable on the phone in ~2 ms, the plan with its windows in ~102 ms (the follow-up after the pending push).An old box (no push, no plug-in replan): see srcfl/ftw-app#73. With FTW Cloud's cable news, plug-in → plan on screen 1.0–18.3 s, median 7.0 s, over 6 plug-ins; the worst case is the reactive check after a recent replan (up to 40 s) plus one 15 s ask, about 55 s.
Tests
appproto: a replan pushes the plan with the charger; the first change goes at once and a burst is one follow-up of the newest plan; the same plan is not pushed twice (aplan.getis still answered); racing pushes land the newest plan last; a viewer and a demoted phone get nothing; nothing before the stream or after close; a watch exchange is not on the feed; 193 slots × two chargers and 24 chargers × 32 windows fit;TestThePlanWithChargersVectorpins the bytes the app decodes.mpc:OnPlanChangehears a start, a publish and a failed newest replan, every listener, and not a replaced request.cmd/ftw: the plug-in test above; a pending plan keeps the windows the box acts on and drops the shortfall.go test -raceonappproto,mpc,api,appuplink,cmd/ftw, andprotocol: pass.Privacy
The relay and FTW Cloud now see bulk frames when the box replans, not only after a request or a command. Scheduled replans come every 15 minutes anyway; a plug-in is now visible as timing. Lane 0 is unchanged.
Coordination
11a70217, feat(cloud): follow FTW Cloud to the shard that holds the home, and spread the redial after a restart #1645) into this branch rather than rebasing, because fix(mpc): keep valid plans when forecast caution fails #1644 is stacked on it.handler.go,app_link.go,protocol.goandmessages.go; whichever lands second rebases. Herehandler.goaddsPlanChargers/PlanFeedtoConfig,planMu/lastPlantoHandler, the feed join/leave, andwritePlan.chargersis a message field;carIdmatches the name feat(contract): cars apart from chargers #1647 adds. When feat(contract): cars apart from chargers #1647 lands,PlanChargercan embed itsChargerCarforcarIdentifiedBytoo.Pair
App side: srcfl/ftw-app#73.
🤖 Generated with Claude Code