Skip to content

fix(solis): stop rewriting a CID 636 TOU bit the inverter refuses - #4710

Merged
springfall2008 merged 2 commits into
mainfrom
fix/solis-tou-bit-refused-4707
Aug 24, 2026
Merged

fix(solis): stop rewriting a CID 636 TOU bit the inverter refuses#4710
springfall2008 merged 2 commits into
mainfrom
fix/solis-tou-bit-refused-4707

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Fixes #4707.

The problem

Some Solis inverters accept a CID 636 (Modbus 43110) storage mode write — the control API answers with code 0 — but keep the time-of-use bit clear. compute_solis_mode_value() starts from the cached value, the failed verify read writes the read-back into that cache, and the next cycle recomputes the same value and writes it again. Once a minute, indefinitely, with a warning each time:

Solis API: Set storage mode to 35 on <SN>
Warn: Solis API: Failed to verify CID 636 storage mode to 35 on <SN>, wrote 35 but read back 33

Why the bit is refused

On firmware "4B and above" the timed charge/discharge enable moved out of CID 636 and into the per-slot registers — SOLIS_CID_CHARGE_ENABLE_BASE/..._DISCHARGE_ENABLE_BASE, Modbus 43707, six slots rather than three — and every mode value carrying bit 1 (3, 35, 43, 51, 98) was dropped from the mode table at the same time. The modes shift down: 35 becomes 33, 98 becomes 96, 51 becomes 49. That accounts for both values in the issue, including 179 → 177 (51 + 12849 + 128, bit 7 being an independent modifier).

Corroborated by wills106/homeassistant-solax-modbus 2024.12.1 ("Simplified Energy Control Switch options", "6 charge and discharge slots rather than 3") and its separate plugin_solis_fb00.py, whose 43110 option table drops exactly the bit-1 values.

Why this does not gate on the firmware generation

is_tou_v2_mode() is not a firmware read — it checks CID 6798 for 43605 (0xAA55). The alignment with 4B is an inference, and the failure would be asymmetric: if a V2-marked inverter does honour bit 1, sending 33 means "Self-Use - No Timed Charge/Discharge" on the older table, silently disabling timed charging. Inverter B in the issue is also on the V1 path with the same signature, which a V2 gate would not touch.

So note_tou_bit_refused() learns it from the inverter instead. Only a read-back differing by exactly bit 1 counts — a write dropped wholesale says nothing about that bit and must not disable it. The verdict expires after SOLIS_TOU_BIT_REPROBE_HOURS (8) and is retested, which bounds the cost of a false positive: #4239 already found the bit is only retained while a window is configured, and slot1_active reflects Predbat's intent rather than the inverter's state, so one probe at one moment can mislead. Nothing is persisted, so a restart re-probes and a firmware update cannot be masked by a stale verdict.

Net effect: one write and one warning per inverter per 8 hours, instead of one per minute.

Second fix: no-battery inverters

automatic_config() already refused to enrol an inverter reporting batteryType 'No Battery', but only into a local list. It stayed in self.inverter_sn, so the control loop and startup_reset_registers() kept driving the full write path against PV-only hardware every cycle. Predbat never plans for it, so its stale slot 1 is never cleared, slot1_active stays true and it asks for a storage mode forever.

In the log this was diagnosed from, that was 11 of the 12 CID 636 failures. is_battery_inverter() now gates every control write. The inverter is still polled and published, so its PV sensors keep working.

Tests

Nine new tests in test_solis.py, all written before the code:

  • test_compute_solis_mode_value_can_drop_the_tou_bit — value table including 179 → 177; default behaviour pinned
  • test_storage_mode_stops_asking_for_a_refused_tou_bit — four cycles produce exactly one write and one warning
  • test_storage_mode_keeps_the_tou_bit_when_the_inverter_accepts_it — no false positive
  • test_storage_mode_does_not_learn_from_an_unrelated_verify_failure — a wholesale refusal retries with the bit
  • test_refused_tou_bit_is_retested_after_the_reprobe_window — quiet at 7h59m, one re-probe at 8h
  • test_refused_tou_bit_is_not_persisted_across_a_restart
  • test_run_skips_control_writes_for_no_battery_inverter
  • test_run_skips_startup_register_reset_for_no_battery_inverter
  • test_run_logs_why_a_no_battery_inverter_is_not_controlled

