Fixed
-
A rejected schedule write is now diagnosable from the diagnostics download alone. Issue #17 (an H3-12.0-E) still reports
FoxESS API error 40257: Parameters do not meet expectationson 1.0.22-beta.4 — with the C-042 declared-range clamp demonstrably working (max_power_w12000, not the 12600 thecapacity x 1050heuristic would give) andForceChargepresent in the device's declared enumeration. So a different schedule-group parameter is out of range, and the download could not say which, for three reasons: the smart-session listener's retry path caught the exception, logged it and returned without recording it, sorecent_errorswas empty despite three failures and an aborted session (defeating D-059 and C-026 for the integration's most important error class); the rejected payload was retained nowhere, because it only ever existed in theschedule_writeevent, which is emitted after a successful POST; andscheduler_limitsreported two derived fields out of the wholepropertiesmap the device declares, hiding the declaredfdsoc/minsocongridranges andmaxGroupCount. All three are now closed. Adapter failures during a session reachrecent_errorswith the errno, the device's own message and the exact groups payload that was refused; the diagnosticsschedulesection reportslast_write_failure(paired withlast_write_ok_at, so a historical failure does not read as a current one) covering writes that never touch a session — the Self Use teardown baseline, a service call, the startup reconcile; andenvironment.scheduler_limitsreports the device's full declared property map. To keep one failing session from evicting the 30-slot buffer, repeats of the same failure signature collapse into a single entry with arepeatcount and alast_ttimestamp — a session failing identically every tick costs two entries (the failure and the circuit-breaker escalation) no matter how many ticks or replays, while every occurrence is still logged. Observability only: no clamp was added, no schedule payload changed, and C-024's circuit-breaker semantics are pinned by test — the breaker still opens on exactly the third consecutive failure, holds for five ticks without touching the adapter, then aborts once. -
Schedule writes no longer depend on an unverified API coupling to take effect. Schedule groups only drive the inverter while its Mode Scheduler master switch is on, and that switch is separate device state: removing every group does not turn it off (issue #16 — FoxCloud still showed the inverter as scheduler-controlled with no groups left). The converse has never been confirmed. Nobody knows whether
POST /op/v0/device/scheduler/enableturns the switch back on from off, and finding out would mean writing to a production home battery, so it has not been tested. If it does not, then any session started after something turned the switch off — the forthcoming scheduler-handback feature, the FoxESS app, the web portal — would write a schedule that silently never fires:errno 0from the API, the groups readable back fromscheduler/get, and the inverter quietly sitting in Self Use with nothing surfaced to the user (P-003, P-005, C-020). The integration now turns the master switch on explicitly (POST /op/v0/device/scheduler/set) at the single point every schedule write passes through, so the outcome no longer depends on the answer. The call is idempotent and failure is tolerated — the endpoint is absent on some firmware and regions, and a device reportingsupport: false(e.g. a batteryless micro-inverter) rejects it — so a failed enable can never abort a write that would otherwise have worked; it warns once per inverter, then stays quiet. Nothing changes for installs whose switch is already on: the schedule payload on the wire is byte-identical. Regression classtests/test_handback_foxess.py, which pins both possible API behaviours in the simulator and asserts the same observable outcome either way — the switch is on and the group is actually in force. Prerequisite for the scheduler handback that closes issues #16 and #4. -
A failed read of the Mode Scheduler capability flag was reported as "this inverter reports no Mode Scheduler support" (C-020, P-005).
get_scheduler_flag()degraded any unexpected response shape to all-False, andresult: nullis a shape this API genuinely produces —scheduler/getreturns it whenever no scheduler is configured. So one malformed or dropped reply became a confident statement about the user's hardware. The decision was fail-safe either way, since the handback declines in both cases, but the reason it would have logged sent someone looking for a firmware limitation that does not exist, and a log that lies is worse than one that says "unknown". There is now exactly one signal for "we do not know" — an exception — because a failed request and a malformed reply are indistinguishable in the only respect that matters, and collapsing them onto one mechanism makes it impossible to treat unknown as False by forgetting to check a sentinel.Inverter.probe_scheduler_support()turns that into the tri-state the policy layer consumes (True/Falseare the device's own answer,Nonemeans the question could not be answered), it never raises and never writes, andplan_handbacknow reports the two cases as two distinct reasons. Nothing user-visible changes yet: nothing on the control path reads the flag.
Changed
- Groundwork for the scheduler handback (issues #16, #4): the integration now captures the user's own persistent Min SoC on grid, once, and restores it if a previous run died with a session floor still in the register.
plan_handbackalready refuses to invent a Min SoC and restores only what was captured — but that rule is only as strong as the capture, and if the captured value were read after something had written a session value intoMinSocOnGrid, the policy layer would faithfully restore the session value: the shipped defect the rule exists to prevent, reproduced with an immaculate-looking decision layer. This project has already shipped exactly that defect once, where an adapter wrote a session target into the persistent floor and never put the user's value back, so once the session ended the inverter imported from the grid to hold a floor the user never chose (P-001, P-002). So the capture is deliberately paranoid. It happens at most once and is then authoritative and never re-read, because any later read may see a session value and "refresh it while we're here" is the bug rather than a convenience. It happens only from a clean device — no smart session active and no managed override group (Force Charge / Force Discharge / Feed-in) on the inverter — and it runs after the startup schedule reconcile, which is what removes the orphaned groups a failed teardown leaves behind, so "clean" means clean rather than "clean apart from yesterday's leftovers". A whole-day Self Use group does not block it, since that is the normal idle state every session teardown leaves behind and group values never move the persistent register; treating it as unclean would mean capture never happened on a real install and the feature silently restored nothing forever. If capture is impossible — API failure or an unclean device — nothing is stored,plan_handbackreceivesNone, and nothing is restored; a later restart may try again, but a default is never substituted. In the other direction, a value already in storage is the user's value and outranks whatever the device currently reports, so it is written back over a leftover floor rather than being overwritten by it. The one write this adds is surgical: it goes through the device'sMinSocOnGridsetting rather thanbattery/soc/set, because that endpoint writesminSocandminSocOnGridin a single request, so restoring only the captured on-grid floor through it would mean inventing an off-grid one — the same sin aimed at the other register. Values pass through verbatim, with no clamping to the Mode Scheduler's 10 % minimum and no rounding, because 0 is exactly what issue #4 is about. Nothing here can break setup or a session: every failure path is swallowed and logged, and a failed restore keeps the captured value so the next restart can retry. Only the write is gated on the new opt-in option, never the capture. Capture is a read and always runs, because reading early is the only way the value can be the user's own — waiting until they opt in would mean reading a register the integration might by then have written to. The crash-recovery restore is the one place this feature touches the inverter with noplan_handbackdecision in front of it, since it runs at setup before any plan exists, so it checks the option itself: with scheduler handback off — the shipped default — nothing here writes to the inverter at all, which is what makes "existing installs behave exactly as they did before the upgrade" true rather than aspirational. And because the captured value is authoritative and never re-read, there is a deliberate way to change your mind: turning the option off and then on again re-reads the floor. That transition is the only safe moment for a re-read — at the instant someone opts in, the integration has not been writing to the register, so whatever it holds is by definition what they chose — and it doubles as the remedy for a floor changed in the FoxESS app, with no new service and nothing extra to explain (C-020). Re-capture keeps every guard the first capture has, and one that cannot read a clean value keeps the value already stored rather than trading a slightly stale floor the user did once choose for no floor at all. Regression classtests/test_handback_capture.py(45 tests driving the real client over HTTP against a fresh simulator and a real Home AssistantStore), including issue #4 end to end at this layer — a 0 % floor captured, a real session run over it, and the floor restored through the realplan_handbackdecision — and verified against two deliberately wrong implementations before the real one was written: one that re-read the device late and substituted the configured default, and one that gated the capture instead of the write and re-captured on every options change. - Groundwork for the scheduler handback (issues #16, #4): the decision about whether to hand the inverter back to its own settings is now a pure function,
handback.plan_handback, separate from anything that performs it. No user-visible change whatsoever — nothing calls it, no configuration option exists, no teardown path changed, and no byte on the wire differs; the wiring lands separately. It is split out first because the handback's risk is not in the two API calls, it is in deciding when they are safe, and a decision expressed as a value can be tested exhaustively without HTTP or a running Home Assistant. Five guards refuse to act, each guarding a way this feature could damage a working install: the option being off (opt-in, default off — hundreds of existing installs must behave exactly as they do today after an upgrade); entity mode (no cloud Mode Scheduler exists to hand back); the device reporting no scheduler support; any unmanaged work mode present in the schedule (C-018 — a Backup group is there because the user put it there, and the refusal names the group so the log is actionable rather than sending them hunting through the FoxESS app); and a smart session being active (C-025 — turning the master switch off mid-session would strand the live override, so the schedule group stops driving the inverter while the integration still believes it is charging or discharging, which is a safety divergence rather than untidiness). Guards are evaluated most-permanent-cause-first — not enabled, then entity mode, then unsupported, then unmanaged modes, then active session — so the reason a caller logs names the condition that will still be true tomorrow: a Backup group needs the user in the FoxESS app, whereas a session clears itself within hours, and reporting the transient cause over the permanent one would tell someone to wait for something that was never going to happen. Precedence only ever selects which reason is reported, since every guard declines, so no ordering can make the decision less safe. The behaviour that matters most is what the plan restores: when no Min SoC was captured, nothing is restored, and a default is never substituted — and a captured value passes through verbatim, with no clamping to the Mode Scheduler's 10 % minimum and no rounding, because 0 is exactly what issue #4 is about. This integration has already shipped a defect in which an adapter wrote a session value into the persistent Min SoC floor and never put the user's value back, so the inverter imported from the grid after the session ended; choosing a floor is the user's business (P-002) and the most this feature may ever do is put back what it found. The plan is a frozen dataclass so a caller cannot fill in a floor between the decision and the execution, and a declined plan carries no actions at all, so a caller that ignored the decision would still do nothing. Regression classtests/test_handback_policy.py(41 tests), which pins every guard, the precedence order, the never-invent rule and the immutability; verified against a deliberately wrong implementation before the real one existed. - Groundwork for the scheduler handback (issues #16, #4): the FoxESS client can now read and write the inverter's own settings, independently of the Mode Scheduler, via the undocumented
POST /op/v0/device/setting/getand.../setting/set. No user-visible change yet — nothing calls these on the control path, no setting is written during normal operation, and no configuration option exists; the handback that uses them lands separately. Two properties of that surface, verified read-only against a live KH10, are now pinned by tests because they are the entire basis of the feature and would otherwise be silent assumptions. First, the direct work-mode enumeration contains no forced modes — it offersPeakShaving,Feedin,Backup,SelfUseand neitherForceChargenorForceDischarge— so a forced charge or discharge can only ever be expressed as a schedule group. That is why smart sessions must keep using the Mode Scheduler however the handback is configured, and why the handback can only govern the idle state; if FoxESS ever adds forced modes there, a failing test now says so rather than nobody noticing. Second,MinSocOnGridaccepts 0 as a device setting while the same device declaresminsocongrid.range.min = 10for schedule groups and rejects anything lower witherrno 40257(C-042) — so the 10 % floor users cannot get below is a Mode Scheduler restriction, not a hardware limit, which is precisely issue #4. Writing a work mode the device does not declare is refused locally with an error naming the mode and the accepted list, rather than being sent to earn an opaque 40257; this is deliberately stricter than the schedule path, which warns and writes anyway because refusing there could break installs that work today. The newset_work_mode_directis named to be impossible to confuse with the existingset_work_mode, which writes a schedule group — the two are different control paths onto the same inverter, and tests pin that they stay independent in both directions: a settings write neither creates nor alters schedule groups, and a session's schedule write moves neither the user's work-mode setting nor their persistent Min SoC.docs/api/foxess-cloud-api.mddocuments both endpoints, their declared shapes and both asymmetries. Regression classtests/test_handback_foxess.py.