Skip to content

fix(inverter): stop re-committing a stable export window every cycle (Solis) - #4711

Merged
springfall2008 merged 11 commits into
mainfrom
fix/solis-redundant-button-press-4709
Sep 12, 2026
Merged

springfall2008 merged 11 commits into
mainfrom
fix/solis-redundant-button-press-4709

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4709

Problem

On Solis GS_fb00 (solax_modbus), pressing the charge/discharge update button zeroes the timed charge/discharge current registers. The reporter measured the current register being knocked to 0 A at :xx:22 on every 5-minute boundary throughout an active export window, stopping export until something restored it.

The cause is in adjust_force_export. For H M time format the discharge slot-time registers are rewritten unconditionally every cycle — a deliberate write-reliability workaround from #1529. #4000 then reused that same "we wrote something" flag (changed_start_end) to decide whether to press the update button, so the button fired every cycle even when the window had not changed.

#4000's stated premise was that adjust_charge_window "always re-writes time entities for H M format → button press fires every cycle". The write half is correct; the button-press half is not — the charge side's press is gated on a plain dirty check and always has been. So the every-cycle press was never symmetric with anything.

The same flag also gates the 30 second GivTCP settle sleep in adjust_inverter_mode and the export-slot notification, both of which were therefore firing every cycle too.

Fix

Separate "we rewrote the registers" from "the schedule needs committing", and gate the press, the sleep and the notify on the latter.

Tracking the last committed schedule keeps a stable window quiet while still committing once after a restart, when nothing has been committed yet — so #4000's own regression test passes unchanged. The commit-once safety net is scoped to the H M path; every other format already commits on a real change alone.

A commit is only recorded once it has actually succeeded. press_and_poll_button returns a success flag, and discarding it would mean a failed press was recorded as done and never retried — the schedule sitting uncommitted until it happened to change on its own. The behaviour being replaced retried implicitly, because changed_start_end fired every cycle, so gating on the real result preserves that retry without bringing back the every-cycle press. (Same failure shape as the known call_service_template issue, where a service call is marked done before its success is checked.)

The register writes themselves are untouched.

Also fixes idle-cycle presses on plain GS

Beyond the export window, this PR also stops a second symptom that only came to light during triage of #4712.

GS (pre-FB00 Solis) is charge_time_format: "H M", so changed_start_end is set unconditionally there too — which means on current main it presses the update button on every idle cycle as well, all day, not just during export. Dropping changed_start_end from the commit condition fixes that. Measured over four idle adjust_force_export(False) calls:

             GS      GS_fb00
main         4/4     4/4
+#4711       1       4/4     <- this PR
+#4713       1       1

GS_fb00 and the cloud types reach the same press by a second, independent route (a None time comparison) which this PR does not touch — that is #4712, fixed by #4713 stacked on top of this one.

Also included

press_and_poll_button now presses only the side that changed. Where separate charge_update_button / discharge_update_button entities are configured, both were pressed whenever either side had something to send — unchanged since the original split-button support. Halves the presses for those users; no effect on the combined-button setups the Solis template ships by default. This was written for #2328 and parked as too small to be worth landing alone.

Commits

9a3a0e23 press_and_poll_button only presses the button(s) that changed
be3d225f commit a stable export window once, not every cycle
4499357c only record an export commit that actually succeeded

Testing

  • New regression test: a stable export window commits once, stays quiet on subsequent cycles, and commits again on a genuine change. Verified to fail without the fix.
  • New regression test: a failed button press is retried on the next cycle rather than recorded as committed. Verified to fail without 4499357c.
  • Existing Fix Solis GS_fb00: always write discharge time entities and press button during force export #4000 regression test passes unchanged.
  • Existing adjust_force_export1 passes unchanged, which is what pins the GE path's behaviour.
  • Side-scoping test from the parked work included.
  • Full --quick suite green.

Not yet confirmed against a live install — logs requested on #4709.

🤖 Generated with Claude Code

chalfontchubby and others added 2 commits August 24, 2026 17:55
…changed

Addresses #2328. press_and_poll_button()'s "separate buttons" fallback
(charge_update_button/discharge_update_button, used by e.g. some Solis and
SolaX SX4 configs instead of a single combined button) pressed both buttons
on every call, regardless of which side actually needed updating - a
charge-only window change also pressed the unrelated discharge button, and
vice versa. rszemeti's report showed exactly this: both update_charge_times
and update_discharge_times pressed together even when the charge window's
own start/end times were provably unchanged (every underlying write
correctly logged "No write needed"). He disabled Predbat after reaching
~75k inverter writes in a year to avoid EEPROM wear.

Added a `side` parameter ("charge"/"discharge"/"both", default "both" for
any caller not yet updated) and passed the correct side from each of the
four call sites (adjust_battery_target, adjust_force_export,
disable_charge_window, adjust_charge_window). A single combined button
(schedule_write_button or charge_discharge_update_button) is unaffected -
there's only one button either way.

