fix(control): every dispatch path reports whether the driver took the command - #805
Conversation
… command #800 established the law — a driver that cannot actuate gets its autonomous default and stops being counted on — and wired exactly one path into it. The reviewer named the gap and its author confirmed it: only the storage loop filed an outcome. PV curtail discarded its error outright and the loadpoint controller only logged one. So the bug #800 fixed for batteries was still live for the other two. A wallbox that answers every poll and refuses every setpoint held the current it last accepted while the plan went on booking the charge and the surplus reserve went on being held back from the battery for an EV that was not drawing. An inverter that refused every cap kept exporting into a negative price while the plan booked the saving. Route both into the same tracker. The wiring is small; what took the work was deciding which command on each path is the one whose refusal means core cannot put power where it asked. Getting that wrong excludes a healthy device, which is worse than the bug. PV curtail: the cap counts, the release does not. A `curtail_disable` is core letting go of the inverter, so refusing it proves nothing about the device — the same reading #800 gives a rejected set_self_consumption in sungrow.lua. It would also seal shut: ComputePVCurtail emits a release the moment a driver drops offline, so a counted refusal would let an excluded inverter hold itself out on its own exclusion. Loadpoint: the periodic ev_set_current counts. Four other sends do not, and each for its own reason. The 0 W standdown is core withdrawing under a stale site meter, a transition the staleness tracker already owns. `charge_start` goes to the bound vehicle driver, and a parked car refuses it whenever it is asleep — counting that would take the car's SoC out of the plan for napping, and wakeVehicleAuto already backs off on its own. The ev_pause/ev_resume contactor cycle is documented as free for any charger implementing those actions, so a charger that implements neither returns an error and is behaving correctly; it also runs on its own goroutine, which the tracker is not built to take. The operator's force-start and refresh are not dispatch at all. Nothing about what counts as a refusal changes: ErrObserveOnly, ErrControlBlocked and deadline/cancel remain faults another mechanism owns. Observe-only drivers still receive no command of any kind. Deliberately still out of scope, as in #800: a refusal joined with ErrControlBlocked is not counted, so such a driver stays in the MPC fleet. #798 owns that registry state machine. Risk direction, stated the way #800 did it: commands now reach hardware on paths that previously sent none. An excluded PV inverter receives its driver's declared default where it previously received nothing. Both shipped EV chargers declare a no-op default — easee_cloud.lua and ctek.lua both say a wallbox has no autonomous equivalent and should hold its last current — so an excluded charger receives no new write at all, and the exclusion is pure accounting. Golden corpus: no record moved, as predicted. This changes command outcome accounting, not dispatch arithmetic, and the replay never goes through the control tick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 479ee34ec2
ℹ️ 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".
| // current and the plan keeps counting the load. Only the periodic | ||
| // ev_set_current is reported — see loadpoint.DispatchOutcomeFunc | ||
| // for the sends that are deliberately not. | ||
| lpController.SetDispatchOutcome(actuation.recordCommandOutcome) |
There was a problem hiding this comment.
Respect EV driver exclusion before sending more setpoints
After three refused ev_set_current commands, this callback marks the charger faulted, but telAdapter does not check DriverHealth.IsOnline() and TickWithDispatch continues sending a setpoint every control tick. On the next tick the tracker sends autonomous default mode and the loadpoint controller can immediately overwrite it; a transient acceptance also clears the fault without waiting for the documented five-minute retry window. Gate normal EV dispatch on driver health so a failed charger remains autonomous until the tracker explicitly re-enables its probe.
AGENTS.md reference: AGENTS.md:L35-L36
Useful? React with 👍 / 👎.
| "action": "curtail", | ||
| "power_w": c.LimitW, | ||
| }) | ||
| tracker.dispatchCommand(ctx, reg, "pv curtail send", c.Driver, payload, timeout, now) |
There was a problem hiding this comment.
Track refusal streaks separately for each dispatch action
When one driver exposes both battery and PV control, this curtail result shares the same refusal entry as its battery setpoint. The main loop sends battery commands first, and any successful battery command deletes the driver's refusal state before a rejected curtail increments it again, so an inverter that accepts battery commands but refuses every PV cap never exceeds one refusal and is never excluded or defaulted. Key the streak by driver plus command class, or otherwise aggregate all outcomes for a tick without letting one action clear another action's failures.
AGENTS.md reference: AGENTS.md:L35-L36
Useful? React with 👍 / 👎.
One conflict: srcfl#805 moved the shared driverActuationTracker init earlier in main(); kept upstream's placement and this branch's solarFeedSender init beside the stale-site tracker. Also answers srcfl#805's per-path question for the new solar feed: a refused solar_pv hint does not count toward driver_cannot_actuate — no power was asked for — documented in the tracker's path enumeration. Known local-only: TestStaleDriverIsLeftToTheWatchdog (new in srcfl#805) flakes on Windows' coarse clock (WatchdogScan(1ns) vs same-tick RecordSuccess); passes on the Linux runners. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Summary
#800 established the law — a driver that cannot actuate gets its autonomous default, and stops being counted on — and wired exactly one path into it. Codex named the gap on that PR and its author confirmed it in "Not in scope": only the storage loop filed an outcome. PV curtail discarded its error outright; the loadpoint controller only logged one.
So the bug #800 fixed for batteries was still live on the other two wires:
SurplusReserveWwent on holding PV back from the home battery for an EV that was not drawing.Both now report to the same
driverActuationTracker.The complete list of sends, and what each one counts as
Every command core sends leaves through one of two doors:
sendDriverCommand(storage + PV curtail) or thereg.Sendhanded toloadpoint.Controller. Following both:batterysetpointmain.godispatch loopcurtailcappv_curtail_dispatch.gocurtail_disablereleasepv_curtail_dispatch.goev_set_current, periodiccontroller.gotickOneev_set_current0 W standdowncontroller.gotickOnecharge_startauto-wakecontroller.gomaybeWakeVehicle/wakeVehicleAutoev_pause/ev_resumecyclecontroller.gocycleWallboxcontroller.goForceStartVehicle/RefreshVehicledriver_default_modesendDriverDefaultThe wiring is small. The work was in that third column, because getting it wrong excludes a healthy device from dispatch, which is worse than the bug.
Why each "no"
curtail_disable. Core letting go of the inverter, not core actuating it, so refusing it proves nothing about the device — the same reading #800 gives a rejectedset_self_consumptioninsungrow.lua. It would also seal shut:ComputePVCurtailemits a release the moment a driver drops offline, so a counted refusal would let an excluded inverter hold itself out on the strength of its own exclusion, with no way back.The 0 W standdown. Core withdrawing under a stale site meter.
staleSiteDefaultTrackeralready owns that transition, and the fault being handled belongs to the meter.charge_start. It goes to the bound vehicle driver, and a parked car refuses it whenever it is asleep —tesla_vehicle.luavia a proxy that returns 503. Counting it would take the car's SoC out of the plan for napping.wakeVehicleAutoalready has its own backoff, andmaybeWakeVehiclealready logs "vehicle won't accept charge_start" as an expected outcome.The contactor cycle. Documented in
cycleWallboxas free for any charger implementingev_pause/ev_resume; a charger implementing neither returnsfalsefromdriver_command's fallthrough and is behaving correctly —zap.luahandles onlyinit/deinit. It also runs on its own goroutine, which the tracker is explicitly not built to take.What the drivers actually return
Read before wiring, because the difference matters more than the rule does.
ferroamp.luarefuses acurtailthat resolves to ≤ 0 W —pplim arg=0means "produce nothing" on that firmware and sticks until somebody clears it from the portal — and it refuses by returning nil, which reaches core as success. Same for acurtail_disablewith nopplim_release_wconfigured. That refusal is the driver protecting the site and it is correctly invisible here. What is visible is a write the driver tried and could not land:sungrow.luareturning false when the active-power registers will not take the ratio, oreasee_cloud.luareturning false when its token has gone. Those mean core cannot actuate, and they are the ones worth acting on.easee_cloud.lua'sper_phase_ampsclamps a sub-6 A request to 0 A and writes it, so the "charger reports a sub-minimum current as a pause" case returns success and never reaches the counter at all.Blast radius of an exclusion, per device class
Both shipped EV chargers declare a no-op
driver_default_mode—easee_cloud.luaandctek.luaboth say a wallbox has no autonomous equivalent and should hold its last current. So an excluded charger receives no new write; the exclusion is pure accounting, and the useful half is thatIsOnline()drops it fromSumOnlineEVW, the MPCLoadpointSpecand the surplus reserve. An excluded PV inverter does get a default write — see the risk note below.The most likely real trigger is an Easee whose cloud token has expired while polls still succeed. Recovery needs no operator and does not wait for the retry window:
tickOnekeeps commanding an excluded loadpoint every tick, so the first accepted command clears the fault.What this trades
Commands now reach hardware on paths that previously sent none. A PV inverter that has refused three caps now receives
driver_default_mode— forsungrow.luathat is self-consumption plus a curtail release — where before it received nothing. That is the intended direction and the same trade #800 named: one write of the driver's own declared safe state, against an inverter holding a stale cap into a fault indefinitely. Observe-only drivers still receive nothing, not even this.Nothing about what counts as a refusal changed:
ErrObserveOnly,ErrControlBlockedand deadline/cancel remain faults another mechanism owns.Not in scope
Unchanged from #800: a refusal joined with
ErrControlBlockedis not counted, so such a driver stays in the MPC fleet. #798 owns that registry state machine and is paused mid-change; this PR touches no file it owns.The curtail loop does not pre-filter
observe_onlythe way the storage loop does. It never did, and it does not need to — the registry refuses withErrObserveOnlyandisCommandRefusaldrops it. Left alone rather than widened.Contention
#732 (OCPP) rewrites the single line
lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, reg.Send), which is the line mySetDispatchOutcomecall sits under. One-line adjacency; whichever lands second keeps the other'ssendexpression and appends theSetDispatchOutcomeline after it. Worth knowing for #732: an OCPP charge point is not in the driver registry, andStore.SetDriverCommandFaultreturns early for a name with no health record, so a refused OCPP setpoint is a no-op here rather than a surprise exclusion.No overlap with #798 (
registry.go) or #797 (internal/config).Verification
TestGoldenCorpusReplaynever goes through the control tick. 590 records, 8 families, tolerance untouched.make verifyclean.go test -raceoncmd/ftw,internal/loadpoint,internal/control,internal/telemetry,internal/mpc.Tests
The negative ones are the point.
internal/loadpoint/controller_dispatch_outcome_test.go— a refusedev_set_currentis reported and an accepted one is too; a refused standdown is not; a sleeping vehicle's refusedcharge_startis not; a refused contactor cycle is not.cmd/ftw/pv_curtail_dispatch_test.go— three refused caps exclude the inverter and walk it to its default once, carrying the operator-facing reason and leavingStatus=ok; a driver that refuses only releases is never excluded, however many; an accepted cap clears the exclusion.cmd/ftw/loadpoint_dispatch_outcome_test.go— end-to-end through a realloadpoint.Controllerwired to the real tracker exactly asmain.gowires it: a refusing charger is excluded and defaulted, a recovering one is counted again, and an unplugged one — which receives no command at all — is never excluded for the silence.🤖 Generated with Claude Code