How a Schedule slot (or an operator override) becomes a verified set of Modbus writes.
This is the authoritative contract for outbound/inverter_control_module.py. The
register-level details of the Solis writes themselves live in
solis_control.md; the surrounding pipeline is in
ARCHITECTURE.md and MODULES.md.
Remote Dispatch (v0.4.0, Solis). Where the install supports it —
solis_modbusexposessolis_dispatchand input register 34502 reads0xAA55— the two forced modes (Discharge, GridCharge) are driven through the inverter's Remote Dispatch block (44100–44112) instead of the Remote-Control registers. The deadman moves into the inverter (failsafe_minutes, refreshed by the ordinary holding tick) rather than a driver heartbeat racing a ~5-minute firmware expiry, the writes are raw registers so neither thenumberentity's same-value short-circuit nor its ±10 kW declared bound applies, and mode + power + failsafe land as two atomic block writes. Passive modes keep the register path unchanged.sensor.sunsale_observed_inverter_modereports which path is live ascontrol_path(dispatch/rc). Seesolis_dispatch_driver.py.Warning: the 44100 block is a single-writer resource. SolisCloud's EMS drives the same registers. sunSale releases dispatch whenever a passive mode is commanded, so the cloud can hold the fallback while sunSale is idle — but a foreign write during a held forced mode surfaces as register drift and is re-commanded by the reconcile path. Do not run both controllers over the same slot.
Capability seam (v0.2.0).
driver.spec_for(mode)returns an effective spec — every target already reduced to what the hardware advertises it will accept — so the value written and the value verified are the same number. Rows carry a four-statestatus(match/mismatch/unknown/no_target), and onlymismatchcounts as drift: an unreadable register is a comms gap, not a control fault. Before this, a spec target above an upstream bound could never verify, and an unavailable entity pinned the drift check True indefinitely. Seecapability_seam.md.
select.sunsale_mode_override is the single source of operator mode intent. Options are the
dispatchable StorageMode values plus the sentinel sunsale (rendered as "sunSale" in the
panel UI via an option-label map in sun-sale-panel.js; entity option strings stay snake_case
for HA).
Three rules govern dispatch in InverterControlModule.tick:
- Write once, on change.
apply_moderuns only on the cycle the resolved target differs fromlast_commanded_mode— a button press, or a schedule-slot boundary. Holding an unchanged, engaged target re-asserts nothing:_dispatch_current_slotreturns theholdingoutcome and issues no Modbus write. The mode is set once and then observed; a freshly-failed write is recovered by the verify loop (below). A drift that appears after the verify window has closed is recovered by the slow reconciliation path (further below) — not by blind re-asserts every cycle. One deliberate exception: every holding tick callsdriver.hold(target, spec)— the module's entire statement about liveness. What holding costs is the driver's business. A platform whose mode is a written setting no-ops; Solis re-asserts the RAM-only RC registers and, because the ~5-min RC expiry is faster than the 5-min cycle, runs its own 3-minuteHeartbeat(outbound/heartbeat.py) phased to eachapply_mode. No keep-alive cadence or flag appears in the neutral protocol;InverterControlModuleholds no timer of its own beyond the verify loop. The 43110 bitmask itself is still write-once. A driver's re-assert is deliberately not gated onverify_state— the module does not even pass it. A deadman must keep running exactly when the loop is least sure of itself:unknownmeans some unrelated control point is unreadable (a solis_modbus register-group outage takes the battery-current entities down while the RC registers answer fine), andmismatchis often the RC selector itself having reverted — the very drop the re-arm repairs. Gating it onokis what let a commanded Discharge expire a few minutes in while every register still read "engaged". - Override bypasses
automation_enabled. Whencoordinator.mode_overrideis set, the dispatcher resolves the override as the target regardless of the automation switch. Operator intent always reaches the inverter (once, on the cycle it changes). automation_enabledonly gates the scheduler path. When the override isNone, the dispatcher only writes the current schedule slot's mode while the switch is on; with it off, the module is observer-only.
A mode is a sequence of writes, and no single one may abort it. Every write in apply_mode
goes through EntityActuator (outbound/entity_control.py), which owns two guarantees the
sequence depends on. First, a number target is clamped into the entity's advertised
min/max before the readback comparison — the integration on the other side owns those
bounds and changes them under us (solis_modbus 4.2.0 began enforcing the declared 0–200 A on
registers 43117/43118), and HA rejects an out-of-range number.set_value before the integration
ever sees it. Clamping before the comparison also keeps the write idempotent: an unclamped
target could never match the clamped readback, so every cycle would re-issue a doomed write. A
clamp logs a warning — it means the hardware will not deliver what the planner priced in, so the
configured battery/inverter limits need reconciling with the entity bounds. Second, a service
call that raises is logged, not propagated: the writes that matter most come last (the RC
engage/release block), and letting one rejected register skip them leaves the inverter
half-configured — or, on a non-RC mode, leaves a stale forced discharge running. The verify loop
below is the designed detector for a write that did not take.
Direct dispatch on button press. Selecting an option in select.sunsale_mode_override does
not trigger a full coordinator refresh — that would re-run 14 translators, 16 DAG nodes, and a
dozen store saves just to reach the dispatcher. Instead async_select_option calls
coordinator.dispatch_mode_override, which routes straight to
InverterControlModule.dispatch_override — a lightweight sibling of tick that runs only the
plan → act → verify path (no observe, no history). It shares the act path with tick via the
private _apply_dispatch core; releasing the override to sunsale (None) still dispatches the
current schedule slot's mode immediately, using the last-computed Schedule cached in
coordinator.data. The button thus feels instant, and the next regular tick reconciles the rest
of the pipeline. Unlike tick, the direct path deliberately leaves the automation-transition
bookkeeping (_reassert_next / _last_automation_enabled) untouched — a button press is not an
automation toggle.
sensor.sunsale_observed_inverter_mode surfaces per-tick dispatch diagnostics —
last_dispatch_outcome (ok write issued / reconcile re-write for an unchanged target /
holding target unchanged and engaged / no_target / no_spec / automation_disabled),
last_dispatch_target, last_dispatch_tick_at, and automation_enabled_at_dispatch — so a user
can confirm whether a UI mode change actually reached apply_mode or fell through one of the
gates.
tick runs once per coordinator cycle, and "write once, on change" means a slot's mode reaches
the inverter on the first cycle inside that slot — so when cycles run decides how much of a
slot is spent executing the previous slot's decision. The coordinator therefore does not use
DataUpdateCoordinator.update_interval, whose timer re-arms relative to the end of the last
refresh: its phase against the wall clock is set by whenever HA last started, never
self-corrects, and — because the 15-minute slot grid is a multiple of the 5-minute interval —
applies the same offset to every boundary. An install that happened to settle on hh:02:48
activated every slot 2 min 48 s late, permanently.
Cycles are instead driven by SunSaleCoordinator._start_aligned_tick — a UTC
async_track_utc_time_change on second=0 of the UPDATE_INTERVAL_MINUTES grid, unioned with
the SCHEDULE_SLOT_MINUTES grid so a slot boundary is a tick even if the two constants stop
dividing evenly. It refreshes directly rather than through the base class's debouncer (whose
cooldown would give the lag back), and skips a tick — with a warning — if the previous cycle is
still running, since an absolute-time tick has no "wait for the last one to finish" built in the
way an interval does. Residual lag is the cycle's own work: translators, the recorder resample,
and the DAG all run before _dispatch_inverter_mode.
The control module keeps its own truth of what the inverter was last asked to do —
last_commanded_mode / last_commanded_at — independent of the solis_modbus state cache. On any
cycle where the resolved target differs from this stored value:
apply_modeis invoked withforce=True, which bypasses the cached-readback comparison in_apply_43110_bits/_set_number. The Solis integration's polling cache can lag a successful write by up to one slow-poll interval (~30 s), so trusting it on the commanded-change cycle would risk silently dropping the write.- The first verify is scheduled at +2 s (
_VERIFY_INITIAL_DELAY_S), then poll every 5 s (_VERIFY_POLL_INTERVAL_S) up to 30 s of total elapsed wall-clock (_VERIFY_WINDOW_S). Each poll re-reads every register the commanded mode writes — the 43110 bitmask plus whichever ofcharge_a/discharge_a/export_limit_w/rc_setpoint_wthe spec carries, plus therc_enablerow (RC function selector, register 43132) for RC-backed modes — and compares each to its target (_build_register_status, exact match for the bitmask and the selector,_NUMBER_WRITE_EPSILONslack for the numbers, mirroringapply_mode's write tolerance). - All registers match →
verify_state = "ok"and the loop stops. Any mismatch within the window → keep polling. Mismatch past the window (first time) → log warning, force-rewrite, reset the window, resume polling. Mismatch past the second window →verify_state = "mismatch", log error, stop. Worst-case time to verdict ≈ 60 s. A total readback blackout (no row readable at all — an integration restart or reload) takes the same one retry:EntityActuatorswallows a service call against an unavailable entity, so a command issued into that window is lost silently, and with nothing readable a re-write is the only way to find out. A partial outage (some rows matching, others unreadable) does not retry — the write demonstrably reached the inverter — and settles onunknown.
A new commanded change always supersedes any pending verify (cancel + reschedule).
sensor.sunsale_observed_inverter_mode exposes last_commanded_mode, last_commanded_at,
verify_state, last_verify_at, last_verify_observed_reg, register_status (the verify/drift
per-register rows), plus observed_mode and register_panel (the always-populated
intended-vs-observed comparison the panel renders — see Panel readout below) so the operator
can see the loop status at a glance.
The verify loop only lives ~60 s after a commanded change. Once it settles on verify_state == "ok", nothing in the write/verify path corrects a later drift — the inverter being changed at
its own screen, or a register slipping — while the schedule holds the same mode.
_dispatch_current_slot would keep returning holding and the panel would show a red register
grid for hours without acting. The reconciliation path closes that gap:
- Drift re-command. On every
holdingtick whereverify_state == "ok",_registers_drifted()re-checks the commanded mode's registers against the inverter. A drift increments a consecutive-cycle counter; at_DRIFT_RECONCILE_CYCLES(default 2 ticks ≈ 10 min — a debounce against a single transient readback glitch) the unchanged target is re-commanded via the same force-write + verify-loop path (outcome = "reconcile"). The counter resets the moment a tick reads clean, or on any write. - Conditioned on
okorunknown. Drift reconciliation runs from the steady engaged state and fromunknown— that verdict is about the rows that could not be read, so a row that was read and disagrees is real drift regardless; excluding it let one unavailable entity disable drift recovery for every other control point indefinitely. It deliberately does not fire whileverify_stateispending(the verify loop owns that window) ormismatch(the verify loop's terminal "this write won't take — check the Modbus chain" verdict is respected; a stuck inverter is not re-spammed every few cycles). mismatchself-heal. If aholdingtick findsverify_state == "mismatch"but the registers now all match (operator fixed it at the inverter, comms restored),verify_stateflips back took— the terminal mismatch never re-checks on its own, so the badge would otherwise stay red forever.- Automation re-enable re-asserts. A False→True
automation_enabledtransition arms_reassert_next, so the next dispatch re-commands the scheduled mode even if it equalslast_commanded_mode(the inverter may have drifted while automation was paused). Without this, toggling automation off→on never re-asserted, becauselast_commanded_modesurvived the pause.
Both triggers re-enter _force_write_and_verify (the shared force-write + verify-restart block),
so a reconciliation is verified exactly like a fresh command — it can itself converge to ok or,
if the write genuinely won't take, settle at mismatch and stop.
Because the verify loop runs on async_call_later between coordinator ticks, the module pushes
an on_state_change callback (coordinator._on_control_state_change) after each verify
mutation; the coordinator mirrors the fresh state via _mirror_control_module_state and calls
async_update_listeners, so the panel's badge and per-register colours track the verify loop in
real time instead of lagging to the next 5-minute cycle.
The drawer is opened by the header's Control button (#schedule-toggle) and rendered as a
single Control section. Its control block (_renderControlRow) is a compact wrapping flex
row of columns: Override (the option buttons, stacked vertically) · Intended (the
last_commanded_mode name + its expected register targets) · Observed (the live-decoded
observed_mode name + the actual register readback) · Policy (the schedule-policy switches) ·
Tuning (the numeric knobs). The Policy and Tuning columns are the same .sched-row switch /
number rows as before, just nested into the control row as extra columns rather than their own
sections — so _syncScheduleDrawer's row-count check stays valid; they always render even when
the override select entity is missing. Intended/Observed share one aligned register grid
(_renderModeReadoutInner → .mo-table, columns label · intended · observed) so each
register's target sits directly beside its observed value. The Observed column header carries the
verify badge — green "✓" (ok / Engaged), amber "⏳" (pending / Verifying), red "⚠" (mismatch).
The selected Override button mirrors the same state via the .pending (pulsing amber outline)
and .mismatch (red) classes. _renderModeReadoutInner is the single source of truth for the
Intended/Observed half — both _renderScheduleDrawer (initial) and _syncScheduleDrawer
(in-place) re-render it.
The sunsale sentinel option button is annotated with the mode the scheduler is currently
dispatching — rendered sunSale[<mode>] by _optionButtonLabel from the observed sensor's
last_commanded_mode — but only while sunSale is the active selection (mode_override is null
on the sensor exactly then; under a manual override last_commanded_mode is that override, not
the scheduled mode). Each Tuning knob carries an ⓘ (.sched-info) whose title / aria-label
describes what the knob does, surfaced on hover or keyboard focus; the Policy switch labels carry
the same description in their title.
Each observed register cell is coloured independently from its match flag plus the verify
phase: green when it matches the intended target, amber while the verify window is still
open (verify_state pending), red once the window has closed on a mismatch, and neutral
when the intended mode leaves that register at the hardware default (match is None, no target
to compare). The cells are driven by the register_panel attribute — the always-populated
canonical comparison (InverterControlModule._build_register_panel): unlike register_status
(the verify/drift view, which lists only the registers the commanded mode writes and is empty
before any command), register_panel always carries the live observed value of every register so
the operator can see what the inverter is actually doing even when no mode is commanded or the
observed bitmask maps to no named mode (observed_mode == "unknown"). register_status is the
subset of register_panel rows whose desired is not None; _register_row's match is
tri-state (True / False / None-for-no-target). observed_mode is decoded live from the
same readbacks as register_panel (independent of the 5-min state string) so the observed-mode
name and register values stay consistent during the verify window.
sun_sale.force_verify_inverter_mode is a HA service that bypasses the +30 s scheduled verify
and runs one immediately. Useful for confirming engagement right after a manual mode change
without waiting on the next verify-tick. Implementation: InverterControlModule.force_verify_now
cancels any pending verify and re-runs the same _on_verify_tick body the scheduled callback
would have.
Every solis_dispatch command carries two fields beyond mode / power / failsafe:
soc_min— the planner'smin_socas whole percent, rounded up (driver_factory.soc_percent_ceil). The block's SOC window otherwise defaults to 0–100 %, so a forced discharge that outlives its slot would drain past the floor; with the window set the inverter stops it itself.allow_grid_charge— GridCharge always sendstrue; every other forced mode mirrors the "Allow grid charging" switch, read at write time throughInverterContext.grid_charge_permitted, so a toggle reaches the next hold without a reload. Without it the firmware default (grid charge allowed, read back as function bits0x55) applied.
Passive modes run with dispatch released, so neither field reaches them — the
43110 "allow grid charge" bit governs there (and the inverter's force-charge below
its Force Charge SOC ignores it; see battery_export_guard.md).
SolisDispatchDriver.decode_observed / observe report Discharge / GridCharge while
the dispatch readbacks show it running with a non-zero target — PCC mode (3):
- export = Discharge, − import = GridCharge (the previous GridCharge command, still
recognised across an upgrade); battery-power mode (2): + charge = GridCharge. A
negative battery-power target is a battery discharge sunSale never commands, so it
reads as foreign. Otherwise they defer to the register decoder (which alone would
call a dispatch Discharge
feed_in).
The verify loop checks what sunSale wrote; it cannot see an inverter that accepts
the writes and then exports anyway (2026-09-09: operating mode 4096 for 91 min
after a dispatch release, every row match). outbound/export_guard.py watches the
behaviour instead:
ExportGuard— fed on every grid / PV state change (not the 5-min cycle). Condition: a passive mode is commanded andgrid export − PV ≥ 1 kW. Held for 120 s it trips; 60 s false clears it. On a trip the coordinator capturesdriver.diagnostic_snapshot()(Solis: 33122, 34504, 43110, currents, dispatch block), logs a warning and raises a persistent notification.SocFloorWatch— per cycle, flags discharge ≥ 0.2 kW at or belowmin_socunder a passive mode (the planner's floor is not a hardware limit).
Both are observation only — no writes. Exposed as export_guard /
soc_floor_guard on sensor.sunsale_observed_inverter_mode and in the debug view;
tools/checks/export_guard.py validates their consistency. Evidence, the 10-day
replay and the phase-2 (act) plan are in battery_export_guard.md.