New test_press_and_poll_button_side_scoping isolates the branching logic
by stubbing _press_single_button_and_poll (its own retry/timestamp-polling
behaviour is a separate concern), asserting each side presses only its own
button. No prior coverage existed for the separate-buttons path at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
For H M format inverters the discharge slot-time registers are rewritten on
every cycle as a write-reliability workaround (#1529), so changed_start_end is
True on every cycle of an unchanged export window. #4000 then used that flag to
gate the update button press, which made the button fire every 5 minutes for the
whole window.

On Solis each press zeroes the timed charge/discharge current registers, so the
inverter stops exporting until something restores them (#4709). The same flag
also triggers the 30 second GivTCP settle sleep in adjust_inverter_mode, and the
export-slot notification, on every cycle.

Separate "we rewrote the registers" from "the schedule needs committing" and
gate the press, the sleep and the notify on the latter. Tracking the last
committed schedule keeps a stable window quiet while still committing once after
a restart, when nothing has been committed yet - so #4000's own regression test
passes unchanged. The extra commit-once safety net is scoped to the H M path;
every other format already commits on a real change alone.

The register writes themselves are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chalfontchubby and others added 2 commits August 24, 2026 21:21
press_and_poll_button returns a success flag that was being discarded, so a
failed button press was still recorded in last_export_schedule_committed and
never retried - the schedule would sit uncommitted until it happened to change
on its own.

The behaviour being replaced retried implicitly, because changed_start_end fired
every cycle. Gating on the real result keeps that retry without bringing back the
every-cycle press.

Same failure shape as the known call_service_template issue, where a service call
is marked done before its success is checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ble-only flip

schedule_changed also goes True on a bare scheduled_discharge_enable flip with
start/end unchanged (e.g. Fox and other HH:MM:SS-format cloud inverters), but
that flag was being passed straight into adjust_inverter_mode's changed_start_end
parameter, which gates a 30 second sleep intended specifically for "start/end of
discharge window was just adjusted". No time register was written in that case,
so there's nothing for GivTCP to settle.

Split out times_changed (start/end genuinely differing, or the H M format
commit-once-after-restart case) and gate the settle sleep on that instead,
leaving schedule_changed - correctly the broader flag - for the button press,
last_export_schedule_committed tracking and notify gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chalfontchubby and others added 3 commits August 28, 2026 14:32
execute.py calls adjust_force_export(False) with no times at all whenever
nothing is being exported. On an inverter with has_discharge_enable_time set the
midnight override is skipped, so new_start/new_end stay None while the inverter
still reports a real time - and None never compares equal, so every idle cycle
looked like a schedule change and pressed the update button. That is most of the
day, not just export windows (#2328).

Only compare times the caller actually asked us to set. A genuine transition out
of export is still caught by force_export != old_discharge_enable, and the GE
branch that deliberately clears the times keeps its existing behaviour - it is
distinguished by whether the caller supplied any times, not by the times being
None once we are past that branch.

Affects GS_fb00 and the FoxCloud/TESLA/Enphase/Deye/Sunsynk/AlphaESS cloud
types; plain GS takes the midnight-override path and was never affected.

Not yet confirmed against a live install - awaiting logs on #4709.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ss_every_cycle

- save/restore the discharge_* args and dummy_items this test overwrites,
  not just inverter_type, so nothing leaks into later tests
- reword the docstring: plain GS wasn't affected by this test's specific
  None-comparison bug, but it does use the same "H M" time format that
  #4711's separate unconditional-write bug affected
… on idle non-export cycles

fix(inverter): don't press the update button on idle non-export cycles
springfall2008 and others added 2 commits September 3, 2026 18:40
test_force_export_off_does_not_press_every_cycle named the FoxCloud/TESLA/
Enphase/Deye/Sunsynk/AlphaESS cloud types in its docstring as the affected
set but only parametrised GS_fb00 and GS, so the cloud behaviour rested on
GS_fb00 happening to share has_discharge_enable_time rather than on a test.

FoxCloud stands in for those types, and its expected press count is 0 rather
than 1: being HH:MM:SS format it never takes the H M unconditional register
rewrite, so the commit-once safety net gated on that format is skipped too
and an idle cloud inverter should be silent, not spend one press on a first
post-restart commit the way GS_fb00 does.

Also adds a positive control - entering an export window straight after the
idle cycles must still commit exactly once. Without it an inverter that had
stopped pressing altogether would satisfy the idle assertion just as well,
which is the failure mode that would actually matter to a Fox user.

inv.sleep is stubbed for the loop: TestHAInterface applies a service call
synchronously, so the write-and-poll read-back already sees the new value
and the sleep between them is pure wall clock. discharge_target_soc and the
idle time selects join saved_items, as the positive control writes them and
this test shares my_predbat/ha with the rest of the run.

Verified against two mutations of inverter.py, each reverted after: restoring
the pre-fix None comparison (times_supplied = True) makes FoxCloud press on
all 4 idle cycles, and disabling the press block trips the positive control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	apps/predbat/inverter.py
#	apps/predbat/tests/test_inverter.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Commit failure propagation, schedule-write result handling, and test-state isolation issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR reduces redundant Solis inverter schedule commits while preserving necessary register writes and retries.

Changes:

  • Tracks committed export schedules separately from register rewrites.
  • Scopes update-button presses and related actions to actual changes.
  • Adds regression tests for stable windows, failed commits, idle cycles, and button scoping.
File summaries
File Summary
apps/predbat/inverter.py Updates export commit tracking and button handling.
apps/predbat/tests/test_inverter.py Adds regression coverage for commit behavior and button scoping.
Review details

Suppressed comments (2)

apps/predbat/inverter.py:2789

  • times_supplied is false for adjust_force_export(False) with no arguments, but the no-discharge-enable branch above deliberately synthesizes and writes a midnight start/end. If those registers drift while export is already disabled, this check treats the write as unchanged once the cache holds the midnight tuple, so no update button is pressed to commit the new values. Include the internally generated disable window in the managed-time dirty check while keeping the GE immediate-control branch excluded.
        start_changed = times_supplied and new_start != old_start
        end_changed = times_supplied and new_end != old_end

apps/predbat/inverter.py:2641

  • This times_supplied guard, together with the new FoxCloud/GS_fb00 idle regression, implements the None-comparison fix described for #4713. That contradicts the PR description's claim that this route is untouched and is left to the stacked #4713; please update the stated scope/stacking or remove the duplicated behavior so the two PRs do not conflict.
        times_supplied = (new_start_time is not None) or (new_end_time is not None)
  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/inverter.py Outdated
Comment thread apps/predbat/inverter.py
Comment thread apps/predbat/tests/test_inverter.py
Co-authored-by: chalfontchubby <48563392+chalfontchubby@users.noreply.github.com>

Copilot AI commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🟡 Changes recommended

Commit failure propagation, schedule-write result handling, and test-state isolation issues remain unresolved....

Automated reply from Copilot: addressed the review items in cfeaad4. This covers button-failure propagation, commit-cache updates only after the relevant schedule writes succeed, and shared-state restoration in the inverter regression tests; the GS midnight disable-window path is also kept commit-aware.

Purely black's formatting of the saved_items comprehension in
test_force_export_enable_only_flip_skips_settle_sleep; it fits inside
the 256-char line limit, so black joins it. No behaviour change.

pre-commit.ci lite skipped auto-fixing this one ("skip: triggered by a
bot"), so it needed doing by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit bc853a0 into main Sep 12, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/solis-redundant-button-press-4709 branch September 12, 2026 19:29
CossieRob pushed a commit to CossieRob/batpred that referenced this pull request Sep 26, 2026
fetch_inverter_data() rebuilt every Inverter on every call, and it is called
one or more times per cycle - the plan run re-reads the inverter again before
execute_plan(). Any state an Inverter accumulated was therefore wiped before it
could be used, which is why the commit-once guard added in springfall2008#4711 held in the
unit tests (one long-lived Inverter) but never suppressed anything in
production. A reporter's log on v9.0.3 shows 377 update-button presses over one
night, one or two per cycle, with every register logging "No write needed".

The rebuild was never a deliberate design: before springfall2008#1803 fetch_inverter_data()
had no choice, it always did self.inverters = []. That PR added a create
parameter to allow reuse but kept create=True as the default, and only one
caller ever opted out, so the main loop kept rebuilding.

Keep the objects instead, and split __init__ into the identity/state half that
runs once and a refresh_config() that runs every cycle. Everything that reads
runtime-changeable config (battery_min_soc, battery_scaling, the rate limits,
battery_calibration, battery_temperature) or has a per-cycle side effect
(battery size tracking, clock skew detection, reserve bounds) moves into
refresh_config, so persisting the object does not freeze a setting a user can
change while Predbat is running. The static half - inverter type, capability
flags, dummy entity creation - cannot change under a live object, since
apps.yaml changes restart Predbat.

The create parameter is now meaningless and is removed rather than left as a
flag that silently does nothing. The two Inverter test doubles gain a no-op
refresh_config; they carry no config to re-read.

Measured with the reporter's config (GS, hour/minute entities only, a
charge_discharge_update_button), driven through fetch_inverter_data() as
production does:

    main   presses/cycle = [2, 1, 1, 1, 1, 1]
    fixed  presses/cycle = [2, 0, 0, 0, 0, 0]

State is deliberately not persisted across restarts: losing it costs one
re-commit on startup, which is defensible anyway, and is not worth an HA
storage dependency.

Addresses springfall2008#4712.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.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.

Slot time registers are rewritten every cycle even when unchanged, which zeroes the timed current registers on Solis

4 participants