Skip to content

fix(control): every dispatch path reports whether the driver took the command - #805

Merged
frahlg merged 1 commit into
masterfrom
agent/dispatch-failure-default
Aug 5, 2026
Merged

fix(control): every dispatch path reports whether the driver took the command#805
frahlg merged 1 commit into
masterfrom
agent/dispatch-failure-default

Conversation

@frahlg

@frahlg frahlg commented Aug 4, 2026

Copy link
Copy Markdown
Member

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:

  • A wallbox that answers every poll and refuses every setpoint held the current it last accepted. The plan went on booking the charge, and SurplusReserveW went on holding PV back from the home battery for an EV that was not drawing.
  • An inverter that refuses every cap kept exporting into a negative price while the plan booked the saving.

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 the reg.Send handed to loadpoint.Controller. Following both:

Send Site Counted?
battery setpoint main.go dispatch loop yes — already, from #800
curtail cap pv_curtail_dispatch.go yes — new
curtail_disable release pv_curtail_dispatch.go no
ev_set_current, periodic controller.go tickOne yes — new
ev_set_current 0 W standdown controller.go tickOne no
charge_start auto-wake controller.go maybeWakeVehicle / wakeVehicleAuto no
ev_pause / ev_resume cycle controller.go cycleWallbox no
operator force-start / refresh controller.go ForceStartVehicle / RefreshVehicle no — not dispatch
driver_default_mode sendDriverDefault no — the release itself

The 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 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 the strength of its own exclusion, with no way back.

The 0 W standdown. Core withdrawing under a stale site meter. staleSiteDefaultTracker already 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.lua via a proxy that returns 503. Counting it would take the car's SoC out of the plan for napping. wakeVehicleAuto already has its own backoff, and maybeWakeVehicle already logs "vehicle won't accept charge_start" as an expected outcome.

The contactor cycle. Documented in cycleWallbox as free for any charger implementing ev_pause / ev_resume; a charger implementing neither returns false from driver_command's fallthrough and is behaving correctly — zap.lua handles only init/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.lua refuses a curtail that resolves to ≤ 0 W — pplim arg=0 means "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 a curtail_disable with no pplim_release_w configured. 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.lua returning false when the active-power registers will not take the ratio, or easee_cloud.lua returning false when its token has gone. Those mean core cannot actuate, and they are the ones worth acting on.

easee_cloud.lua's per_phase_amps clamps 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_modeeasee_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; the exclusion is pure accounting, and the useful half is that IsOnline() drops it from SumOnlineEVW, the MPC LoadpointSpec and 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: tickOne keeps 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 — for sungrow.lua that 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, ErrControlBlocked and deadline/cancel remain faults another mechanism owns.

Not in scope

Unchanged from #800: a refusal joined with ErrControlBlocked is 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_only the way the storage loop does. It never did, and it does not need to — the registry refuses with ErrObserveOnly and isCommandRefusal drops 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 my SetDispatchOutcome call sits under. One-line adjacency; whichever lands second keeps the other's send expression and appends the SetDispatchOutcome line after it. Worth knowing for #732: an OCPP charge point is not in the driver registry, and Store.SetDriverCommandFault returns 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

  • Golden corpus: no record moved. Predicted before looking, and it held — this changes command outcome accounting, not dispatch arithmetic, and TestGoldenCorpusReplay never goes through the control tick. 590 records, 8 families, tolerance untouched.
  • make verify clean.
  • go test -race on cmd/ftw, internal/loadpoint, internal/control, internal/telemetry, internal/mpc.
  • Each new test checked against a mutation of the code it covers: dropping the loadpoint outcome hop fails 4 tests; counting the curtail release fails the release test; not counting the cap fails 2; reporting the standdown fails the standdown test.

Tests

The negative ones are the point.

  • internal/loadpoint/controller_dispatch_outcome_test.go — a refused ev_set_current is reported and an accepted one is too; a refused standdown is not; a sleeping vehicle's refused charge_start is 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 leaving Status=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 real loadpoint.Controller wired to the real tracker exactly as main.go wires 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

… 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>

@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: 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".

Comment thread go/cmd/ftw/main.go
// 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)

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 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)

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 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 👍 / 👎.

@frahlg
frahlg merged commit a4f1300 into master Aug 5, 2026
13 checks passed
@frahlg
frahlg deleted the agent/dispatch-failure-default branch August 5, 2026 04:09
HuggeK added a commit to HuggeK/ftw that referenced this pull request Aug 5, 2026
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>
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