The storage-mode tests drive the real read_and_write_cid() path via a stub inverter, so the write → verify → cache round-trip is genuinely exercised.

Full suite green (./run_all, exit 0). pre-commit clean.

Not in scope

  • switch.<prefix>_solis_<sn>_time_of_use still writes bit 1 directly when a user toggles it, so on an affected inverter that switch cannot work. Worth a separate decision: hide it once a refusal is known, or let it fail visibly.
  • read_and_write_cid() verifies with no settle time. The timing evidence says lag is not what is happening here, but the code cannot distinguish refusal from lag in principle.
  • Persisting the refusal would mean introducing the Storage component to solis.py, which it does not use today. The re-probe window covers the same ground without it.

Still open

Whether the cleared bit actually breaks slot execution. On 4B firmware 33 is "Self-Use" and the slots are gated by CIDs 5916/5922, which verify fine — so this may be log noise rather than a dead plan. Worth confirming against a real charge slot.

🤖 Generated with Claude Code

Some Solis inverters accept a CID 636 (Modbus 43110) storage mode write --
the control API answers with code 0 -- but keep the time-of-use bit clear.
compute_solis_mode_value() then recomputes the same value from the cached
read-back on the next cycle, so Predbat rewrote it once a minute
indefinitely, warning each time (GH#4707).

On firmware "4B and above" that is expected: the timed charge/discharge
enable moved to the per-slot registers (CIDs 5916/5922, Modbus 43707, six
slots rather than three) and every mode value carrying bit 1 -- 3, 35, 43,
51, 98 -- was dropped from the mode table, 35 becoming 33 and 98 becoming 96.

Rather than predict that from the firmware generation, note_tou_bit_refused()
learns it from the inverter. Only a read-back differing by exactly bit 1
counts, so a write dropped wholesale does not disable it. The verdict expires
after SOLIS_TOU_BIT_REPROBE_HOURS and is retested, bounding the cost of a
false positive -- GH#4239 already found the bit is only retained while a
window is configured, and slot1_active reflects Predbat's intent rather than
the inverter's state. Nothing is persisted, so a restart re-probes and a
firmware update cannot be masked by a stale verdict.

Also stop writing control registers to an inverter Solis Cloud reports as
having no battery. automatic_config() already refused to enrol it, but only
into a local list -- it stayed in self.inverter_sn, so the control loop and
startup_reset_registers() kept driving the full write path against PV-only
hardware every cycle. Predbat never plans for it, so its stale slot 1 is
never cleared and it asks for a storage mode forever; that was 11 of the 12
CID 636 failures in the log this was diagnosed from. It is still polled and
published, so its PV sensors keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 24, 2026 17:36

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

The new “TOU bit refused” path still emits a generic warning and includes an overly-certain explanatory log message that isn’t guaranteed by the code’s detection logic.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves Solis inverter control behaviour in Predbat by (1) learning when an inverter refuses the CID 636 “TOU” bit and avoiding repeated write/verify loops, and (2) skipping all control writes to PV-only (“No Battery”) inverters while continuing to poll/publish their telemetry.

Changes:

  • Add a “TOU bit refused” learning/re-probe mechanism so storage mode writes stop retrying the same unsupported bit repeatedly.
  • Gate all control writes (including startup register reset) behind a new is_battery_inverter() check for inverters reporting batteryType: "No Battery".
  • Add a set of targeted Solis tests covering the new storage-mode learning behaviour, re-probe expiry, and no-battery write suppression.
File summaries
File Description
apps/predbat/solis.py Learns and re-probes TOU-bit refusal for CID 636; adds PV-only inverter write gating.
apps/predbat/tests/test_solis.py Adds new unit tests for TOU-bit refusal learning/re-probe and “No Battery” inverter write skipping/logging.
.claude/skills/issue-triage/references/debug-journal.md Updates internal debugging notes to reflect the new understanding/handling of CID 636 TOU-bit refusal and PV-only inverter behaviour.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • 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/solis.py
Comment thread apps/predbat/solis.py
Comment on lines 2578 to 2583
# Write storage mode CID
success = await self.read_and_write_cid(inverter_sn, SOLIS_CID_STORAGE_MODE, mode_value, field_description=f"storage mode to {mode_value}")
if not success:
self.note_tou_bit_refused(inverter_sn, mode_value)
self.log(f"Warn: Solis API set storage mode encountered errors for {inverter_sn}")

@springfall2008

Copy link
Copy Markdown
Owner Author

Source for the "bit 1 was dropped from the mode table" claim

Permalinked at 82e072fd in wills106/homeassistant-solax-modbus, so the line numbers stay valid.

The same register, in the two firmware plugins that project ships.

plugin_solis.py L1053-1074 — pre-4B:

        register=43110,
        option_dict={
            1: "Self-Use - No Grid Charging",
            3: "Timed Charge/Discharge - No Grid Charging",
            17: "Backup/Reserve - No Grid Charging",
            33: "Self-Use - No Timed Charge/Discharge",
            35: "Self-Use",
            37: "Off-Grid Mode",
            41: "Battery Awaken",
            43: "Battery Awaken + Timed Charge/Discharge",
            49: "Backup/Reserve - No Timed Charge/Discharge",
            51: "Backup/Reserve",
            64: "Feed-in priority - No Grid Charging",
            96: "Feed-in priority - No Timed Charge/Discharge",
            98: "Feed-in priority",
        },

plugin_solis_fb00.py L2209-2225 — 4B and above:

        register=43110,
        option_dict={
            1: "Self-Use - No Grid Charging",
            17: "Backup/Reserve - No Grid Charging",
            33: "Self-Use",
            37: "Off-Grid Mode",
            41: "Battery Awaken",
            49: "Backup/Reserve",
            64: "Feed-in priority - No Grid Charging",
            96: "Feed-in priority",
        },

Removed: 3, 35, 43, 51, 98 — exactly the values with bit 1 set. The names then slide down onto the bit-1-clear values: 33 goes from "Self-Use - No Timed Charge/Discharge" to plain "Self-Use", and 49 and 96 do the same for Backup/Reserve and Feed-in priority.

That is both values from the issue:

  • 35 → 33 — old "Self-Use" to new "Self-Use".
  • 179 → 17751 + 128 to 49 + 128; old "Backup/Reserve" to new "Backup/Reserve", bit 7 being an independent modifier and surviving.

The read-only mirrors of the register carry the same two tables: plugin_solis.py:1638 and plugin_solis_fb00.py:2790 (register 33132).

Where the enable moved to

plugin_solis_fb00.py L1511-1522 adds a register the older plugin does not have at all:

        name="Timed Charge Discharge On Off",
        key="timed_charge_discharge_on_off",
        register=43707,

and L2060-2078 drives it one bit per slot:

        name="Timed Charge Slot 1 Enable",
        register=43707,
        register_bit=0,
...
        name="Timed Charge Slot 2 Enable",
        register=43707,
        register_bit=1,

43707 appears zero times in plugin_solis.py. Those bits are Predbat's SOLIS_CID_CHARGE_ENABLE_BASE / SOLIS_CID_DISCHARGE_ENABLE_BASE CIDs, which the V2 path already writes and verifies successfully.

Provenance

Release 2024.12.1:

Solis Firmware 4B and above: (Separate plugin, until firmware detection is working #1142 #1143 thanks @fboundy)

  • Simplified Energy Control Switch options
  • 6 charge and discharge slots rather than 3

Field reports of the same symptom on the newer hardware: Pho3niX90/solis_modbus#93 (S6-EH3P20K-H, "Time Of Use switch turns off again after about 30/40 seconds") and wills106/homeassistant-solax-modbus#1110 ("My Solis have new firmware with better charge/discharge control. With this firmware, Time Of Use switch not work"), the latter closed by the 2024.12.1 plugin above.

Two caveats

  1. This is a community integration, not a Solis document. The authoritative source is RS485_MODBUS (ESINV-33000ID) Hybrid Inverter V3.2, Appendix 8 — which Pho3niX90/solis_modbus cites for its own table, but which is NDA-gated.
  2. All of the above is register 43110 over local Modbus. That CID 636 is the cloud-side handle for 43110 is an inference, resting on Predbat's SOLIS_BIT_* constants matching that bit layout exactly and its storage mode names matching the pre-4B option labels verbatim.

Neither caveat affects this PR: note_tou_bit_refused() learns the refusal from the inverter's own read-back and does not depend on any of it.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@springfall2008
springfall2008 merged commit cc8c494 into main Aug 24, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/solis-tou-bit-refused-4707 branch August 24, 2026 18:26
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.

Solis: inverter clears the TOU bit on CID 636 while a slot is active, so timed charge/discharge slots are ignored

2 participants