From 925684580821856ccb2a2dfc9d8bcb3312eb1e96 Mon Sep 17 00:00:00 2001 From: CI Date: Sun, 27 Sep 2026 11:58:19 +0100 Subject: [PATCH 1/2] feat(solis): SolisCloud freeze charge and holds via the Battery Reserve Freeze charge: SolisCloud has no reserve, so Predbat freezes by keeping the charge window with the target at the current SoC. The component treated that as a normal charge on V2 (Self-Use, grid charging on - it topped up from the grid) and, on V1, zeroed the charge current at target so the 0A rule chose Feed-in priority, exporting PV during the freeze. - is_holding_in_charge_slot(): a charge slot open now with the battery at or above its target is only holding. Write 'Self-Use - No Grid Charging' (ENUM_SELF_USE_HOLD: 3 on V1, where bit 1 is the slot enable; 1 on V2) ahead of the 0A -> Feed-in rule Holds (car/iBoost, freeze or hold on reserve) did nothing on SolisCloud: has_reserve_soc was False and reserve was bound to the over-discharge SOC. - Bind reserve to the Battery Reserve SOC (CID 157) and declare has_reserve_soc, keeping battery_min_soc on the over-discharge SOC - set_storage_mode_if_needed() sets the Battery Reserve bit (CID 636 bit 4) while set_reserve_enable is on, so that SOC is a real discharge floor; it is left alone otherwise, as enabling it unmanaged would enforce whatever reserve SOC the inverter already held. Outside slots the component already writes a No-Grid-Charging mode, so a raised reserve is never imported to - Discovery record reports reserve (rw) as well as battery_min_soc; spec D9 marked superseded - Docs (Solis component notes, discovery catalogue) and debug journal Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/config.py | 3 +- apps/predbat/solis.py | 88 +++++++-- apps/predbat/tests/test_discovery_contract.py | 5 +- apps/predbat/tests/test_solis.py | 170 ++++++++++++++++-- docs/components.md | 2 + docs/discovery-catalogue.md | 4 +- ...overy-inverter-record-vocabulary-design.md | 4 +- tools/debug-journal.md | 2 +- 8 files changed, 241 insertions(+), 37 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 486650642..16ea36bdd 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2460,7 +2460,8 @@ "has_charge_enable_time": True, "has_discharge_enable_time": True, "has_target_soc": True, - "has_reserve_soc": False, + # The Battery Reserve SOC (CID 157), a floor while the component keeps the Battery Reserve bit on + "has_reserve_soc": True, "has_timed_pause": False, "charge_time_format": "HH:MM:SS", "charge_time_entity_is_option": True, diff --git a/apps/predbat/solis.py b/apps/predbat/solis.py index e9c266ddb..8a81107ae 100644 --- a/apps/predbat/solis.py +++ b/apps/predbat/solis.py @@ -181,12 +181,14 @@ ENUM_SELF_USE = 2 ENUM_FEED_IN_PRIORITY = 3 ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING = 4 +ENUM_SELF_USE_HOLD = 5 SOLIS_STORAGE_MODES = { "Other": ENUM_OTHER, "Self-Use": ENUM_SELF_USE, "Self-Use - No Timed Charge/Discharge": ENUM_SELF_USE_NO_GRID_CHARGING, "Feed-in priority": ENUM_FEED_IN_PRIORITY, - "Feed-in priority - No Timed Charge/Discharge": ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING + "Feed-in priority - No Timed Charge/Discharge": ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING, + "Self-Use - No Grid Charging": ENUM_SELF_USE_HOLD, } # Real solis mode bits @@ -291,6 +293,8 @@ def get_solis_mode_enum(value): mode = ENUM_SELF_USE elif value & (1 << SOLIS_BIT_FEED_IN_PRIORITY): mode = ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING + elif value & (1 << SOLIS_BIT_SELF_USE) and (value & (1 << SOLIS_BIT_TOU_MODE)): + mode = ENUM_SELF_USE_HOLD elif value & (1 << SOLIS_BIT_SELF_USE): mode = ENUM_SELF_USE_NO_GRID_CHARGING mode_str = "Other" @@ -300,7 +304,7 @@ def get_solis_mode_enum(value): break return mode, mode_str -def compute_solis_mode_value(mode_enum, old_value, drop_tou_bit=False): +def compute_solis_mode_value(mode_enum, old_value, drop_tou_bit=False, battery_reserve=None): """ Convert storage mode enum to bit flag value for CID 636 @@ -312,6 +316,9 @@ def compute_solis_mode_value(mode_enum, old_value, drop_tou_bit=False): into the per-slot registers (CIDs 5916/5922) and every mode value carrying bit 1 was dropped from the table, so 35 becomes 33 and 98 becomes 96. Set by set_storage_mode_if_needed() from is_tou_v2_mode() (see issues #4707, #4774). + battery_reserve: True sets the Battery Reserve bit, which makes the Battery Reserve SOC (CID 157) a + discharge floor; None leaves it as old_value has it, so a reserve the user turned on + is not cleared. Set by set_storage_mode_if_needed() while set_reserve_enable is on. Returns: The value to write to CID 636 """ @@ -341,8 +348,19 @@ def compute_solis_mode_value(mode_enum, old_value, drop_tou_bit=False): value &= ~(1 << SOLIS_BIT_OFF_GRID) # Clear off-grid bit value &= ~(1 << SOLIS_BIT_TOU_MODE) # Clear TOU mode bit + elif mode_enum == ENUM_SELF_USE_HOLD: + # Self-Use with timed charge/discharge still on but no grid charging: a charge slot that is only + # holding the battery. The TOU bit stays for V1 firmware, where it is the slot enable. + value |= (1 << SOLIS_BIT_SELF_USE) # Set self-use bit + value &= ~(1 << SOLIS_BIT_GRID_CHARGING) # Clear grid charging bit + value &= ~(1 << SOLIS_BIT_FEED_IN_PRIORITY) # Clear feed-in priority bit + value &= ~(1 << SOLIS_BIT_OFF_GRID) # Clear off-grid bit + value |= (1 << SOLIS_BIT_TOU_MODE) # Set TOU mode bit + if drop_tou_bit: value &= ~(1 << SOLIS_BIT_TOU_MODE) + if battery_reserve: + value |= (1 << SOLIS_BIT_BACKUP_MODE) return value @@ -1074,6 +1092,31 @@ def restore_refused_slot_write(self, inverter_sn, slot, direction, kind, cid, at slot_data.update(restored) self.log(f"Warn: Solis API: {direction} slot {slot} {kind} was not accepted by {inverter_sn}, showing the inverter's {inverter_value} so Predbat writes it again") + def is_holding_in_charge_slot(self, inverter_sn, slot_data): + """True when slot_data is a charge slot open now with the battery already at or above its target SOC. + + That is what a freeze charge looks like here - Predbat keeps the charge window with the target at the + current SoC - and what the end of a charge looks like once the target is reached. The slot keeps the + battery from discharging, so with grid charging off it holds without importing to top up. + + Args: + inverter_sn: Inverter serial number + slot_data: One slot of the charge/discharge schedule, or None + + Returns: True when the slot is only holding the battery + """ + if not slot_data or not slot_data.get("charge_enable", 0): + return False + current_time = self.now_utc_exact.strftime("%H:%M") # Local time, as the slot times are + if not time_in_window(current_time, slot_data.get("charge_start_time", "00:00"), slot_data.get("charge_end_time", "00:00")): + return False + try: + battery_soc = float(self.inverter_details.get(inverter_sn, {}).get("batteryCapacitySoc")) + charge_soc = float(slot_data.get("charge_soc", 100)) + except (TypeError, ValueError): + return False + return battery_soc >= charge_soc + async def write_time_windows_if_changed(self, inverter_sn): """Write charge/discharge time windows, SOC, and current to inverter, only if values changed from cache. Automatically handles V1 vs V2 modes and only writes registers that have changed. @@ -1102,6 +1145,7 @@ async def write_time_windows_if_changed(self, inverter_sn): charge_current = max_charge_current_amps discharge_current = max_discharge_current_amps slot1_active = False # Whether slot 1 has any charge/discharge window configured + holding = False # Whether slot 1 is a charge slot open now with the battery at its target # There appears to be charge current limit on the slots which confused as slot 1 has a higher limit than the others, # but the value isn't accepted, find the lowest slot limit @@ -1130,6 +1174,7 @@ async def write_time_windows_if_changed(self, inverter_sn): # Predbat only uses slot 1, so it can indicate current here. charge_current = slot_data.get("charge_current", charge_current) slot1_active = bool(slot_data.get("charge_enable", 0)) or bool(slot_data.get("discharge_enable", 0)) + holding = self.is_holding_in_charge_slot(inverter_sn, slot_data) discharge_current = slot_data.get("discharge_current", discharge_current) # When a slot is disabled, zero out its times so the inverter shows a clean 00:00-00:00 @@ -1263,8 +1308,12 @@ async def write_time_windows_if_changed(self, inverter_sn): # Decide if Solar charges the batter or exports # Gate the mode choice on whether slot 1 has a window configured, the same way the V1 # branch does below (see the in_charge_slot/in_discharge_slot handling), so the - # inverter is not left in a timed mode with nothing scheduled. - if charge_current == 0: + # inverter is not left in a timed mode with nothing scheduled. A charge slot that is only + # holding the battery comes first: a freeze charge must not export PV via the 0A rule. + if holding: + self.log(f"Solis API: In a charge slot at its target SOC on {inverter_sn}, setting storage mode to 'Self-Use - No Grid Charging' so it holds without importing") + await self.set_storage_mode_if_needed(inverter_sn, "Self-Use - No Grid Charging") + elif charge_current == 0: if slot1_active: self.log(f"Solis API: Charge current is 0A for {inverter_sn}, setting storage mode to 'Feed-in priority'") await self.set_storage_mode_if_needed(inverter_sn, "Feed-in priority") @@ -1289,6 +1338,7 @@ async def write_time_windows_if_changed(self, inverter_sn): # if we are inside the slot then compare the current SOC to the target SOC and decide if we zero out the Amps or not in_charge_slot = None + holding = False # Whether the open charge slot is only holding the battery at its target in_discharge_slot = None current_time = self.now_utc_exact.strftime("%H:%M") # Use local time to match time windows set by Predbat in local timezone max_charge_current_amps = self.cached_values.get(inverter_sn, {}).get(SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT, None) @@ -1341,6 +1391,7 @@ async def write_time_windows_if_changed(self, inverter_sn): charge_current = slot_data.get("charge_current", charge_current) elif in_charge_slot: slot_data = time_windows.get(in_charge_slot) + holding = self.is_holding_in_charge_slot(inverter_sn, slot_data) charge_soc_to_write = slot_data.get("charge_soc", 100) if battery_soc >= charge_soc_to_write: # Zero out charge current if above target SOC @@ -1383,8 +1434,13 @@ async def write_time_windows_if_changed(self, inverter_sn): else: self.log(f"Solis API: Time windows unchanged for {inverter_sn}, skipping SOLIS_CID_CHARGE_DISCHARGE_SETTINGS write") - # Decide if Solar charges the battery or exports - if charge_current == 0: + # Decide if Solar charges the battery or exports. A charge slot that is only holding the + # battery comes first, as in V2: the zeroed charge current above would otherwise select + # Feed-in priority and export PV during a freeze charge. + if holding: + self.log(f"Solis API: In a charge slot at its target SOC on {inverter_sn}, setting storage mode to 'Self-Use - No Grid Charging' so it holds without importing") + await self.set_storage_mode_if_needed(inverter_sn, "Self-Use - No Grid Charging") + elif charge_current == 0: if in_charge_slot or in_discharge_slot: self.log(f"Solis API: Charge current is 0A for {inverter_sn}, setting storage mode to 'Feed-in priority'") await self.set_storage_mode_if_needed(inverter_sn, "Feed-in priority") @@ -1837,7 +1893,10 @@ async def automatic_config(self): # self.set_arg("soc_max", [f"sensor.{self.prefix}_solis_{device}_battery_capacity" for device in devices]) # Reserve and limits - self.set_arg_auto("reserve", [f"number.{self.prefix}_solis_{device}_over_discharge_soc" for device in devices]) + # reserve is the Battery Reserve SOC (CID 157): a discharge floor while set_storage_mode_if_needed keeps the + # Battery Reserve bit on, which is how Predbat holds the battery (car/iBoost, freeze or hold on reserve). + # battery_min_soc stays the over-discharge SOC, the hard minimum Predbat only reads. + self.set_arg_auto("reserve", [f"number.{self.prefix}_solis_{device}_reserve_soc" for device in devices]) self.set_arg_auto("battery_min_soc", [f"number.{self.prefix}_solis_{device}_over_discharge_soc" for device in devices]) # Charge/discharge controls - using slot 1 for Predbat primary control @@ -1882,10 +1941,10 @@ def _discovery_entities(self, sn): for a setting inverter.py writes (the slot 1 schedule, SoC and power controls) and "r" for one it only reads. - Two automatic_config() bindings are deliberately absent. reserve: SolisCloud does not write - the reserve, the row has has_reserve_soc False and inverter.py replaces the binding with a - dummy, so the record reports battery_min_soc - the same over_discharge_soc number - instead - (spec D9). battery_power_invert: it becomes invert on battery_power. + One automatic_config() binding is deliberately absent: battery_power_invert, which becomes invert + on battery_power. reserve is the Battery Reserve SOC Predbat writes, and battery_min_soc the + over-discharge SOC it only reads (this supersedes spec D9, which dated from reserve being bound + to the over-discharge SOC). load_today, pv_today, load_power and pv_power are carried even when solis_cloud_pv_load_ignore stops automatic_config() binding them: the record describes the device, and the user's opt-out @@ -1907,6 +1966,7 @@ def _discovery_entities(self, sn): { "import_today": {"entity_id": f"sensor.{prefix}_solis_{device}_today_import_energy", "access": "r"}, "export_today": {"entity_id": f"sensor.{prefix}_solis_{device}_today_export_energy", "access": "r"}, + "reserve": {"entity_id": f"number.{prefix}_solis_{device}_reserve_soc", "access": "rw", "unit": "%"}, "battery_min_soc": {"entity_id": f"number.{prefix}_solis_{device}_over_discharge_soc", "access": "r", "unit": "%"}, "charge_start_time": {"entity_id": f"select.{prefix}_solis_{device}_charge_slot1_start_time", "access": "rw", "domain": "select", "format": "HH:MM:SS"}, "charge_end_time": {"entity_id": f"select.{prefix}_solis_{device}_charge_slot1_end_time", "access": "rw", "domain": "select", "format": "HH:MM:SS"}, @@ -3214,7 +3274,11 @@ async def set_storage_mode_if_needed(self, inverter_sn, mode): cached_mode = self.get_current_solis_mode_value(inverter_sn) # TOU V2 firmware dropped bit 1 from the mode table, so asking for it there only # produces a write that reads back without it (issues #4707, #4774) - mode_value = compute_solis_mode_value(mode_enum, cached_mode, drop_tou_bit=self.is_tou_v2_mode(inverter_sn)) + # Keep the Battery Reserve bit on while Predbat manages the reserve, so the Battery Reserve SOC it + # writes is a real floor. Without set_reserve_enable Predbat never writes that SOC, and turning the + # bit on would enforce whatever the inverter already holds there, so the bit is left alone. + battery_reserve = True if self.base.get_arg("set_reserve_enable", False) else None + mode_value = compute_solis_mode_value(mode_enum, cached_mode, drop_tou_bit=self.is_tou_v2_mode(inverter_sn), battery_reserve=battery_reserve) # Claimed unconditionally so the claim is spent by whichever write happens this cycle, # rather than being held over and asserting again later in the same window diff --git a/apps/predbat/tests/test_discovery_contract.py b/apps/predbat/tests/test_discovery_contract.py index 64613f599..1982cfb5f 100644 --- a/apps/predbat/tests/test_discovery_contract.py +++ b/apps/predbat/tests/test_discovery_contract.py @@ -120,8 +120,9 @@ def test_agreement_names_each_disagreement(): def test_dummied_settings_follow_the_row(): - """SolisCloud has no reserve, and no cloud type has a GE mode entity.""" - assert "reserve" in dummied_settings("SolisCloud") + """GS has no reserve, SolisCloud now does (its Battery Reserve SOC), and no cloud type has a GE mode entity.""" + assert "reserve" in dummied_settings("GS") + assert "reserve" not in dummied_settings("SolisCloud") assert "reserve" not in dummied_settings("SunsynkCloud") assert "inverter_mode" in dummied_settings("SunsynkCloud") assert "inverter_mode" not in dummied_settings("GE") diff --git a/apps/predbat/tests/test_solis.py b/apps/predbat/tests/test_solis.py index 43dd96f7b..fa38740a4 100644 --- a/apps/predbat/tests/test_solis.py +++ b/apps/predbat/tests/test_solis.py @@ -24,7 +24,7 @@ from solis import SOLIS_CID_POWER_LIMIT, SOLIS_BIT_BACKUP_MODE from solis import SOLIS_READ_ENDPOINT, SOLIS_READ_BATCH_ENDPOINT, SOLIS_CONTROL_ENDPOINT, SOLIS_INVERTER_LIST_ENDPOINT, SOLIS_INVERTER_DETAIL_ENDPOINT from solis import get_solis_mode_enum, compute_solis_mode_value -from solis import ENUM_OTHER, ENUM_SELF_USE, ENUM_SELF_USE_NO_GRID_CHARGING, ENUM_FEED_IN_PRIORITY, ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING +from solis import ENUM_OTHER, ENUM_SELF_USE, ENUM_SELF_USE_NO_GRID_CHARGING, ENUM_FEED_IN_PRIORITY, ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING, ENUM_SELF_USE_HOLD from solis import SOLIS_BIT_SELF_USE, SOLIS_BIT_FEED_IN_PRIORITY, SOLIS_BIT_OFF_GRID from solis import SOLIS_CID_MAX_EXPORT_POWER, SOLIS_CLOUD_CAPABILITIES from coordinator import inverter_definition, validate_report @@ -36,12 +36,13 @@ class MockBase: def __init__(self, prefix="predbat"): self.prefix = prefix + self.args = {} def get_arg(self, key, default=None): - """Mock get_arg method""" + """Mock get_arg method - prefix, then anything a test put in args, else the default""" if key == "prefix": return self.prefix - return default + return self.args.get(key, default) class MockSolisAPI(SolisAPI): @@ -1502,27 +1503,28 @@ def test_solis_catalogue_two_inverters_get_their_own_entities(): return False -def test_solis_catalogue_reports_battery_min_soc_not_reserve(): - """Spec D9: the record carries battery_min_soc (read-only entity plus rating) and no reserve. +def test_solis_catalogue_reports_reserve_soc_and_battery_min_soc(): + """The record carries reserve as the Battery Reserve SOC (CID 157, written) and battery_min_soc as the over-discharge SOC (read-only). - automatic_config() binds reserve and battery_min_soc to the same over_discharge_soc number, and - inverter.py dummies the reserve binding because the SolisCloud row has has_reserve_soc False - - so the record leaves reserve out and the derived has_reserve_soc is False. Also pins the row's - time_button_press False (no schedule_write_button is bound) and battery_power_invert's string - "True" becoming invert on battery_power. + Superseding spec D9: the reserve Predbat writes is the Reserved SOC, which the inverter holds as a + discharge floor while the Battery Reserve bit of CID 636 is on (set_storage_mode_if_needed keeps it on + while set_reserve_enable is). The over-discharge SOC stays the hard minimum it only reads. Also pins + the row's time_button_press False (no schedule_write_button is bound) and battery_power_invert's + string "True" becoming invert on battery_power. """ api = _solis_fleet() api.cached_values["BAT001"][SOLIS_CID_BATTERY_OVER_DISCHARGE_SOC] = "20" record = validated_inverters(api.build_discovery())[0] entities = record["entities"] - assert "reserve" not in entities, entities.get("reserve") + assert entities["reserve"] == {"entity_id": "number.predbat_solis_bat001_reserve_soc", "access": "rw", "unit": "%"}, entities.get("reserve") assert entities["battery_min_soc"] == {"entity_id": "number.predbat_solis_bat001_over_discharge_soc", "access": "r", "unit": "%"}, entities["battery_min_soc"] assert record["ratings"]["battery_min_soc"] == 20, record["ratings"] captured = capture_automatic_config(api) - assert captured["reserve"] == captured["battery_min_soc"], "automatic_config() binds both to over_discharge_soc" + assert captured["reserve"] == ["number.predbat_solis_bat001_reserve_soc"], captured["reserve"] + assert captured["battery_min_soc"] == ["number.predbat_solis_bat001_over_discharge_soc"], captured["battery_min_soc"] definition, gaps, _ = inverter_definition(record, SolisAPI.WRITE_AND_POLL_SLEEP) assert not gaps, gaps - assert definition["has_reserve_soc"] is False + assert definition["has_reserve_soc"] is True assert "schedule_write_button" not in entities and "schedule_write_button" not in captured assert definition["time_button_press"] is False assert captured["battery_power_invert"] == ["True"] and entities["battery_power"]["invert"] is True, entities["battery_power"] @@ -1902,7 +1904,7 @@ def run_solis_tests(my_predbat): failed |= test_solis_catalogue_mixed_fleet_matches_each_list_by_position() failed |= test_solis_catalogue_pv_load_ignore_keeps_the_entities() failed |= test_solis_catalogue_two_inverters_get_their_own_entities() - failed |= test_solis_catalogue_reports_battery_min_soc_not_reserve() + failed |= test_solis_catalogue_reports_reserve_soc_and_battery_min_soc() failed |= test_solis_catalogue_export_limit_is_the_configured_register() failed |= asyncio.run(test_solis_catalogue_filed_even_when_automatic_config_configures_nothing()) failed |= asyncio.run(test_solis_catalogue_filed_when_automatic_config_gate_is_closed()) @@ -1933,6 +1935,9 @@ def run_solis_tests(my_predbat): failed |= asyncio.run(test_storage_mode_failure_does_not_fail_control_write()) failed |= asyncio.run(test_recovery_soc_polled_outside_batch()) failed |= asyncio.run(test_write_time_windows_zero_charge_current()) + failed |= asyncio.run(test_write_time_windows_hold_in_charge_slot()) + failed |= asyncio.run(test_compute_solis_mode_value_hold_and_reserve()) + failed |= asyncio.run(test_storage_mode_sets_battery_reserve_with_set_reserve_enable()) failed |= asyncio.run(test_write_time_windows_v1_slot_detection()) failed |= asyncio.run(test_write_time_windows_v1_discharge_slot_detection()) failed |= asyncio.run(test_write_time_windows_v1_local_time_not_utc()) @@ -3541,6 +3546,136 @@ async def test_write_time_windows_zero_charge_current(): return False +def _hold_slot_api(v2, battery_soc, charge_soc, charge_current=50): + """A MockSolisAPI at 03:00 inside charge slot 1 (02:00-05:00) with the battery at battery_soc. + + Args: + v2: True for TOU V2 firmware (per-slot registers), False for V1 (CID 103) + battery_soc: batteryCapacitySoc the inverter reports + charge_soc: Slot 1's charge target SOC + charge_current: Slot 1's charge current in amps + + Returns: (api, inverter_sn) + """ + api = MockSolisAPI() + api._test_v2_mode = v2 + api._mock_storage_mode = True + inverter_sn = "TEST123" + api.inverter_sn = [inverter_sn] + empty = {"charge_start_time": "00:00", "charge_end_time": "00:00", "charge_soc": 100, "charge_current": 0, "discharge_start_time": "00:00", "discharge_end_time": "00:00", "discharge_soc": 10, "discharge_current": 0} + slot1 = { + "charge_enable": 1, + "charge_start_time": "02:00", + "charge_end_time": "05:00", + "charge_soc": charge_soc, + "charge_current": charge_current, + "discharge_enable": 0, + "discharge_start_time": "00:00", + "discharge_end_time": "00:00", + "discharge_soc": 10, + "discharge_current": 30, + } + if v2: + api.charge_discharge_time_windows[inverter_sn] = {1: slot1} + else: + api.charge_discharge_time_windows[inverter_sn] = {1: dict(slot1, field_length=18), 2: dict(empty, field_length=18), 3: dict(empty, field_length=18)} + api.cached_values[inverter_sn] = {} + api.inverter_details[inverter_sn] = {"batteryCapacitySoc": battery_soc} + mock_now = MagicMock() + mock_now.strftime.return_value = "03:00" + api._test_now_utc_exact = mock_now + return api, inverter_sn + + +async def test_write_time_windows_hold_in_charge_slot(): + """In an active charge slot with the battery at or above its target, grid charging goes off. + + That is what a freeze charge looks like here - Predbat keeps the charge window with the target at the + current SoC - and a hold at the end of a charge: the slot keeps the battery from discharging, and + without grid charging it cannot import to top up to the target, while PV can still charge it. It + takes precedence over the 0A -> Feed-in priority rule, which would export PV during a freeze charge. + Below the target it is an ordinary charge and grid charging stays on. + """ + print("\n=== Test: write_time_windows_if_changed hold in charge slot ===") + cases = [ + # (v2, battery_soc, charge_soc, charge_current, expected mode) + (True, 60, 60, 50, "Self-Use - No Grid Charging"), + (True, 61, 60, 50, "Self-Use - No Grid Charging"), + (True, 60, 60, 0, "Self-Use - No Grid Charging"), + (True, 40, 60, 50, "Self-Use"), + (False, 60, 60, 50, "Self-Use - No Grid Charging"), + (False, 40, 60, 50, "Self-Use"), + ] + for v2, battery_soc, charge_soc, charge_current, expected in cases: + api, inverter_sn = _hold_slot_api(v2, battery_soc, charge_soc, charge_current) + result = await api.write_time_windows_if_changed(inverter_sn) + assert result is True, "write_time_windows_if_changed should return True" + modes = [call["mode"] for call in api.set_storage_mode_calls] + assert modes == [expected], "v2={} soc={} target={} current={}A: expected [{}], got {}".format(v2, battery_soc, charge_soc, charge_current, expected, modes) + + print("PASSED: charge slot at target holds without grid charging") + return False + + +async def test_compute_solis_mode_value_hold_and_reserve(): + """The hold mode keeps the TOU bit only where it is the slot enable, and battery_reserve sets bit 4. + + Self-Use - No Grid Charging is Self-Use with timed charge/discharge on but grid charging off: 3 on V1 + firmware, where bit 1 enables the slots, and 1 on TOU V2, where the per-slot registers do. The existing + Self-Use - No Timed Charge/Discharge (1) would switch the V1 slots off and lose the hold. + battery_reserve=True sets the Battery Reserve bit so the Reserved SOC is a discharge floor; left at None + the bit is kept as it was, so a Battery Reserve the user turned on is not cleared. + """ + print("\n=== Test: compute_solis_mode_value hold and battery reserve ===") + assert compute_solis_mode_value(ENUM_SELF_USE_HOLD, 0) == 3 + assert compute_solis_mode_value(ENUM_SELF_USE_HOLD, 0, drop_tou_bit=True) == 1 + assert compute_solis_mode_value(ENUM_SELF_USE_HOLD, 35) == 3, "grid charging and nothing else cleared from Self-Use" + assert get_solis_mode_enum(3) == (ENUM_SELF_USE_HOLD, "Self-Use - No Grid Charging") + assert get_solis_mode_enum(1) == (ENUM_SELF_USE_NO_GRID_CHARGING, "Self-Use - No Timed Charge/Discharge") + + backup = 1 << SOLIS_BIT_BACKUP_MODE + assert compute_solis_mode_value(ENUM_SELF_USE, 0, battery_reserve=True) == 35 | backup + assert compute_solis_mode_value(ENUM_SELF_USE, 0, drop_tou_bit=True, battery_reserve=True) == 49 + assert compute_solis_mode_value(ENUM_SELF_USE_NO_GRID_CHARGING, 0, battery_reserve=True) == 17 + assert compute_solis_mode_value(ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING, 0, battery_reserve=True) == 80 + assert compute_solis_mode_value(ENUM_SELF_USE, backup) == 35 | backup, "bit 4 is kept when battery_reserve is not asked for" + assert compute_solis_mode_value(ENUM_SELF_USE, 0) == 35, "and not added" + assert get_solis_mode_enum(49) == (ENUM_SELF_USE, "Self-Use") + assert get_solis_mode_enum(80) == (ENUM_FEED_IN_PRIORITY_NO_GRID_CHARGING, "Feed-in priority - No Timed Charge/Discharge") + + print("PASSED: hold mode and battery reserve bit") + return False + + +async def test_storage_mode_sets_battery_reserve_with_set_reserve_enable(): + """set_storage_mode_if_needed turns the Battery Reserve bit on only while Predbat manages the reserve. + + With set_reserve_enable on, Predbat writes the Reserved SOC (reserve_min normally, SoC + 1 for a hold), + so the bit makes that a real floor. With it off Predbat never writes the Reserved SOC, and turning the bit + on would enforce whatever the inverter already holds there, so the bit is left alone. + """ + print("\n=== Test: storage mode Battery Reserve bit follows set_reserve_enable ===") + for reserve_enable, mode, cached, expected in [ + (True, "Self-Use", "1", "49"), + (True, "Self-Use - No Timed Charge/Discharge", "33", "17"), + (True, "Feed-in priority - No Timed Charge/Discharge", "1", "80"), + (False, "Self-Use", "1", "33"), + (False, "Self-Use - No Timed Charge/Discharge", "33", "1"), + ]: + api = MockSolisAPI() + api._test_v2_mode = True + inverter_sn = "RES001" + api.inverter_sn = [inverter_sn] + api.base.args["set_reserve_enable"] = reserve_enable + api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: cached} + await api.set_storage_mode_if_needed(inverter_sn, mode) + values = [call["value"] for call in api.read_and_write_cid_calls if call["cid"] == SOLIS_CID_STORAGE_MODE] + assert values == [expected], "set_reserve_enable={} {} from {}: expected [{}], got {}".format(reserve_enable, mode, cached, expected, values) + + print("PASSED: Battery Reserve bit follows set_reserve_enable") + return False + + async def test_write_time_windows_v1_slot_detection(): """Test V1 mode slot detection for active charge/discharge windows""" print("\n=== Test: write_time_windows_if_changed V1 mode slot detection ===") @@ -5781,12 +5916,13 @@ def mock_set_arg(key, value): assert "export_today" in set_arg_calls, "export_today not configured" assert "pv_today" in set_arg_calls, "pv_today not configured" - # Verify reserve and limits use over_discharge_soc (not reserve_soc) + # Verify the reserve is the Battery Reserve SOC Predbat writes, and the minimum the over-discharge SOC it reads assert "reserve" in set_arg_calls, "reserve not configured" - expected_reserve = ["number.predbat_solis_abc123_over_discharge_soc", "number.predbat_solis_def456_over_discharge_soc"] + expected_reserve = ["number.predbat_solis_abc123_reserve_soc", "number.predbat_solis_def456_reserve_soc"] assert set_arg_calls["reserve"] == expected_reserve, f"Expected {expected_reserve}, got {set_arg_calls['reserve']}" assert "battery_min_soc" in set_arg_calls, "battery_min_soc not configured" - assert set_arg_calls["battery_min_soc"] == expected_reserve, f"Expected {expected_reserve}, got {set_arg_calls['battery_min_soc']}" + expected_min_soc = ["number.predbat_solis_abc123_over_discharge_soc", "number.predbat_solis_def456_over_discharge_soc"] + assert set_arg_calls["battery_min_soc"] == expected_min_soc, f"Expected {expected_min_soc}, got {set_arg_calls['battery_min_soc']}" # Verify rate controls configured assert "battery_rate_max" in set_arg_calls, "battery_rate_max not configured" diff --git a/docs/components.md b/docs/components.md index 51f3f7111..798b08772 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1212,6 +1212,8 @@ Integrates with Solis inverters for monitoring and controlling Solis battery sys - Manually set `soc_max` in `apps.yaml` with your battery capacity in kWh (recommended), or - Leave `soc_max` unset and allow Predbat to automatically detect battery size from historical charging data (requires several days of data) - Supports both V1 (older firmware) and V2 (newer firmware) time window formats +- **Reserve and holds**: Predbat's reserve is the inverter's **Battery Reserve SOC**. With **switch.predbat_set_reserve_enable** on, Predbat keeps the inverter's Battery Reserve switched on so that SOC is a real discharge floor, and uses it to hold the battery - while a car or iBoost charges, and for freeze or hold charging. With it off, Predbat leaves the Battery Reserve switch as you set it +- **Freeze charging**: inside a charge slot with the battery already at the slot's target SOC, Predbat sets the storage mode to `Self-Use - No Grid Charging`, so the slot holds the battery without importing from the grid while solar can still charge it - Automatic configuration available - sets up all required Predbat sensors automatically - **PV-only inverters**: an inverter Solis Cloud reports as having no battery is never managed as a battery inverter and is never written to, but its generation is still included in `pv_today` and `pv_power` so the array total covers the whole roof. Its load and grid readings are left out, as those registers can overlap with the battery inverter's on a shared-CT installation - set `pv_today`/`pv_power`/`load_today` manually with `solis_cloud_pv_load_ignore: true` if you need something different - **Inverter timezone must match Predbat's `timezone` setting**: charge/discharge slot times are written to the inverter as plain `HH:MM` values with no timezone attached. The inverter interprets these using its own configured timezone, not Predbat's. If your inverter's timezone is set to UTC (or anything other than Predbat's `timezone`, `Europe/London` by default), the resulting charge/discharge windows will be offset by the difference - for example, a full hour out whenever British Summer Time is in effect. Set the inverter's own timezone to match Predbat's `timezone` setting to avoid this. diff --git a/docs/discovery-catalogue.md b/docs/discovery-catalogue.md index 4b2063277..f116969d5 100644 --- a/docs/discovery-catalogue.md +++ b/docs/discovery-catalogue.md @@ -228,8 +228,8 @@ An inverter record holds enough to rebuild that inverter's definition - the per- has a reserve, a target SoC, charge/discharge enable switches, idle times or a timed pause follows from which settings `entities` binds to a writable (`rw`) entity; and protocol detail such as the time format or whether the charge rate is set in watts or amps is read from those entities' descriptors. -A setting Predbat replaces with a placeholder for this inverter type - SolisCloud's `reserve`, say - is -left out of `entities`. See the design in +A setting Predbat replaces with a placeholder for this inverter type - the Solis Modbus (`GS`) type's +`reserve`, say - is left out of `entities`. See the design in `docs/superpowers/specs/2026-09-24-discovery-inverter-record-vocabulary-design.md`. ### Writing a reporter diff --git a/docs/superpowers/specs/2026-09-24-discovery-inverter-record-vocabulary-design.md b/docs/superpowers/specs/2026-09-24-discovery-inverter-record-vocabulary-design.md index 23a053419..c7efd21ba 100644 --- a/docs/superpowers/specs/2026-09-24-discovery-inverter-record-vocabulary-design.md +++ b/docs/superpowers/specs/2026-09-24-discovery-inverter-record-vocabulary-design.md @@ -63,7 +63,7 @@ is the first to change behaviour. | D6 | `ratings` are keyed by Predbat setting name, in Predbat's units. A sensor binding for a rating lives in `entities` under the same key. | | D7 | A site-wide export limit is reported as each inverter's share of it. Predbat sums `export_limit` across inverters, so the shares add back up to the site figure. | | D8 | `functions` reports what the component believes the device is, whether probed or assumed. There is no marker for an assumed value. | -| D9 | SolisCloud does not write the reserve (it often won't change); it presents the battery minimum SoC instead. Its record reports `battery_min_soc` and no `reserve` (section 3). | +| D9 | SolisCloud does not write the reserve (it often won't change); it presents the battery minimum SoC instead. Its record reports `battery_min_soc` and no `reserve` (section 3). **Superseded:** `reserve` was then bound to the over-discharge SOC. It is now the Battery Reserve SOC (CID 157), a floor while the component keeps the Battery Reserve bit on, so SolisCloud has a reserve and its record reports both `reserve` (rw) and `battery_min_soc` (r). | | D10 | A record describes its own device, not the fleet. `automatic_config()` today binds some settings only when every inverter has them (GE Cloud: when any has them); the record states what this device has, and piece 3's coordinator applies whatever fleet rule it keeps. | | D11 | A record describes the device, not the user's opt-outs. Settings that stop a binding (`automatic_ignore_pv`, `givtcp_rest_power_ignore`) do not remove entities from the record; piece 3's coordinator applies them. | | D12 | A PV-only device's record carries its `pv_power` and `pv_today` as `access: r` entities - still with no `inverter_type` and no `capabilities` - so its generation is not lost when the coordinator configures from records (the GH#4922 regression). | @@ -158,7 +158,7 @@ published, because its component publishes them itself before `build_discovery() A setting that `inverter.py` replaces with a dummy entity for this inverter type is left out of `entities`, because Predbat never reads or writes the component's binding for it. Today that means any setting whose presence flag is False in the type's row (section 2), and `inverter_mode` when both `has_ge_inverter_mode` -and `has_ge_eco_toggle` are False. Solis's `reserve` is one such case (D9). +and `has_ge_eco_toggle` are False. The Solis Modbus (`GS`) type's `reserve` is one such case; SolisCloud's was until D9 was superseded. A rating with a sensor appears twice: the number in `ratings`, the binding in `entities` with `access: r`. The coordinator binds the entity where one exists and the rating's number otherwise. diff --git a/tools/debug-journal.md b/tools/debug-journal.md index 202e787af..86211f6c6 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -101,7 +101,7 @@ Grep for the named symbol rather than trusting a line number. | Area | What past debugging found | Targeted test | |------|---------------------------|---------------| | Fox (`fox.py`) | The cloud API returns errno 42015/44096 for settings a given device does not support (`FOX_SETTINGS_UNSUPPORTED_ERRNO`); those are marked unavailable and never polled or written again. Entity type matters — WorkMode is a select, ExportLimit a number. Two later capacity/schedule traps, both still live: `publish_data()` sums every `batteryList` entry unconditionally (`fox.py:1779-1781`), and an AIO ESS returns one physical pack as four `bmu` entries all carrying the inverter's own `batterySN`, so `soc_max` comes out at 4x the correct `batteryDesignCapacity` and `soc_kw` with it (GH#4919, read out of the reporter's own API response). `fox_automatic: true` re-`set_arg`s `soc_max` every cycle and `soc_max` is not in `CONFIG_API_OVERRIDE`, so apps.yaml cannot override it - the escape hatch is `fox_automatic: false`. With the Mode Scheduler deleted the scheduler `enable` flag reads 0 and `compute_schedule()` (`fox.py:1080`) derives the displayed charge window from the legacy `forceChargeTime` read, which Predbat cannot clear: `set_battery_charging_time()` (`fox.py:1009`), the only writer of that endpoint, has zero production callers - re-verified on main, where it is referenced only by `test_fox_api.py` (GH#4939). Note this is the *residual* half of that issue: the blocker the reporter actually hit was HA event routing filtering on the literal string `predbat` rather than the configured entity prefix, **fixed in PR #4962**. Unlike the self-referential HA verify trap below, a Fox `didn't complete got X` is a genuine failure - the read-back polls entities the component republishes from Fox Cloud. A separate trap on the **HA/modbus path** (inverter type `FoxESS`, not `FoxCloud`): the stock template binds `reserve:` to `number.foxess_min_soc_on_grid` (`templates/fox.yaml`), and that file's own mode table ties both Freeze charging and Hold charging to that entity. A reporter had `reserve:` bound to `number.foxess_inverter_min_soc` instead - a name that appears nowhere in `templates/` or `docs/` - so Predbat never read or wrote `min_soc_on_grid`; when the inverter stranded it at 100 (observed sitting there six days) Predbat could neither see it nor clear it, and `min_soc_on_grid` at 100% stops the battery discharging while grid-connected. `FoxESS` is `has_idle_time: False` (`config.py`), so the `idle time is ...` line is bookkeeping only and never programs a demand period - discharge outside a charge window depends entirely on the inverter's own settings, which is what makes a wrong reserve binding silent (GH#4961). FoxCloud freeze export (GH#5015, **fixed in PR #5038**, v9.0.2 - keep the mechanism for pre-v9.0.2 logs): `support_feedin_first: True` makes `prediction.py`'s freeze branch model freeze export as genuine Feed-in-First — load exports up to the limit, only the surplus beyond it charges the battery — but the cloud path used to never select the `Feedin` work mode: `apply_battery_schedule()` (`fox.py`) only emitted `SelfUse`/`ForceCharge`/`ForceDischarge` groups and `adjust_inverter_mode()` hardcodes `SelfUse` for Fox (`inverter.py`), so the inverter sat in SelfUse where surplus PV charges the battery *before* exporting and the plan's freeze-export revenue didn't match the hardware. #5038 makes `apply_battery_schedule()` select `Feedin` as the baseline work mode whenever freeze export is requested; note the cloud API's verify read is slow there — a write can log success, read back pre-write 3s later and read back the new mode after ~18s (recorded in a `fox.py` comment), so a quick verify read is not evidence of failure. `support_feedin_first` therefore means two different things depending on connection method: the modelling is correct on the FoxESS HA/modbus path, where `discharge_freeze_service` genuinely selects Feed-in First (#4207, fixed by #4425), while #4582 extended the flag to the cloud defs with no equivalent execution path. Log check: a freeze-export day shows hundreds of full-day `SelfUse` groups in `Fox: New schedule` lines and never a `ForceDischarge` or `Feedin` group — and `ForceDischarge` appearing in a `Fetch scheduler V1 returned` enum list is **not** evidence a discharge window was written. Workaround: turn `set_export_freeze` off so the planner stops selecting freeze-export slots. #4182 is the neighbouring open issue. | `fox_api`, `fox_oauth` | -| Solis (`solis.py`) | `SOLIS_CID_STORAGE_MODE = 636` is Modbus 43110, a bit mask. On firmware "4B and above" (what `is_tou_v2_mode()` detects: CID 6798 reads 43605) the timed charge/discharge enable moved to the per-slot registers — `SOLIS_CID_CHARGE_ENABLE_BASE`/`..._DISCHARGE_ENABLE_BASE`, Modbus 43707, six slots not three — and every mode value carrying TOU bit 1 (3/35/43/51/98) was dropped; 35 became 33, 98 became 96. Such an inverter answers a CID 636 write with code 0 and reads back without bit 1, so a log full of CID 636 verification warnings is a refused bit, not a failed write (GH#4707). `set_storage_mode_if_needed()` decides the bit from `is_tou_v2_mode()`: never asked for on V2, always asked for and retried on V1, where bit 1 still *is* the timed charge/discharge enable. Do not try to learn it from the read-back instead — GH#4710 did, and GH#4774 showed why that cannot work: on six inverters the same write of 179 verifies minutes before and minutes after the one that reads back 177, so a single post-write read is not evidence of a firmware property. Latching it was also silent rather than noisy: the verify read refreshes `cached_values`, so after a stripped write the cache held the stripped value, the suppressed computed value matched it, and `set_storage_mode_if_needed()` stopped writing CID 636 at all for the whole 8-hour verdict — across an overnight charge window. GH#4239's "only retained while a window is configured" is not the whole story — it was refused with slot 1 enabled and its window in force. `read_and_write_cid()` re-reads once after `verify_settle_seconds` before calling a mismatch a failure, because the immediate verify read is taken about half a second after the write. On the SolisCloud select path specifically, **PR #5247 (merged 2026-09-26) makes a discovered inverter's slot-time writes run their handler immediately** so Predbat's read-back sees them in the same cycle — pre-#5247 the select callback only queued the event (GH#4875) and the queue drained at the top of `run()` up to a minute later, so the write verified against the stale value and logged a spurious `didn't complete` once per moved slot time (pass 1, V1 and the slot SoC/current writes keep their every-minute retry). Do not "stop the failed verify poisoning the cache" — `write_cid()` caches the value it *requested* and the post-write `read_cid()` deliberately overwrites it with what the inverter actually reports, so the cache mirrors the inverter rather than Predbat's intent; skipping that update would leave the cache agreeing with the write that just failed, and change detection would then never retry it. The two control paths choose the storage mode differently and it matters: V1 picks from `in_charge_slot`/`in_discharge_slot`, which are clock tests, so CID 636 changes value and gets written the moment a window opens; V2 picks from `slot1_active`, which is only "slot 1 has a window configured", so the mode is written when the slot is *programmed* and nothing at all happens at the window boundary. On the GH#4774 night the last CID 636 write was 22 minutes before the window opened, and the comparison night that worked had one near the window - so `claim_window_mode_assertion()` now asserts the mode once per window on both paths. Slot registers are polled hourly, plus every 5 minutes while `is_inside_active_window()` is true, so drift during the window that matters is caught in the same cycle it happens. GH#4774 also left a useful negative result: that inverter grid-charged from 20% to 91% overnight with bit 1 clear throughout, confirming the per-slot enables alone drive timed charging on V2 — so a "charge window never started" report on V2 firmware is not a CID 636 problem and needs looking at elsewhere. Separately, an inverter reporting `batteryType 'No Battery'` stays in `self.inverter_sn` for polling; `is_battery_inverter()` keeps control writes off it, which was most of the warning volume in GH#4707. That explanation doesn't cover every CID 636 report, though: a later GH#4707 comment showed zero `setting storage mode to` idle-mode log lines, and both V1 mode-decision idle paths always log that line — so a report with neither line cannot be coming from `write_time_windows_if_changed()` at all. The only remaining write path is the HA select handler, `set_storage_mode_value()` at `solis.py:2574`. Check which write path actually ran before assuming this entry's explanation applies. Separately, on inverters using `H M` time format (`GS_fb00`, most cloud subtypes) `adjust_force_export()` used to re-commit a stable export window every cycle — `is_hm_format` alone made `changed_start_end` true, so `press_and_poll_button()` fired on every cycle of an unchanged export window (GH#4709, confirmed live on main at the time) — and PR #4713 fixed the twin idle-cycle press where the times being managed came back as `None` and never compared equal to what the inverter still reported (GH#4712, #2328). **Both merged together in PR #4711 (`bc853a0e`, 2026-09-12, post-v9.0.2)**: `adjust_force_export()` re-commits only on a real schedule change through the commit-once ledger (`last_committed`/`commit_pending`, `commit_needed()`/`record_commit()` in `inverter.py` — the attribute this entry used to name, `last_export_schedule_committed`, is gone), and a commit is only recorded when the writes *and* the button press both succeeded; **the guard only became real when PR #5126 (merged 2026-09-26, unreleased at the time of writing) stopped rebuilding Inverter objects every cycle** — pre-#5126 the rebuild reset the latch to empty each cycle, so the guard was decorative and the every-cycle export commit (the #2328 ~288 TOU-register-write-batches/day signature) stayed live; keep that reading for pre-#5126 logs, where counting `Successfully pressed button` lines settles the actual volume. (The every-cycle `H M` time-entity rewrite remains, deliberately — #1529 write reliability); `press_and_poll_button()` takes a `side` argument so a split-button config no longer presses the unrelated side's button (which is what cleared `timed_charge_current` alongside the intended one); and `adjust_inverter_mode()` now sleeps 30s after a real window change as a GivTCP settle workaround, so expect a 30s pause per real window change on those types. Keep the mechanism for pre-merge logs, where the signature is a button press logged every 5 minutes inside a window. Away from the CID work, `automatic_config()` has two arg-binding defects. It binds `battery_rate_max` - which feeds *both* the charge and the discharge cap - to the per-device `max_charge_power` entity (`solis.py:1516`), while the `max_discharge_power` entity it also creates is bound to nothing; `inverter.py:429`'s `min(inverter_limit_discharge, battery_rate_max_raw)` then caps discharge at the charge limit on an asymmetric inverter, and `inverter_limit_discharge` cannot lift it because the raw value is the binding constraint (GH#4940, confirmed against the reporter's debug yaml, still live). Auto-discovery also always beats a manual apps.yaml value here (`component_base.py:96-110`). Separately, on Solis Cloud/TOU-V2 the per-slot current write is clamped by `write_time_windows()` (`solis.py`, `new_current = min(slot_current, max_discharge_current_amps)`), where the cap is the *minimum* `sysCommand.max` metadata across **all six** slot CIDs from `cached_infos` — deliberately, because a slot 1 value above the others is not accepted. On GH#5068 that bound slot 1 at 60A against a ~93A plan, and the trap is that the number entity is republished from the local `charge_discharge_time_windows` cache, so the write verify reads back the *clamped* value converted at the **live** battery voltage (60A × ~51.6V ≈ 3094W, not 48V) and logs `didn't complete got 3094.0` every cycle — while a "Wrote N successfully now N" line can also be self-referential (cached 60 == clamped 60 skips the CID write entirely). Grep `CID 5967 ... is set to` to see what actually went to the inverter. The planner models `battery_rate_max_discharge` from apps.yaml/entity max and knows nothing of the clamp, so the plan is systematically optimistic while it binds; workaround is `inverter_limit_discharge` at 60A × pack voltage. #4220 is the same 60A clamp from the user's angle. Separately it built every arg list - PV, load and grid included - from the battery-filtered `devices` list (`solis.py:1456`, narrowed by `cd6e7c79`), dropping a PV-only inverter's generation from `pv_today`/`pv_power`; the PV half was fixed in PR #4923, load/grid deliberately left battery-only because those registers can overlap on a shared-CT install. One more shape worth remembering: the entity-event queue used to drain at the top of `run()`, before the `first` block created the `ClientSession` and discovered inverters, so an event queued during startup executed against `session=None` with an empty `inverter_sn` - and was popped before execution, making it a silently lost write (fixed in `9fe1f7e0`). Three September fixes and one still-live trap. **PR #5089 (merged 2026-09-14)** closed the SolisCloud API-allowance family (GH#5087/#5091): `B0115` ("Datalogger offline or disconnected") and `R0000` ("Daily API request allowance exhausted") are now classified in `SOLIS_API_CODES` and excluded from retry (`SOLIS_API_CODES_NO_RETRY`) - retrying cannot change the answer and every attempt still spends one of the 200 daily requests - with a per-inverter datalogger-offline cooldown (`datalogger_offline()`) so one offline datalogger backs off alone rather than the fleet; and `automatic_config()` is no longer first-cycle-only: `run()` retries it every cycle until it returns True (`automatic_config_done`), so an API outage at startup no longer leaves `load_today` unset until a Predbat restart (the ValueError-at-fetch symptom this produced has its own row in the symptom table). Keep the mechanism for pre-#5089 logs: B0115 was treated as rate limiting (10s sleep + retry, the `ad26f95a` "Quick rate limit hack") and any R-series code fell through to `Unknown code` with the full retry ladder - an offline datalogger drained the 200/day allowance and R0000 arrived hours later. `automatic_config()` makes no API calls of its own - it reads only `self.inverter_details` - and returns False when details are still missing, which is the retryable case. **PR #5095 (merged 2026-09-15)** fixed the amp↔watt conversion voltage (GH#5090): `get_nominal_voltage()` now prefers `solis_nominal_voltage` from apps.yaml (the only stated physical property), then a live reading above `SOLIS_HV_BATTERY_VOLTAGE` (HV packs keep the live voltage as they have since #4493), then BMS charge voltage classification (16S at/above 55V charge voltage, 15S below - settings rather than measurements, so the result holds across polls), then the 48V fallback; the live voltage had swung 10-14% over a month on every sampled system, dragging `battery_rate_max`, the write tolerance computed from it and the read-back of every rate setpoint along. No workaround existed pre-#5095 - `solis_nominal_voltage` fed only the capacity path (`get_capacity_voltage()`), so telling a user to set it fixed capacity but not the rate paths. Diagnostic tell for pre-#5095 logs: failed-write readbacks at a constant ratio of the target (e.g. exactly 66/70) are consistent with write-time vs read-time voltage difference alone. **Still live (GH#5093):** `SolisAPI.run()` folds per-inverter failures into one `poll_success` bool - discovery failure, any inverter's detail fetch, TOU window reads, the in-window re-read - and `ComponentBase.start()` gates startup on that return: False → `api_started` never set, and the startup burst (discovery, details, `startup_reset_registers()`, the infrequent poll) re-runs on a doubling backoff from 60s to a 128-minute cap, so a multi-inverter fleet is held hostage to the least reachable device and every retry re-spends quota. Per-inverter success booleans already exist at every `poll_success` site, so the fix is contained to `run()`; the interim workaround is listing only working SNs in `solis_inverter_sn`. PR #5185 (merged 2026-09-23) also backs off `startup_reset_registers()` per-inverter with a persistent B0600 pause, and a refused startup register read no longer aborts startup — the try/except now sitting at the `run()` call site covers both triggers, the #5177 B0600 refusal and the SolisCloud timeout (GH#5202, closed as its dup: the traceback matched main line-for-line, the same `SolisAPIError` escaping the same call). Three Modbus-vs-component bindings and a mode-domain split (GH#5127/#5128/#5129, triaged 2026-09-17). On `charge_time_format: "H M"` types (`GS_fb00`, most cloud subtypes) `adjust_charge_window()` rewrites the charge start/end time entities on every 5-minute cycle of a programmed window (the format check in its write condition, inverter.py) - deliberately, the same #1529 write-reliability workaround the export side has - so "Predbat hammers my Solis with writes" is this first, not SoC-hold micromanagement: `adjust_battery_target()` is change-gated (logs `already at target` and writes nothing when equal) and the hold path's `disable_charge_window()` writes nothing unless the enable switch was on. The Modbus-path types (`GS`, `GS_fb00`, `SX4`, Sofar) have **no Predbat component at all** - every control arg is bound only by the hand-edited template; `create_missing_arg()` only ever *dummies* absent capabilities (a dummy `charge_limit` 100 when `has_target_soc` is False), so no INVERTER_DEF default binds a real entity, and a GS_fb00 user who misses the template's `charge_limit` uncomment gets a silent 100.0 target plus `No entity_id for charge_limit to write N` on every target change while the inverter keeps charging to its own default (GH#5129 - the signature is the repeated `No entity_id` line, not a startup error). On the Solis Cloud component the opposite applies: `automatic_config()` binds `charge_limit` with `set_arg_auto(..., overwrite=True)` (solis.py), so an apps.yaml `charge_limit:` pointing at the inverter's own timed-charge-SoC entity is replaced and logged `auto-discovery wins for this setting` - the first thing to grep a reporter's log for when #2328's guidance "didn't help" (GH#5127). Separately, Solis has **two mode-switch control paths whose mode domains do not overlap** (GH#5128): the HA `solax_modbus` integration (`inverter_type: "GS"`) writes a mode *name* from `SOLAX_SOLIS_MODES`/`SOLAX_SOLIS_MODES_NEW` through `alt_charge_discharge_enable()` and only ever computes 33/35 - the 64/96/98 "Feed-in priority" entries are dead in that table - while the SolisCloud API path above does use feed-in priority in its idle paths; so a "why doesn't Predbat use feed-in priority" report is GH#5128's gap on modbus and already-covered on the API component. Trap for any fix there: 33/35 mean different things in the two tables (`SOLAX_SOLIS_MODES` vs `_NEW`, the `solax_modbus_new` switch), so writes must go through the table-derived name, never a bare number; the reusable pattern is `support_feedin_first` (PR #5038, Fox). Check `fox.py`/`gecloud.py` for the same fold-everything-into-one-bool shape - GE Cloud still runs its own auto-config under `if first:` only, with no retry, so its self-heal after an outage is the pre-#5089-era slow one. **GH#5187, fixed in PR #5189 (merged 2026-09-24) - keep the mechanism for pre-#5189 logs.** Slot currents are now capped at the inverter's rated power (from the `inverter_size` sensor, logged `Capping slot currents on ... at the most the inverter can deliver at its rated power`); pre-#5189 the bound was only CID 7226 × count and the per-slot `sysCommand.max` metadata, so a 3.6 kW AC inverter reading 100 A at CID 7226 refused every 100 A slot-current write and left the slot enabled at 0 A, while the planner side (auto-config binds `battery_rate_max` to `max_charge_power`, GH#4940, and sets no `inverter_limit_discharge`) never saw the AC rating — `inverter_limit_discharge: 3600` remains the plan-side workaround where the modelling matters. Implausible recovery SoC (CID 7229) no longer defeats the #4706 clamp: 0/1/≤over-discharge/>100 now falls back to `over_discharge_soc + 1` with a log line; pre-#5189 a 65521 read passed the guard and made Predbat attempt to write the recovery register itself down every cycle. **Still live:** the write-skip on a never-read slot current CID — `cached_values.get(cid, cap)` defaults to the cap, so a register the poll has never returned reads as "no change" and **no write is attempted at all** (`solis.py`, both charge and discharge paths); if a fixture shows no write of the slot CID, check whether the fixture seeded it in `cached_values` before assuming a bug. Test notes from #5177: `test_solis.py`'s first-cycle tests mock `startup_reset_registers` via instance attribute (`del api.startup_reset_registers` restores the real method); a canned `api.session` set before `run(0, True)` reaches the real request path; and do not patch `asyncio.sleep` to speed up a retried error — `_with_retry` budgets with `time.monotonic()`, so a no-op sleep just burns the real ~30 s budget across ~8 attempts. The hold lever on Solis modbus is window-scoped (GH#5152, code-verified): the only discharge-suppression write is `set_current_from_power()`'s `timed_{charge,discharge}_current` (re-asserted on every rate adjustment, #4415); the standing `battery_discharge_current` registers are never driven anywhere in the repo, and GS makes the timed write the *only* lever (`has_reserve_soc: False`, `has_timed_pause: False`) — so a hold's 0A binds only inside the timed slot, and a "hold/freeze doesn't stop discharge" report on Solis-modbus points at GH#5152 before any plan logic: the execute-side hold gates fire correctly, the lever they pull is what is window-scoped. (The "only binds during the slot" firmware semantics is read off the register names in the solax_modbus integration, not yet live-log confirmed.) Same firmware on the **solax_modbus** path: `inverter_type: GS` with the integration's plain "Solis" plugin on V2/FB00 firmware shows as `Setting Solis Energy Control Switch to 35 Self-Use from 33 Self-Use - No Timed Charge/Discharge` every cycle, the write "succeeds", and the next read is 33 again — a `Control interference: N change(s) in the last 24h, sustained on [...energy_storage_control_switch]` count in the hundreds. Charging still worked (SoC 28→100% overnight) but exports never ran: the discharge slot's times were written but its per-slot enable (43707) stayed 0, which the `solis.py` V2 time-window decode showed as `discharge_enable: 0`. Not a code bug — the user needs the solax_modbus "Solis FB00" plugin (per-slot enable switches, no mode 35) and `inverter_type: GS_fb00` via `templates/ginlong_solis_fb00.yaml`; confirmed fixed on a live system on 26 Sep 2026. Adding Solis Cloud credentials *without* `solis_automatic: True` on such a system leaves inverter 0 bound as GS while `solis.py` still runs its slot/mode logic with no plan — it logs `no active slot`, clears discharge slot 1 and sets 'Self-Use - No Timed Charge/Discharge', fighting the modbus path. **GH#5190 (verified on main): PEP 515 underscores defeat every numeric-parse guard.** Python ≥3.6 `int()`/`float()` accept `_` digit separators, so a junk register string such as `"1234567890_00"` (datalogger off the remote-control platform; polls fail with `B0063 "Lack of iot platform three elements"`) parses as `123456789000` with no exception — `parse_cid_int()`, `cid_value_matches()` and the raw `int(...)`/`float(...)` coercion all accept it, so every `try/except ValueError` fallback silently passes. Consequences seen in the reporter's config: `reserve`/`battery_min_soc` auto-bound to the junk percent (inverter.py raises `set_reserve_min` to it), CID 636 never converging (every-minute write + failed-verify repeat), and no `B0063` handling anywhere in `solis.py`. The same gap is in the core `get_arg()` float coercion (`userinterface.py`), so it generalises past Solis: start there on any "value reads as an absurdly huge number / the fallback never fires" report. Fix shape per the issue: one strict-parse helper (plain decimal only), range checks, publish `None` for unparseable reads, never write CID 636 from an unreadable current mode. | `solis` | +| Solis (`solis.py`) | `SOLIS_CID_STORAGE_MODE = 636` is Modbus 43110, a bit mask. On firmware "4B and above" (what `is_tou_v2_mode()` detects: CID 6798 reads 43605) the timed charge/discharge enable moved to the per-slot registers — `SOLIS_CID_CHARGE_ENABLE_BASE`/`..._DISCHARGE_ENABLE_BASE`, Modbus 43707, six slots not three — and every mode value carrying TOU bit 1 (3/35/43/51/98) was dropped; 35 became 33, 98 became 96. Such an inverter answers a CID 636 write with code 0 and reads back without bit 1, so a log full of CID 636 verification warnings is a refused bit, not a failed write (GH#4707). `set_storage_mode_if_needed()` decides the bit from `is_tou_v2_mode()`: never asked for on V2, always asked for and retried on V1, where bit 1 still *is* the timed charge/discharge enable. Do not try to learn it from the read-back instead — GH#4710 did, and GH#4774 showed why that cannot work: on six inverters the same write of 179 verifies minutes before and minutes after the one that reads back 177, so a single post-write read is not evidence of a firmware property. Latching it was also silent rather than noisy: the verify read refreshes `cached_values`, so after a stripped write the cache held the stripped value, the suppressed computed value matched it, and `set_storage_mode_if_needed()` stopped writing CID 636 at all for the whole 8-hour verdict — across an overnight charge window. GH#4239's "only retained while a window is configured" is not the whole story — it was refused with slot 1 enabled and its window in force. `read_and_write_cid()` re-reads once after `verify_settle_seconds` before calling a mismatch a failure, because the immediate verify read is taken about half a second after the write. On the SolisCloud select path specifically, **PR #5247 (merged 2026-09-26) makes a discovered inverter's slot-time writes run their handler immediately** so Predbat's read-back sees them in the same cycle — pre-#5247 the select callback only queued the event (GH#4875) and the queue drained at the top of `run()` up to a minute later, so the write verified against the stale value and logged a spurious `didn't complete` once per moved slot time (pass 1, V1 and the slot SoC/current writes keep their every-minute retry). Do not "stop the failed verify poisoning the cache" — `write_cid()` caches the value it *requested* and the post-write `read_cid()` deliberately overwrites it with what the inverter actually reports, so the cache mirrors the inverter rather than Predbat's intent; skipping that update would leave the cache agreeing with the write that just failed, and change detection would then never retry it. The two control paths choose the storage mode differently and it matters: V1 picks from `in_charge_slot`/`in_discharge_slot`, which are clock tests, so CID 636 changes value and gets written the moment a window opens; V2 picks from `slot1_active`, which is only "slot 1 has a window configured", so the mode is written when the slot is *programmed* and nothing at all happens at the window boundary. On the GH#4774 night the last CID 636 write was 22 minutes before the window opened, and the comparison night that worked had one near the window - so `claim_window_mode_assertion()` now asserts the mode once per window on both paths. Slot registers are polled hourly, plus every 5 minutes while `is_inside_active_window()` is true, so drift during the window that matters is caught in the same cycle it happens. GH#4774 also left a useful negative result: that inverter grid-charged from 20% to 91% overnight with bit 1 clear throughout, confirming the per-slot enables alone drive timed charging on V2 — so a "charge window never started" report on V2 firmware is not a CID 636 problem and needs looking at elsewhere. Separately, an inverter reporting `batteryType 'No Battery'` stays in `self.inverter_sn` for polling; `is_battery_inverter()` keeps control writes off it, which was most of the warning volume in GH#4707. That explanation doesn't cover every CID 636 report, though: a later GH#4707 comment showed zero `setting storage mode to` idle-mode log lines, and both V1 mode-decision idle paths always log that line — so a report with neither line cannot be coming from `write_time_windows_if_changed()` at all. The only remaining write path is the HA select handler, `set_storage_mode_value()` at `solis.py:2574`. Check which write path actually ran before assuming this entry's explanation applies. Separately, on inverters using `H M` time format (`GS_fb00`, most cloud subtypes) `adjust_force_export()` used to re-commit a stable export window every cycle — `is_hm_format` alone made `changed_start_end` true, so `press_and_poll_button()` fired on every cycle of an unchanged export window (GH#4709, confirmed live on main at the time) — and PR #4713 fixed the twin idle-cycle press where the times being managed came back as `None` and never compared equal to what the inverter still reported (GH#4712, #2328). **Both merged together in PR #4711 (`bc853a0e`, 2026-09-12, post-v9.0.2)**: `adjust_force_export()` re-commits only on a real schedule change through the commit-once ledger (`last_committed`/`commit_pending`, `commit_needed()`/`record_commit()` in `inverter.py` — the attribute this entry used to name, `last_export_schedule_committed`, is gone), and a commit is only recorded when the writes *and* the button press both succeeded; **the guard only became real when PR #5126 (merged 2026-09-26, unreleased at the time of writing) stopped rebuilding Inverter objects every cycle** — pre-#5126 the rebuild reset the latch to empty each cycle, so the guard was decorative and the every-cycle export commit (the #2328 ~288 TOU-register-write-batches/day signature) stayed live; keep that reading for pre-#5126 logs, where counting `Successfully pressed button` lines settles the actual volume. (The every-cycle `H M` time-entity rewrite remains, deliberately — #1529 write reliability); `press_and_poll_button()` takes a `side` argument so a split-button config no longer presses the unrelated side's button (which is what cleared `timed_charge_current` alongside the intended one); and `adjust_inverter_mode()` now sleeps 30s after a real window change as a GivTCP settle workaround, so expect a 30s pause per real window change on those types. Keep the mechanism for pre-merge logs, where the signature is a button press logged every 5 minutes inside a window. Away from the CID work, `automatic_config()` has two arg-binding defects. It binds `battery_rate_max` - which feeds *both* the charge and the discharge cap - to the per-device `max_charge_power` entity (`solis.py:1516`), while the `max_discharge_power` entity it also creates is bound to nothing; `inverter.py:429`'s `min(inverter_limit_discharge, battery_rate_max_raw)` then caps discharge at the charge limit on an asymmetric inverter, and `inverter_limit_discharge` cannot lift it because the raw value is the binding constraint (GH#4940, confirmed against the reporter's debug yaml, still live). Auto-discovery also always beats a manual apps.yaml value here (`component_base.py:96-110`). Separately, on Solis Cloud/TOU-V2 the per-slot current write is clamped by `write_time_windows()` (`solis.py`, `new_current = min(slot_current, max_discharge_current_amps)`), where the cap is the *minimum* `sysCommand.max` metadata across **all six** slot CIDs from `cached_infos` — deliberately, because a slot 1 value above the others is not accepted. On GH#5068 that bound slot 1 at 60A against a ~93A plan, and the trap is that the number entity is republished from the local `charge_discharge_time_windows` cache, so the write verify reads back the *clamped* value converted at the **live** battery voltage (60A × ~51.6V ≈ 3094W, not 48V) and logs `didn't complete got 3094.0` every cycle — while a "Wrote N successfully now N" line can also be self-referential (cached 60 == clamped 60 skips the CID write entirely). Grep `CID 5967 ... is set to` to see what actually went to the inverter. The planner models `battery_rate_max_discharge` from apps.yaml/entity max and knows nothing of the clamp, so the plan is systematically optimistic while it binds; workaround is `inverter_limit_discharge` at 60A × pack voltage. #4220 is the same 60A clamp from the user's angle. Separately it built every arg list - PV, load and grid included - from the battery-filtered `devices` list (`solis.py:1456`, narrowed by `cd6e7c79`), dropping a PV-only inverter's generation from `pv_today`/`pv_power`; the PV half was fixed in PR #4923, load/grid deliberately left battery-only because those registers can overlap on a shared-CT install. One more shape worth remembering: the entity-event queue used to drain at the top of `run()`, before the `first` block created the `ClientSession` and discovered inverters, so an event queued during startup executed against `session=None` with an empty `inverter_sn` - and was popped before execution, making it a silently lost write (fixed in `9fe1f7e0`). Three September fixes and one still-live trap. **PR #5089 (merged 2026-09-14)** closed the SolisCloud API-allowance family (GH#5087/#5091): `B0115` ("Datalogger offline or disconnected") and `R0000` ("Daily API request allowance exhausted") are now classified in `SOLIS_API_CODES` and excluded from retry (`SOLIS_API_CODES_NO_RETRY`) - retrying cannot change the answer and every attempt still spends one of the 200 daily requests - with a per-inverter datalogger-offline cooldown (`datalogger_offline()`) so one offline datalogger backs off alone rather than the fleet; and `automatic_config()` is no longer first-cycle-only: `run()` retries it every cycle until it returns True (`automatic_config_done`), so an API outage at startup no longer leaves `load_today` unset until a Predbat restart (the ValueError-at-fetch symptom this produced has its own row in the symptom table). Keep the mechanism for pre-#5089 logs: B0115 was treated as rate limiting (10s sleep + retry, the `ad26f95a` "Quick rate limit hack") and any R-series code fell through to `Unknown code` with the full retry ladder - an offline datalogger drained the 200/day allowance and R0000 arrived hours later. `automatic_config()` makes no API calls of its own - it reads only `self.inverter_details` - and returns False when details are still missing, which is the retryable case. **PR #5095 (merged 2026-09-15)** fixed the amp↔watt conversion voltage (GH#5090): `get_nominal_voltage()` now prefers `solis_nominal_voltage` from apps.yaml (the only stated physical property), then a live reading above `SOLIS_HV_BATTERY_VOLTAGE` (HV packs keep the live voltage as they have since #4493), then BMS charge voltage classification (16S at/above 55V charge voltage, 15S below - settings rather than measurements, so the result holds across polls), then the 48V fallback; the live voltage had swung 10-14% over a month on every sampled system, dragging `battery_rate_max`, the write tolerance computed from it and the read-back of every rate setpoint along. No workaround existed pre-#5095 - `solis_nominal_voltage` fed only the capacity path (`get_capacity_voltage()`), so telling a user to set it fixed capacity but not the rate paths. Diagnostic tell for pre-#5095 logs: failed-write readbacks at a constant ratio of the target (e.g. exactly 66/70) are consistent with write-time vs read-time voltage difference alone. **Still live (GH#5093):** `SolisAPI.run()` folds per-inverter failures into one `poll_success` bool - discovery failure, any inverter's detail fetch, TOU window reads, the in-window re-read - and `ComponentBase.start()` gates startup on that return: False → `api_started` never set, and the startup burst (discovery, details, `startup_reset_registers()`, the infrequent poll) re-runs on a doubling backoff from 60s to a 128-minute cap, so a multi-inverter fleet is held hostage to the least reachable device and every retry re-spends quota. Per-inverter success booleans already exist at every `poll_success` site, so the fix is contained to `run()`; the interim workaround is listing only working SNs in `solis_inverter_sn`. PR #5185 (merged 2026-09-23) also backs off `startup_reset_registers()` per-inverter with a persistent B0600 pause, and a refused startup register read no longer aborts startup — the try/except now sitting at the `run()` call site covers both triggers, the #5177 B0600 refusal and the SolisCloud timeout (GH#5202, closed as its dup: the traceback matched main line-for-line, the same `SolisAPIError` escaping the same call). Three Modbus-vs-component bindings and a mode-domain split (GH#5127/#5128/#5129, triaged 2026-09-17). On `charge_time_format: "H M"` types (`GS_fb00`, most cloud subtypes) `adjust_charge_window()` rewrites the charge start/end time entities on every 5-minute cycle of a programmed window (the format check in its write condition, inverter.py) - deliberately, the same #1529 write-reliability workaround the export side has - so "Predbat hammers my Solis with writes" is this first, not SoC-hold micromanagement: `adjust_battery_target()` is change-gated (logs `already at target` and writes nothing when equal) and the hold path's `disable_charge_window()` writes nothing unless the enable switch was on. The Modbus-path types (`GS`, `GS_fb00`, `SX4`, Sofar) have **no Predbat component at all** - every control arg is bound only by the hand-edited template; `create_missing_arg()` only ever *dummies* absent capabilities (a dummy `charge_limit` 100 when `has_target_soc` is False), so no INVERTER_DEF default binds a real entity, and a GS_fb00 user who misses the template's `charge_limit` uncomment gets a silent 100.0 target plus `No entity_id for charge_limit to write N` on every target change while the inverter keeps charging to its own default (GH#5129 - the signature is the repeated `No entity_id` line, not a startup error). On the Solis Cloud component the opposite applies: `automatic_config()` binds `charge_limit` with `set_arg_auto(..., overwrite=True)` (solis.py), so an apps.yaml `charge_limit:` pointing at the inverter's own timed-charge-SoC entity is replaced and logged `auto-discovery wins for this setting` - the first thing to grep a reporter's log for when #2328's guidance "didn't help" (GH#5127). Separately, Solis has **two mode-switch control paths whose mode domains do not overlap** (GH#5128): the HA `solax_modbus` integration (`inverter_type: "GS"`) writes a mode *name* from `SOLAX_SOLIS_MODES`/`SOLAX_SOLIS_MODES_NEW` through `alt_charge_discharge_enable()` and only ever computes 33/35 - the 64/96/98 "Feed-in priority" entries are dead in that table - while the SolisCloud API path above does use feed-in priority in its idle paths; so a "why doesn't Predbat use feed-in priority" report is GH#5128's gap on modbus and already-covered on the API component. Trap for any fix there: 33/35 mean different things in the two tables (`SOLAX_SOLIS_MODES` vs `_NEW`, the `solax_modbus_new` switch), so writes must go through the table-derived name, never a bare number; the reusable pattern is `support_feedin_first` (PR #5038, Fox). Check `fox.py`/`gecloud.py` for the same fold-everything-into-one-bool shape - GE Cloud still runs its own auto-config under `if first:` only, with no retry, so its self-heal after an outage is the pre-#5089-era slow one. **GH#5187, fixed in PR #5189 (merged 2026-09-24) - keep the mechanism for pre-#5189 logs.** Slot currents are now capped at the inverter's rated power (from the `inverter_size` sensor, logged `Capping slot currents on ... at the most the inverter can deliver at its rated power`); pre-#5189 the bound was only CID 7226 × count and the per-slot `sysCommand.max` metadata, so a 3.6 kW AC inverter reading 100 A at CID 7226 refused every 100 A slot-current write and left the slot enabled at 0 A, while the planner side (auto-config binds `battery_rate_max` to `max_charge_power`, GH#4940, and sets no `inverter_limit_discharge`) never saw the AC rating — `inverter_limit_discharge: 3600` remains the plan-side workaround where the modelling matters. Implausible recovery SoC (CID 7229) no longer defeats the #4706 clamp: 0/1/≤over-discharge/>100 now falls back to `over_discharge_soc + 1` with a log line; pre-#5189 a 65521 read passed the guard and made Predbat attempt to write the recovery register itself down every cycle. **Still live:** the write-skip on a never-read slot current CID — `cached_values.get(cid, cap)` defaults to the cap, so a register the poll has never returned reads as "no change" and **no write is attempted at all** (`solis.py`, both charge and discharge paths); if a fixture shows no write of the slot CID, check whether the fixture seeded it in `cached_values` before assuming a bug. Test notes from #5177: `test_solis.py`'s first-cycle tests mock `startup_reset_registers` via instance attribute (`del api.startup_reset_registers` restores the real method); a canned `api.session` set before `run(0, True)` reaches the real request path; and do not patch `asyncio.sleep` to speed up a retried error — `_with_retry` budgets with `time.monotonic()`, so a no-op sleep just burns the real ~30 s budget across ~8 attempts. The hold lever on Solis modbus is window-scoped (GH#5152, code-verified): the only discharge-suppression write is `set_current_from_power()`'s `timed_{charge,discharge}_current` (re-asserted on every rate adjustment, #4415); the standing `battery_discharge_current` registers are never driven anywhere in the repo, and GS makes the timed write the *only* lever (`has_reserve_soc: False`, `has_timed_pause: False`) — so a hold's 0A binds only inside the timed slot, and a "hold/freeze doesn't stop discharge" report on Solis-modbus points at GH#5152 before any plan logic: the execute-side hold gates fire correctly, the lever they pull is what is window-scoped. (The "only binds during the slot" firmware semantics is read off the register names in the solax_modbus integration, not yet live-log confirmed.) Same firmware on the **solax_modbus** path: `inverter_type: GS` with the integration's plain "Solis" plugin on V2/FB00 firmware shows as `Setting Solis Energy Control Switch to 35 Self-Use from 33 Self-Use - No Timed Charge/Discharge` every cycle, the write "succeeds", and the next read is 33 again — a `Control interference: N change(s) in the last 24h, sustained on [...energy_storage_control_switch]` count in the hundreds. Charging still worked (SoC 28→100% overnight) but exports never ran: the discharge slot's times were written but its per-slot enable (43707) stayed 0, which the `solis.py` V2 time-window decode showed as `discharge_enable: 0`. Not a code bug — the user needs the solax_modbus "Solis FB00" plugin (per-slot enable switches, no mode 35) and `inverter_type: GS_fb00` via `templates/ginlong_solis_fb00.yaml`; confirmed fixed on a live system on 26 Sep 2026. Adding Solis Cloud credentials *without* `solis_automatic: True` on such a system leaves inverter 0 bound as GS while `solis.py` still runs its slot/mode logic with no plan — it logs `no active slot`, clears discharge slot 1 and sets 'Self-Use - No Timed Charge/Discharge', fighting the modbus path. **GH#5190 (verified on main): PEP 515 underscores defeat every numeric-parse guard.** Python ≥3.6 `int()`/`float()` accept `_` digit separators, so a junk register string such as `"1234567890_00"` (datalogger off the remote-control platform; polls fail with `B0063 "Lack of iot platform three elements"`) parses as `123456789000` with no exception — `parse_cid_int()`, `cid_value_matches()` and the raw `int(...)`/`float(...)` coercion all accept it, so every `try/except ValueError` fallback silently passes. Consequences seen in the reporter's config: `reserve`/`battery_min_soc` auto-bound to the junk percent (inverter.py raises `set_reserve_min` to it), CID 636 never converging (every-minute write + failed-verify repeat), and no `B0063` handling anywhere in `solis.py`. The same gap is in the core `get_arg()` float coercion (`userinterface.py`), so it generalises past Solis: start there on any "value reads as an absurdly huge number / the fallback never fires" report. Fix shape per the issue: one strict-parse helper (plain decimal only), range checks, publish `None` for unparseable reads, never write CID 636 from an unreadable current mode. Holds and freeze (PR after #5258): `reserve` is now bound to the **Battery Reserve SOC** (CID 157, `reserve_soc`) and the row declares `has_reserve_soc` True - `battery_min_soc` stays the over-discharge SOC (CID 158). Solis only treats the Battery Reserve SOC as a discharge floor while CID 636 bit 4 (`SOLIS_BIT_BACKUP_MODE`) is on, so `set_storage_mode_if_needed()` sets that bit while `set_reserve_enable` is on (and leaves it alone otherwise - turning it on unmanaged would enforce whatever reserve SOC the inverter already held); a hold outside a slot needs no extra grid-charging logic because outside slots the component already writes a No-Grid-Charging mode. Inside an open charge slot with the battery at or above the slot's SOC (`is_holding_in_charge_slot()` - a freeze charge, or a charge that reached its target) it writes `Self-Use - No Grid Charging` (`ENUM_SELF_USE_HOLD`: 3 on V1 where bit 1 is the slot enable, 1 on V2) ahead of the 0A -> Feed-in rule, which on V1 used to select Feed-in priority - exporting PV - during a freeze charge. | `solis` | | Solis Modbus (`GS`, `GS_fb00` in `inverter.py`) | The Energy Storage Control Switch (Modbus 43110, `energy_control_switch`) is driven by `alt_charge_discharge_enable()`, gated on the `has_solis_energy_control` capability. `GS` reaches it through `mimic_target_soc()` and uses the Timed Charge/Discharge bit as the charge enable (35/33). `GS_fb00` has a target SoC, so it never reached `mimic_target_soc()` - until the fix it never wrote the switch at all, and a switch left on `Self-Use - No Grid Charging` (1, bit 5 clear) made every charge slot hold the battery at 0 W while Predbat logged a correct slot, target and rate. For FB00 it is driven through `write_solis_energy_control()` from `adjust_charge_immediate()` on non-exporting cycles - `Backup/Reserve` (49) to charge or idle, `Backup/Reserve - No Grid Charging` (17) for a freeze or a car/iBoost/reserve hold - and from `adjust_export_immediate()` on exporting cycles - `Feed-in priority - No Grid Charging` (64) for a freeze export, `Backup/Reserve` for a real export (PR #5258). The Battery Reserve bit (bit 4) is what makes the template's `reserve` (`backup_mode_soc`, 43024) a discharge floor, so the reserve holds in `execute_plan()` work unchanged; grid charging goes off during holds because the reserve is raised to SoC + 1 and the inverter would import to reach it, again each cycle. `execute_plan()` never calls both immediate methods with a real target in one cycle, and the idle `adjust_export_immediate(100)` deliberately leaves the switch alone; GS still writes from `mimic_target_soc()`. Adding a writer anywhere else (e.g. `adjust_battery_target()`) flips the switch twice per cycle. Older logs: PR #5256 (merged 2026-09-26) held it on `Self-Use` (33) from `adjust_battery_target()`, and before that GS_fb00 never wrote the switch at all. A SolisCloud session is the usual way the switch ends up at 1: `solis.py` maps `"Self-Use - No Timed Charge/Discharge"` to `ENUM_SELF_USE_NO_GRID_CHARGING`, which clears bit 5, and writes it whenever no slot is active - so stopping the cloud component outside a slot leaves grid charging disallowed. The FB00 Solax Modbus plugin's option names differ from the pre-FB00 plugin's (`Self-Use` is 33 there, 35 here) - `SOLAX_SOLIS_MODES_FB00`. Verified from a user log (26 Sep 2026) and the plugin source. | `./run_all --test inverter` (`solis_fb00_*`, `solis_gs_*`) | | SolaX (`solax.py`) | Code `10402` is a token/auth failure, retried in-request rather than waiting for the next cycle. SolaX clamps battery minimum SOC at 10% (`SOLAX_MIN_RESERVE_PERCENT`), so `battery_min_soc` is auto-configured to stop Predbat writing limits the inverter will reject. **Follow-up on the GH#4993 thread (2026-09-21, from a 23-hour reporter log):** `10402` repeating every cycle with data still flowing is **server-side token invalidation between cycles**, not broken auth or lost data — Predbat's in-request refresh recovers every cycle. The tell: the same token value succeeds mid-cycle and is rejected next cycle while Predbat holds it untouched in memory between cycles (the auth-error branch of `_request_get_impl()` clears it, and `request_wrapper()` retries with a fresh token instead of failing the fetch), and a stated ~30-day `expires_in` against a real ~1-cycle token lifetime. Prime suspect is a second client sharing the same `solax_client_id`/`solax_client_secret` — the reporter ran the separate SolaX Developer API HA integration — **suspected, not proven** (one-active-token-per-app is common but could not be confirmed against SolaX docs); the decisive test is disabling the second client for an hour or registering each client its own app. Count `error 10402` lines against `Fetched ... successfully` lines before calling data lost, and note each 10402 also increments the component's lifetime error counter (`solax.py` auth-error branch), which is what accumulates in the Components panel — do not "fix" this by removing the in-request retry. Secondary, same thread: `Warn: SolarAPI: No solar forecast data was returned from HA sensors.` with the SolaX Cloud template means **no PV forecast source is configured at all** — with `forecast_solar`/`open_meteo`/`solcast_host`+`solcast_api_key` all absent, `fetch_pv_forecast()` falls through to the HA-sensor branch (`solcast.py` sets `active_source = "ha_sensors"`) and the template's `pv_forecast_today`-style `re:` regexes (`templates/solax_cloud.yaml`) match nothing unless the Solcast HA integration is installed. Check which source the log names ("Obtaining solar forecast from Forecast Solar API" vs "Using Solcast integration from inside HA") — a working source dropped during a template migration looks exactly like this. | `solax` | | Sigenergy (`sigenergy.py`) | Lifetime/history totals are cumulative server-side and reset around EU midnight, which showed up as an overnight dip; `fetch_history_totals` applies a monotonic clamp. "Energy totals went backwards overnight" starts here. Separately, a user `apps.yaml` override of `inverter.has_reserve_soc: true` (stock `SIG` default is `False`, deliberately — reverted in `5bfc5c80` after #2873/#3124) bypasses two guards that normally make reserve writes inert for Sigenergy (`execute.py:947-950`, `inverter.py:547-549`), so `adjust_reserve()` writes straight to `number.sigen_plant_ess_discharge_cut_off_state_of_charge`. Confirmed from a reporter's log: zero occurrences of the "Inverter does not support reserve" disable message the first guard would normally log, plus 56 reserve writes landing on that register (GH#4728). The stock template's `reserve:` line also points at that same entity rather than `ess_backup_state_of_charge`, which is what makes the override immediately destructive rather than merely inert. `Applying mode=eco ... duration=720min` is emitted only by the no-window fall-through branch (`sigenergy.py:2199`), so seeing it while a plan export window is live means the component's control snapshot never saw the window. Two code-verified routes to that, both still live: with `sigenergy_automatic: False`, `automatic_config()` - the only wiring of the component's control entities into Predbat - never runs, so Predbat's plan writes land on its own simulated dummy entities (`inverter.py:539-545`) and a `True == True` read-back is Predbat comparing against itself rather than reading a register; and `fetch_controls` runs on the first tick only (`if first:`, `sigenergy.py:2658`), so a single missed `call_service` event leaves the component's internal state permanently out of step while Predbat's write-skip comparison guarantees the healing write is never re-issued (GH#4894, probe-verified in `test_sigenergy.py`). `_publish_mqtt` logged the live `accessToken` on every command until PR #4926 added `SigenergyAPI.redact()`. The docs template still writes `number.sigen_plant_grid_import_limitation` to 0 in both freeze branches (`docs/inverter-setup.md:1936-1940` and `:1971-1975`), and Sigenergy's own EVAC/EVDC chargers are EMS-throttled to the plant import limit - so the block lands exactly when Predbat asks for Freeze Charging to hold for a car (GH#4911). | `sigenergy` | From f15c4cff47c39062bb500a6fc47075930184f82b Mon Sep 17 00:00:00 2001 From: CI Date: Sun, 27 Sep 2026 16:08:59 +0100 Subject: [PATCH 2/2] fix(solis): address #5265 review - honour the reserve, show SOC limits at once, hold hysteresis - Always set the Battery Reserve bit. set_reserve_enable only decides whether Predbat changes the Battery Reserve SOC; with it off inverter.py plans against the live reserve, so the inverter must enforce it too. Gating the bit left the plan flooring at a register the inverter ignored, a reserve pinned after switching off mid-hold, and a mixed-fleet hole - show_soc_limit_now(): a battery SOC limit change updates the cache and its entity at once; the write still happens from the event queue. Non-slot events wait for the next run(), far beyond Predbat's 2s write verify - 1% hysteresis on the charge-slot hold (SOLIS_HOLD_HYSTERESIS) so the mode does not flip across the target boundary, without stopping a charge short - V1 zeroes the slot current from the same hold check; one set_hold_storage_mode() for both firmware branches; SOLIS_SOC_LIMITS shared by publishing, the immediate update and the write handler - Warn at startup when reserve still points at the over-discharge SOC - Tests: hold hysteresis, hold values with the reserve bit (19 V1 / 17 V2), hold ending with the slot window, immediate SOC limit, the warning; existing storage-mode value tests now carry bit 4 - Docs: reserve always honoured, manual reserve binding, V2 hold label Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/solis.py | 194 +++++++++++++++++++++---------- apps/predbat/tests/test_solis.py | 189 ++++++++++++++++++++++-------- docs/components.md | 5 +- tools/debug-journal.md | 2 +- 4 files changed, 275 insertions(+), 115 deletions(-) diff --git a/apps/predbat/solis.py b/apps/predbat/solis.py index 8a81107ae..f091a82dc 100644 --- a/apps/predbat/solis.py +++ b/apps/predbat/solis.py @@ -217,6 +217,19 @@ SOLIS_BIT_FEED_IN_PRIORITY = 6 # Feed-in priority mode SOLIS_BIT_PEAK_SHAVING = 11 # Peak-shaving mode +# Battery SOC limit numbers the component publishes: (CID, entity field, friendly name, icon) +SOLIS_SOC_LIMITS = [ + (SOLIS_CID_BATTERY_RESERVE_SOC, "reserve_soc", "Battery Reserve SOC", "mdi:battery-50"), + (SOLIS_CID_BATTERY_OVER_DISCHARGE_SOC, "over_discharge_soc", "Battery Over Discharge SOC", "mdi:battery-50"), + (SOLIS_CID_BATTERY_FORCE_CHARGE_SOC, "force_charge_soc", "Battery Force Charge SOC", "mdi:battery-alert"), + (SOLIS_CID_BATTERY_RECOVERY_SOC, "recovery_soc", "Battery Recovery SOC", "mdi:battery-50"), + (SOLIS_CID_BATTERY_MAX_CHARGE_SOC, "max_charge_soc", "Battery Max Charge SOC", "mdi:battery"), +] + +# How far below its target a charge slot that is already holding the battery may dip before it charges again. +# Without it the mode would flip between hold and charge (a CID 636 write each way) across the target boundary. +SOLIS_HOLD_HYSTERESIS = 1 + # How long to wait before re-reading a register whose verify read disagreed with the write. The # verify read is taken within about half a second of the write and a disagreement is not proof the # write was refused - the same value verifies successfully minutes either side of one that does not @@ -317,8 +330,8 @@ def compute_solis_mode_value(mode_enum, old_value, drop_tou_bit=False, battery_r bit 1 was dropped from the table, so 35 becomes 33 and 98 becomes 96. Set by set_storage_mode_if_needed() from is_tou_v2_mode() (see issues #4707, #4774). battery_reserve: True sets the Battery Reserve bit, which makes the Battery Reserve SOC (CID 157) a - discharge floor; None leaves it as old_value has it, so a reserve the user turned on - is not cleared. Set by set_storage_mode_if_needed() while set_reserve_enable is on. + discharge floor; None leaves it as old_value has it. set_storage_mode_if_needed() always + sets it, so Predbat's reserve is honoured whether or not Predbat is the one changing it. Returns: The value to write to CID 636 """ @@ -459,6 +472,7 @@ def initialize(self, api_key=None, api_secret=None, inverter_sn=None, automatic= self.automatic = automatic self.session = None self.queued_events = [] + self.charge_slot_holding = {} # {inverter_sn: bool} whether the open charge slot was holding last time # Slot events change the schedule from the caller's loop while run() copies and restores it on this one self.schedule_lock = threading.Lock() self.schedules_loaded = set() # Inverters whose schedule has been decoded, so slot events can apply to it @@ -1093,29 +1107,42 @@ def restore_refused_slot_write(self, inverter_sn, slot, direction, kind, cid, at self.log(f"Warn: Solis API: {direction} slot {slot} {kind} was not accepted by {inverter_sn}, showing the inverter's {inverter_value} so Predbat writes it again") def is_holding_in_charge_slot(self, inverter_sn, slot_data): - """True when slot_data is a charge slot open now with the battery already at or above its target SOC. + """True when slot_data is a charge slot open now with the battery already at its target SOC. That is what a freeze charge looks like here - Predbat keeps the charge window with the target at the current SoC - and what the end of a charge looks like once the target is reached. The slot keeps the - battery from discharging, so with grid charging off it holds without importing to top up. + battery from discharging, so with grid charging off it holds without importing to top up. Once holding, + a dip of up to SOLIS_HOLD_HYSTERESIS below the target stays in the hold, so the mode does not flip + (and re-import the dip) across the target boundary; the answer is remembered for the next call. Args: inverter_sn: Inverter serial number - slot_data: One slot of the charge/discharge schedule, or None + slot_data: The charge slot to check, or None when no charge slot is open Returns: True when the slot is only holding the battery """ - if not slot_data or not slot_data.get("charge_enable", 0): - return False - current_time = self.now_utc_exact.strftime("%H:%M") # Local time, as the slot times are - if not time_in_window(current_time, slot_data.get("charge_start_time", "00:00"), slot_data.get("charge_end_time", "00:00")): - return False - try: - battery_soc = float(self.inverter_details.get(inverter_sn, {}).get("batteryCapacitySoc")) - charge_soc = float(slot_data.get("charge_soc", 100)) - except (TypeError, ValueError): - return False - return battery_soc >= charge_soc + holding_before = self.charge_slot_holding.get(inverter_sn, False) + holding = False + if slot_data and slot_data.get("charge_enable", 0): + current_time = self.now_utc_exact.strftime("%H:%M") # Local time, as the slot times are + if time_in_window(current_time, slot_data.get("charge_start_time", "00:00"), slot_data.get("charge_end_time", "00:00")): + try: + battery_soc = float(self.inverter_details.get(inverter_sn, {}).get("batteryCapacitySoc")) + charge_soc = float(slot_data.get("charge_soc", 100)) + holding = battery_soc >= charge_soc - (SOLIS_HOLD_HYSTERESIS if holding_before else 0) + except (TypeError, ValueError): + holding = False + self.charge_slot_holding[inverter_sn] = holding + return holding + + async def set_hold_storage_mode(self, inverter_sn): + """Set the storage mode for a charge slot that is only holding the battery: Self-Use with no grid charging. + + Args: + inverter_sn: Inverter serial number + """ + self.log(f"Solis API: In a charge slot at its target SOC on {inverter_sn}, setting storage mode to 'Self-Use - No Grid Charging' so it holds without importing") + await self.set_storage_mode_if_needed(inverter_sn, "Self-Use - No Grid Charging") async def write_time_windows_if_changed(self, inverter_sn): """Write charge/discharge time windows, SOC, and current to inverter, only if values changed from cache. @@ -1145,7 +1172,6 @@ async def write_time_windows_if_changed(self, inverter_sn): charge_current = max_charge_current_amps discharge_current = max_discharge_current_amps slot1_active = False # Whether slot 1 has any charge/discharge window configured - holding = False # Whether slot 1 is a charge slot open now with the battery at its target # There appears to be charge current limit on the slots which confused as slot 1 has a higher limit than the others, # but the value isn't accepted, find the lowest slot limit @@ -1174,7 +1200,6 @@ async def write_time_windows_if_changed(self, inverter_sn): # Predbat only uses slot 1, so it can indicate current here. charge_current = slot_data.get("charge_current", charge_current) slot1_active = bool(slot_data.get("charge_enable", 0)) or bool(slot_data.get("discharge_enable", 0)) - holding = self.is_holding_in_charge_slot(inverter_sn, slot_data) discharge_current = slot_data.get("discharge_current", discharge_current) # When a slot is disabled, zero out its times so the inverter shows a clean 00:00-00:00 @@ -1185,6 +1210,9 @@ async def write_time_windows_if_changed(self, inverter_sn): slot_data["discharge_start_time"] = "00:00" slot_data["discharge_end_time"] = "00:00" + # Whether slot 1 is a charge slot open now with the battery at its target + holding = self.is_holding_in_charge_slot(inverter_sn, time_windows.get(1)) + # Pass 1: Clear all disabled slots first. # This prevents stale times on the inverter from blocking active-slot writes due to overlap conflicts. for slot in slots_to_check: @@ -1311,8 +1339,7 @@ async def write_time_windows_if_changed(self, inverter_sn): # inverter is not left in a timed mode with nothing scheduled. A charge slot that is only # holding the battery comes first: a freeze charge must not export PV via the 0A rule. if holding: - self.log(f"Solis API: In a charge slot at its target SOC on {inverter_sn}, setting storage mode to 'Self-Use - No Grid Charging' so it holds without importing") - await self.set_storage_mode_if_needed(inverter_sn, "Self-Use - No Grid Charging") + await self.set_hold_storage_mode(inverter_sn) elif charge_current == 0: if slot1_active: self.log(f"Solis API: Charge current is 0A for {inverter_sn}, setting storage mode to 'Feed-in priority'") @@ -1338,7 +1365,6 @@ async def write_time_windows_if_changed(self, inverter_sn): # if we are inside the slot then compare the current SOC to the target SOC and decide if we zero out the Amps or not in_charge_slot = None - holding = False # Whether the open charge slot is only holding the battery at its target in_discharge_slot = None current_time = self.now_utc_exact.strftime("%H:%M") # Use local time to match time windows set by Predbat in local timezone max_charge_current_amps = self.cached_values.get(inverter_sn, {}).get(SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT, None) @@ -1380,6 +1406,10 @@ async def write_time_windows_if_changed(self, inverter_sn): detail = self.inverter_details.get(inverter_sn, {}) battery_soc = detail.get("batteryCapacitySoc", 0) + # Whether the open charge slot is only holding the battery at its target - the one reading of "at + # target" that both the zeroed current and the mode decision below follow + holding = self.is_holding_in_charge_slot(inverter_sn, time_windows.get(in_charge_slot) if in_charge_slot and not in_discharge_slot else None) + # Adjust currents based on SOC and active slots if in_discharge_slot: slot_data = time_windows.get(in_discharge_slot) @@ -1391,10 +1421,9 @@ async def write_time_windows_if_changed(self, inverter_sn): charge_current = slot_data.get("charge_current", charge_current) elif in_charge_slot: slot_data = time_windows.get(in_charge_slot) - holding = self.is_holding_in_charge_slot(inverter_sn, slot_data) charge_soc_to_write = slot_data.get("charge_soc", 100) - if battery_soc >= charge_soc_to_write: - # Zero out charge current if above target SOC + if holding: + # Zero out charge current once at the target SOC slot_data["charge_current"] = 0 self.log(f"Solis API: In charge slot {in_charge_slot}, target SOC: {charge_soc_to_write}%") discharge_current = slot_data.get("discharge_current", discharge_current) @@ -1438,8 +1467,7 @@ async def write_time_windows_if_changed(self, inverter_sn): # battery comes first, as in V2: the zeroed charge current above would otherwise select # Feed-in priority and export PV during a freeze charge. if holding: - self.log(f"Solis API: In a charge slot at its target SOC on {inverter_sn}, setting storage mode to 'Self-Use - No Grid Charging' so it holds without importing") - await self.set_storage_mode_if_needed(inverter_sn, "Self-Use - No Grid Charging") + await self.set_hold_storage_mode(inverter_sn) elif charge_current == 0: if in_charge_slot or in_discharge_slot: self.log(f"Solis API: Charge current is 0A for {inverter_sn}, setting storage mode to 'Feed-in priority'") @@ -3049,29 +3077,8 @@ async def publish_entities(self): ) # Battery SOC limit numbers - for cid, name, friendly, icon in [ - (SOLIS_CID_BATTERY_RESERVE_SOC, "reserve_soc", "Battery Reserve SOC", "mdi:battery-50"), - (SOLIS_CID_BATTERY_OVER_DISCHARGE_SOC, "over_discharge_soc", "Battery Over Discharge SOC", "mdi:battery-50"), - (SOLIS_CID_BATTERY_FORCE_CHARGE_SOC, "force_charge_soc", "Battery Force Charge SOC", "mdi:battery-alert"), - (SOLIS_CID_BATTERY_RECOVERY_SOC, "recovery_soc", "Battery Recovery SOC", "mdi:battery-50"), - (SOLIS_CID_BATTERY_MAX_CHARGE_SOC, "max_charge_soc", "Battery Max Charge SOC", "mdi:battery"), - ]: - entity_id = f"number.{prefix}_solis_{inverter_sn_lower}_{name}" - soc_value = values.get(cid, None) - self.dashboard_item( - entity_id, - state=soc_value, - attributes={ - "friendly_name": f"Solis {inverter_name} {friendly}", - "unit_of_measurement": "%", - "min": 0, - "max": 100, - "step": 1, - "device_class": "battery", - "icon": icon, - }, - app="solis" - ) + for cid, name, friendly, icon in SOLIS_SOC_LIMITS: + self.publish_soc_limit(inverter_sn, cid, name, friendly, icon) # Battery max current numbers (displayed as power in watts) entity_id = f"number.{prefix}_solis_{inverter_sn_lower}_max_charge_power" @@ -3274,11 +3281,10 @@ async def set_storage_mode_if_needed(self, inverter_sn, mode): cached_mode = self.get_current_solis_mode_value(inverter_sn) # TOU V2 firmware dropped bit 1 from the mode table, so asking for it there only # produces a write that reads back without it (issues #4707, #4774) - # Keep the Battery Reserve bit on while Predbat manages the reserve, so the Battery Reserve SOC it - # writes is a real floor. Without set_reserve_enable Predbat never writes that SOC, and turning the - # bit on would enforce whatever the inverter already holds there, so the bit is left alone. - battery_reserve = True if self.base.get_arg("set_reserve_enable", False) else None - mode_value = compute_solis_mode_value(mode_enum, cached_mode, drop_tou_bit=self.is_tou_v2_mode(inverter_sn), battery_reserve=battery_reserve) + # Keep the Battery Reserve bit on so the Battery Reserve SOC - Predbat's reserve - is honoured as a discharge + # floor. set_reserve_enable only decides whether Predbat changes that SOC; with it off the user manages the + # reserve, but it is still their reserve and Predbat plans against it, so the inverter must enforce it too. + mode_value = compute_solis_mode_value(mode_enum, cached_mode, drop_tou_bit=self.is_tou_v2_mode(inverter_sn), battery_reserve=True) # Claimed unconditionally so the claim is spent by whichever write happens this cycle, # rather than being held over and asserting again later in the same window @@ -3361,8 +3367,76 @@ async def select_event(self, entity_id, value): async def number_event(self, entity_id, value): """Handle a number change from Home Assistant.""" + self.show_soc_limit_now(entity_id, value) await self.route_event(self.number_event_handler, entity_id, value) + def publish_soc_limit(self, inverter_sn, cid, name, friendly, icon): + """Publish one battery SOC limit number from the cached register value. + + Args: + inverter_sn: Inverter serial number + cid: The limit's CID + name: Entity field, e.g. "reserve_soc" + friendly: Friendly name suffix + icon: Entity icon + """ + prefix = self.base.get_arg("prefix", "predbat") + inverter_name = self.inverter_details.get(inverter_sn, {}).get("inverterName", inverter_sn) + self.dashboard_item( + f"number.{prefix}_solis_{inverter_sn.lower()}_{name}", + state=self.cached_values.get(inverter_sn, {}).get(cid, None), + attributes={ + "friendly_name": f"Solis {inverter_name} {friendly}", + "unit_of_measurement": "%", + "min": 0, + "max": 100, + "step": 1, + "device_class": "battery", + "icon": icon, + }, + app="solis", + ) + + def show_soc_limit_now(self, entity_id, value): + """Show a battery SOC limit change in the cache and its entity at once; the write waits in the queue. + + Predbat checks what it wrote within write_and_poll_sleep, but an event other than a slot schedule one + is only handled at the top of the next run(), up to a minute later - so without this the reserve it + just set reads back unchanged and the write is reported as failed. The queued handler still does the + write, and its read-back puts what the inverter actually reports back in the cache. + + Args: + entity_id: The number entity changed + value: The requested value + """ + prefix = self.base.get_arg("prefix", "predbat") + entity_prefix = f"number.{prefix}_solis_" + if not entity_id.startswith(entity_prefix): + return + inverter_sn, _, field = entity_id[len(entity_prefix):].partition("_") + limit = next((limit for limit in SOLIS_SOC_LIMITS if limit[1] == field), None) + inverter_sn = self.find_inverter_by_sn(inverter_sn) + if limit is None or inverter_sn is None: + return + try: + value_str = str(int(value)) + except (TypeError, ValueError): + return + cid, name, friendly, icon = limit + self.cached_values.setdefault(inverter_sn, {})[cid] = value_str + self.publish_soc_limit(inverter_sn, cid, name, friendly, icon) + + def warn_reserve_bound_to_over_discharge_soc(self): + """Warn when the reserve is still bound to the over-discharge SOC, as automatic_config() used to bind it. + + The reserve is now the Battery Reserve SOC. A manual setup copied from the old binding would have Predbat + raise the over-discharge SOC - a safety floor it otherwise never writes - to hold the battery. + """ + reserve = self.base.get_arg("reserve", None, indirect=False) + for entity_id in reserve if isinstance(reserve, list) else [reserve]: + if isinstance(entity_id, str) and entity_id.endswith("_over_discharge_soc"): + self.log(f"Warn: Solis API: reserve is set to {entity_id} in apps.yaml - point it at the Battery Reserve SOC (the matching _reserve_soc number) instead, or Predbat will raise the over-discharge SOC to hold the battery") + async def switch_event(self, entity_id, service): """Handle a switch service call from Home Assistant.""" await self.route_event(self.switch_event_handler, entity_id, service) @@ -3585,14 +3659,8 @@ async def number_event_handler(self, entity_id, value): return # Handle battery SOC limits - if field in ["reserve_soc", "over_discharge_soc", "force_charge_soc", "recovery_soc", "max_charge_soc"]: - cid_map = { - "reserve_soc": SOLIS_CID_BATTERY_RESERVE_SOC, - "over_discharge_soc": SOLIS_CID_BATTERY_OVER_DISCHARGE_SOC, - "force_charge_soc": SOLIS_CID_BATTERY_FORCE_CHARGE_SOC, - "recovery_soc": SOLIS_CID_BATTERY_RECOVERY_SOC, - "max_charge_soc": SOLIS_CID_BATTERY_MAX_CHARGE_SOC, - } + cid_map = {name: cid for cid, name, _, _ in SOLIS_SOC_LIMITS} + if field in cid_map: cid = cid_map[field] # Write to inverter @@ -4052,6 +4120,8 @@ async def run(self, seconds, first): self.log("Error: Solis API: No inverters to manage after discovery") return False # Stop further processing if no inverters + self.warn_reserve_bound_to_over_discharge_soc() + # Process events queued by the entity callbacks, on this loop. Drained after the # startup block: the handlers need the ClientSession and the discovered inverter # list, which only exist once the first cycle has completed. On a failed startup diff --git a/apps/predbat/tests/test_solis.py b/apps/predbat/tests/test_solis.py index fa38740a4..32db3556f 100644 --- a/apps/predbat/tests/test_solis.py +++ b/apps/predbat/tests/test_solis.py @@ -38,7 +38,7 @@ def __init__(self, prefix="predbat"): self.prefix = prefix self.args = {} - def get_arg(self, key, default=None): + def get_arg(self, key, default=None, **kwargs): """Mock get_arg method - prefix, then anything a test put in args, else the default""" if key == "prefix": return self.prefix @@ -64,6 +64,7 @@ def __init__(self, prefix="predbat"): # Entity callbacks queue here for the component loop to drain; the real # __init__ (skipped above) sets this. self.queued_events = [] + self.charge_slot_holding = {} # Guards the slot schedule, which slot events change from the caller's loop; the real __init__ sets both self.schedule_lock = threading.Lock() self.schedules_loaded = set() @@ -1937,7 +1938,9 @@ def run_solis_tests(my_predbat): failed |= asyncio.run(test_write_time_windows_zero_charge_current()) failed |= asyncio.run(test_write_time_windows_hold_in_charge_slot()) failed |= asyncio.run(test_compute_solis_mode_value_hold_and_reserve()) - failed |= asyncio.run(test_storage_mode_sets_battery_reserve_with_set_reserve_enable()) + failed |= asyncio.run(test_storage_mode_always_sets_battery_reserve()) + failed |= asyncio.run(test_number_event_soc_limit_shows_at_once()) + failed |= asyncio.run(test_warns_when_reserve_is_the_over_discharge_soc()) failed |= asyncio.run(test_write_time_windows_v1_slot_detection()) failed |= asyncio.run(test_write_time_windows_v1_discharge_slot_detection()) failed |= asyncio.run(test_write_time_windows_v1_local_time_not_utc()) @@ -3546,7 +3549,7 @@ async def test_write_time_windows_zero_charge_current(): return False -def _hold_slot_api(v2, battery_soc, charge_soc, charge_current=50): +def _hold_slot_api(v2, battery_soc, charge_soc, charge_current=50, now="03:00", real_mode=False): """A MockSolisAPI at 03:00 inside charge slot 1 (02:00-05:00) with the battery at battery_soc. Args: @@ -3554,12 +3557,14 @@ def _hold_slot_api(v2, battery_soc, charge_soc, charge_current=50): battery_soc: batteryCapacitySoc the inverter reports charge_soc: Slot 1's charge target SOC charge_current: Slot 1's charge current in amps + now: Local time the slot is checked at, "HH:MM" + real_mode: Drive the real set_storage_mode_if_needed, so the CID 636 value written is recorded Returns: (api, inverter_sn) """ api = MockSolisAPI() api._test_v2_mode = v2 - api._mock_storage_mode = True + api._mock_storage_mode = not real_mode inverter_sn = "TEST123" api.inverter_sn = [inverter_sn] empty = {"charge_start_time": "00:00", "charge_end_time": "00:00", "charge_soc": 100, "charge_current": 0, "discharge_start_time": "00:00", "discharge_end_time": "00:00", "discharge_soc": 10, "discharge_current": 0} @@ -3582,7 +3587,7 @@ def _hold_slot_api(v2, battery_soc, charge_soc, charge_current=50): api.cached_values[inverter_sn] = {} api.inverter_details[inverter_sn] = {"batteryCapacitySoc": battery_soc} mock_now = MagicMock() - mock_now.strftime.return_value = "03:00" + mock_now.strftime.return_value = now api._test_now_utc_exact = mock_now return api, inverter_sn @@ -3613,6 +3618,30 @@ async def test_write_time_windows_hold_in_charge_slot(): modes = [call["mode"] for call in api.set_storage_mode_calls] assert modes == [expected], "v2={} soc={} target={} current={}A: expected [{}], got {}".format(v2, battery_soc, charge_soc, charge_current, expected, modes) + # Hysteresis: once holding, a dip of 1% stays in the hold rather than re-importing it; more than that + # charges again. Without a hold to stay in, 1% below the target is an ordinary charge that finishes. + api, inverter_sn = _hold_slot_api(True, 60, 60) + for battery_soc, expected in [(60, "Self-Use - No Grid Charging"), (59, "Self-Use - No Grid Charging"), (58, "Self-Use"), (59, "Self-Use"), (60, "Self-Use - No Grid Charging")]: + api.inverter_details[inverter_sn]["batteryCapacitySoc"] = battery_soc + api.set_storage_mode_calls = [] + await api.write_time_windows_if_changed(inverter_sn) + modes = [call["mode"] for call in api.set_storage_mode_calls] + assert modes == [expected], "hysteresis at soc {}: expected [{}], got {}".format(battery_soc, expected, modes) + + # The hold written for real carries the Battery Reserve bit: 19 on V1 (TOU bit kept), 17 on V2 + for v2, expected_value in [(False, "19"), (True, "17")]: + api, inverter_sn = _hold_slot_api(v2, 60, 60, real_mode=True) + await api.write_time_windows_if_changed(inverter_sn) + values = [call["value"] for call in api.read_and_write_cid_calls if call["cid"] == SOLIS_CID_STORAGE_MODE] + assert values == [expected_value], "v2={}: expected CID 636 write [{}], got {}".format(v2, expected_value, values) + + # Outside the slot's window the hold ends even though the slot is still programmed + for v2, expected in [(True, "Self-Use"), (False, "Self-Use - No Timed Charge/Discharge")]: + api, inverter_sn = _hold_slot_api(v2, 60, 60, now="06:00") + await api.write_time_windows_if_changed(inverter_sn) + modes = [call["mode"] for call in api.set_storage_mode_calls] + assert modes == [expected], "v2={} after the slot: expected [{}], got {}".format(v2, expected, modes) + print("PASSED: charge slot at target holds without grid charging") return False @@ -3647,20 +3676,20 @@ async def test_compute_solis_mode_value_hold_and_reserve(): return False -async def test_storage_mode_sets_battery_reserve_with_set_reserve_enable(): - """set_storage_mode_if_needed turns the Battery Reserve bit on only while Predbat manages the reserve. +async def test_storage_mode_always_sets_battery_reserve(): + """set_storage_mode_if_needed always keeps the Battery Reserve bit on, so the Battery Reserve SOC is honoured. - With set_reserve_enable on, Predbat writes the Reserved SOC (reserve_min normally, SoC + 1 for a hold), - so the bit makes that a real floor. With it off Predbat never writes the Reserved SOC, and turning the bit - on would enforce whatever the inverter already holds there, so the bit is left alone. + set_reserve_enable only says whether Predbat may change that SOC (reserve_min, or SoC + 1 for a hold). With + it off the user manages the reserve themselves, but it is still their reserve: the inverter enforces it, + and Predbat plans against it, whichever way the switch is set. """ - print("\n=== Test: storage mode Battery Reserve bit follows set_reserve_enable ===") + print("\n=== Test: storage mode always sets the Battery Reserve bit ===") for reserve_enable, mode, cached, expected in [ (True, "Self-Use", "1", "49"), (True, "Self-Use - No Timed Charge/Discharge", "33", "17"), (True, "Feed-in priority - No Timed Charge/Discharge", "1", "80"), - (False, "Self-Use", "1", "33"), - (False, "Self-Use - No Timed Charge/Discharge", "33", "1"), + (False, "Self-Use", "1", "49"), + (False, "Self-Use - No Timed Charge/Discharge", "33", "17"), ]: api = MockSolisAPI() api._test_v2_mode = True @@ -3672,7 +3701,59 @@ async def test_storage_mode_sets_battery_reserve_with_set_reserve_enable(): values = [call["value"] for call in api.read_and_write_cid_calls if call["cid"] == SOLIS_CID_STORAGE_MODE] assert values == [expected], "set_reserve_enable={} {} from {}: expected [{}], got {}".format(reserve_enable, mode, cached, expected, values) - print("PASSED: Battery Reserve bit follows set_reserve_enable") + print("PASSED: Battery Reserve bit always set") + return False + + +async def test_number_event_soc_limit_shows_at_once(): + """A battery SOC limit change shows in the cache and its entity at once; the write waits in the queue. + + Predbat checks a write within write_and_poll_sleep (2s on SolisCloud), but events other than slot schedule + ones are only handled at the top of the next run(), up to a minute later - so the requested value is shown + straight away, and the queued handler writes it and refreshes the cache from what the inverter reports. + """ + print("\n=== Test: number_event SOC limit shows at once ===") + api = MockSolisAPI() + inverter_sn = "123456" + api.inverter_sn = [inverter_sn] + api.cached_values[inverter_sn] = {SOLIS_CID_BATTERY_RESERVE_SOC: "10"} + entity_id = f"number.predbat_solis_{inverter_sn}_reserve_soc" + + await api.number_event(entity_id, 55) + assert api.cached_values[inverter_sn][SOLIS_CID_BATTERY_RESERVE_SOC] == "55", api.cached_values[inverter_sn] + assert api.dashboard_items[entity_id]["state"] == "55", api.dashboard_items.get(entity_id) + assert api.dashboard_items[entity_id]["attributes"]["unit_of_measurement"] == "%" + assert not [call for call in api.read_and_write_cid_calls if call["cid"] == SOLIS_CID_BATTERY_RESERVE_SOC], "the write waits for the queue" + assert len(api.queued_events) == 1, api.queued_events + + handler, *args = api.queued_events.pop(0) + await handler(*args) + writes = [call["value"] for call in api.read_and_write_cid_calls if call["cid"] == SOLIS_CID_BATTERY_RESERVE_SOC] + assert writes == ["55"], writes + + print("PASSED: SOC limit change shown at once, written from the queue") + return False + + +async def test_warns_when_reserve_is_the_over_discharge_soc(): + """A reserve binding still pointing at the over-discharge SOC is warned about. + + automatic_config() used to bind reserve there, so a manual (solis_automatic: false) setup copied from it + would now have Predbat raise the over-discharge SOC - a safety floor it otherwise never writes - to hold + the battery, and battery_min_soc would follow it up. + """ + print("\n=== Test: warn when reserve is the over-discharge SOC ===") + api = MockSolisAPI() + api.base.args["reserve"] = ["number.predbat_solis_123456_over_discharge_soc"] + api.warn_reserve_bound_to_over_discharge_soc() + assert any("over_discharge_soc" in msg and msg.startswith("Warn:") for msg in api.log_messages), api.log_messages + + api = MockSolisAPI() + api.base.args["reserve"] = ["number.predbat_solis_123456_reserve_soc"] + api.warn_reserve_bound_to_over_discharge_soc() + assert not any(msg.startswith("Warn:") for msg in api.log_messages), api.log_messages + + print("PASSED: over-discharge reserve binding warned about") return False @@ -5033,8 +5114,8 @@ async def test_set_storage_mode_self_use(): # Verify correct CID and value (Self-Use = bits 0,5 set = 33) call = calls[0] assert call["cid"] == SOLIS_CID_STORAGE_MODE, f"Expected CID {SOLIS_CID_STORAGE_MODE}, got {call['cid']}" - assert call["value"] == "35", f"Expected value '35' (Self-Use), got {call['value']}" - assert "storage mode to 35" in call["field_description"], "Field description should mention value 35" + assert call["value"] == "51", f"Expected value '51' (Self-Use + Battery Reserve), got {call['value']}" + assert "storage mode to 51" in call["field_description"], "Field description should mention value 51" print("PASSED: set_storage_mode_if_needed writes Self-Use correctly") return False @@ -5061,8 +5142,8 @@ async def test_set_storage_mode_feed_in_priority(): # Verify correct CID and value (Feed-in priority = bits 5,6 set = 96) call = calls[0] assert call["cid"] == SOLIS_CID_STORAGE_MODE, f"Expected CID {SOLIS_CID_STORAGE_MODE}, got {call['cid']}" - assert call["value"] == "98", f"Expected value '98' (Feed-in priority), got {call['value']}" - assert "storage mode to 98" in call["field_description"], "Field description should mention value 98" + assert call["value"] == "114", f"Expected value '114' (Feed-in priority + Battery Reserve), got {call['value']}" + assert "storage mode to 114" in call["field_description"], "Field description should mention value 114" print("PASSED: set_storage_mode_if_needed writes Feed-in priority correctly") return False @@ -5115,7 +5196,7 @@ async def test_set_storage_mode_if_needed_changes(): # Verify correct CID and value call = calls[0] assert call["cid"] == SOLIS_CID_STORAGE_MODE, f"Expected CID {SOLIS_CID_STORAGE_MODE}, got {call['cid']}" - assert call["value"] == "35", f"Expected value '35' (Self-Use), got {call['value']}" + assert call["value"] == "51", f"Expected value '51' (Self-Use + Battery Reserve), got {call['value']}" print("PASSED: set_storage_mode_if_needed writes when mode changes") return False @@ -5129,8 +5210,8 @@ async def test_set_storage_mode_if_needed_no_changes(): inverter_sn = "789012" api.inverter_sn = [inverter_sn] - # Setup cached value (currently Self-Use = bits 0,5 = 35) - api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "35"} + # Setup cached value (currently Self-Use + Battery Reserve = 51) + api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "51"} # Call set_storage_mode_if_needed with same mode await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") @@ -5157,10 +5238,10 @@ async def test_set_storage_mode_if_needed_all_modes(): # Test mode transitions (sequential, each uses previous cached value) # Default cache = 1 (1< 49, 35 -> 51. """ failed = False print("\n=== Test: storage mode drops the TOU bit on TOU V2 firmware ===") @@ -5289,11 +5372,11 @@ async def test_storage_mode_drops_the_tou_bit_on_tou_v2_firmware(): for _ in range(3): await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [33]: - print("ERROR: expected one write of 33 with the TOU bit already dropped, got {}".format(api.writes)) + if api.writes != [49]: + print("ERROR: expected one write of 49 with the TOU bit already dropped, got {}".format(api.writes)) failed = True - if api.register != 33: - print("ERROR: expected the inverter to settle at 33, got {}".format(api.register)) + if api.register != 49: + print("ERROR: expected the inverter to settle at 49, got {}".format(api.register)) failed = True warnings = [m for m in api.log_messages if m.startswith("Warn:") and str(SOLIS_CID_STORAGE_MODE) in m] @@ -5314,6 +5397,8 @@ async def test_storage_mode_keeps_asking_for_the_tou_bit_on_tou_v1_firmware(): is transient - the same write of 179 verifies minutes before and minutes after the one that fails - so a single sample cannot establish a firmware property. On V1 firmware bit 1 *is* the timed charge/discharge enable, so dropping it silently disables the charge window; keep asking. + + The Battery Reserve bit (16) is always set now, so the values below carry it: 33 -> 49, 35 -> 51. """ failed = False print("\n=== Test: storage mode keeps asking for the TOU bit on TOU V1 firmware ===") @@ -5327,7 +5412,7 @@ async def test_storage_mode_keeps_asking_for_the_tou_bit_on_tou_v1_firmware(): for _ in range(3): await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [35, 35, 35]: + if api.writes != [51, 51, 51]: print("ERROR: expected the TOU bit to be asked for every cycle, got {}".format(api.writes)) failed = True @@ -5344,6 +5429,8 @@ async def test_a_transient_tou_bit_strip_is_recovered_on_the_next_cycle(): and set_storage_mode_if_needed() stopped writing CID 636 altogether - for eight hours, right across an overnight charge window. Predbat reported a charge target every cycle while the inverter sat with timed charge/discharge disabled. + + The Battery Reserve bit (16) is always set now, so the values below carry it: 33 -> 49, 35 -> 51. """ failed = False print("\n=== Test: a transient TOU bit strip is recovered on the next cycle ===") @@ -5354,19 +5441,19 @@ async def test_a_transient_tou_bit_strip_is_recovered_on_the_next_cycle(): api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "1"} await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.register != 33: - print("ERROR: expected the stripped write to leave the inverter at 33, got {}".format(api.register)) + if api.register != 49: + print("ERROR: expected the stripped write to leave the inverter at 49, got {}".format(api.register)) failed = True # The very same write succeeds on the next cycle, as it does in the reported logs. api.strip_tou = False await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [35, 35]: - print("ERROR: expected 35 to be requested again after the strip, got {}".format(api.writes)) + if api.writes != [51, 51]: + print("ERROR: expected 51 to be requested again after the strip, got {}".format(api.writes)) failed = True - if api.register != 35: - print("ERROR: expected the inverter to recover to 35, got {}".format(api.register)) + if api.register != 51: + print("ERROR: expected the inverter to recover to 51, got {}".format(api.register)) failed = True if not failed: @@ -5379,18 +5466,20 @@ async def test_storage_mode_keeps_the_tou_bit_when_the_inverter_accepts_it(): Dropping it there would be a silent downgrade - without the TOU bit, 33 means "Self-Use - No Timed Charge/Discharge" on the firmware that still uses that table. + + The Battery Reserve bit (16) is always set now, so the values below carry it: 33 -> 49, 35 -> 51. """ failed = False print("\n=== Test: storage mode keeps the TOU bit when it is accepted ===") inverter_sn = "1031260253072197" - api = _StorageModeInverter(register=33, strip_tou=False) + api = _StorageModeInverter(register=49, strip_tou=False) api.inverter_sn = [inverter_sn] - api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "33"} + api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "49"} await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.register != 35: - print("ERROR: expected the inverter to reach 35, got {}".format(api.register)) + if api.register != 51: + print("ERROR: expected the inverter to reach 51, got {}".format(api.register)) failed = True # Something outside Predbat moves it back; the next cycle must still ask for the TOU bit. @@ -5398,8 +5487,8 @@ async def test_storage_mode_keeps_the_tou_bit_when_the_inverter_accepts_it(): api.cached_values[inverter_sn][SOLIS_CID_STORAGE_MODE] = "1" await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [35, 35]: - print("ERROR: expected 35 to be requested both times, got {}".format(api.writes)) + if api.writes != [51, 51]: + print("ERROR: expected 51 to be requested both times, got {}".format(api.writes)) failed = True if not failed: @@ -5728,9 +5817,9 @@ async def test_the_storage_mode_is_re_asserted_when_a_window_opens(): inverter_sn = "6031052254150188" # Already in the value V2 computes, so nothing but the window open can trigger a write. - api = _StorageModeInverter(register=33) + api = _StorageModeInverter(register=49) # Self-Use + Battery Reserve on V2 api.inverter_sn = [inverter_sn] - api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "33", SOLIS_CID_TOU_V2_MODE: "43605"} + api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "49", SOLIS_CID_TOU_V2_MODE: "43605"} api.charge_discharge_time_windows[inverter_sn] = {1: {"charge_enable": 1, "charge_start_time": "23:30", "charge_end_time": "23:45"}} # Slot programmed, window not yet open: the mode already matches, so nothing is written. @@ -5743,7 +5832,7 @@ async def test_the_storage_mode_is_re_asserted_when_a_window_opens(): # Window opens: assert the mode even though it equals the cache. api._test_now_utc_exact = datetime(2026, 8, 25, 23, 30, tzinfo=api.local_tz) await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [33]: + if api.writes != [49]: print("ERROR: expected one mode write when the window opened, got {}".format(api.writes)) failed = True @@ -5751,7 +5840,7 @@ async def test_the_storage_mode_is_re_asserted_when_a_window_opens(): for minute in (35, 40): api._test_now_utc_exact = datetime(2026, 8, 25, 23, minute, tzinfo=api.local_tz) await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [33]: + if api.writes != [49]: print("ERROR: expected the assertion to happen once per window, got {}".format(api.writes)) failed = True @@ -5760,7 +5849,7 @@ async def test_the_storage_mode_is_re_asserted_when_a_window_opens(): await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") api._test_now_utc_exact = datetime(2026, 8, 26, 23, 30, tzinfo=api.local_tz) await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [33, 33]: + if api.writes != [49, 49]: print("ERROR: expected the next night's window to be asserted too, got {}".format(api.writes)) failed = True @@ -5780,9 +5869,9 @@ async def test_a_window_rewritten_mid_flight_gets_its_own_mode_assertion(): print("\n=== Test: a window rewritten mid-flight gets its own mode assertion ===") inverter_sn = "6031052254150188" - api = _StorageModeInverter(register=33) + api = _StorageModeInverter(register=49) # Self-Use + Battery Reserve on V2 api.inverter_sn = [inverter_sn] - api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "33", SOLIS_CID_TOU_V2_MODE: "43605"} + api.cached_values[inverter_sn] = {SOLIS_CID_STORAGE_MODE: "49", SOLIS_CID_TOU_V2_MODE: "43605"} api.charge_discharge_time_windows[inverter_sn] = {1: {"charge_enable": 1, "charge_start_time": "23:30", "charge_end_time": "23:45"}} api._test_now_utc_exact = datetime(2026, 8, 25, 23, 35, tzinfo=api.local_tz) @@ -5792,7 +5881,7 @@ async def test_a_window_rewritten_mid_flight_gets_its_own_mode_assertion(): api.charge_discharge_time_windows[inverter_sn] = {1: {"charge_enable": 1, "charge_start_time": "23:30", "charge_end_time": "01:30"}} await api.set_storage_mode_if_needed(inverter_sn, "Self-Use") - if api.writes != [33, 33]: + if api.writes != [49, 49]: print("ERROR: expected the rewritten window to be asserted as well, got {}".format(api.writes)) failed = True diff --git a/docs/components.md b/docs/components.md index 798b08772..b1206ea22 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1212,8 +1212,9 @@ Integrates with Solis inverters for monitoring and controlling Solis battery sys - Manually set `soc_max` in `apps.yaml` with your battery capacity in kWh (recommended), or - Leave `soc_max` unset and allow Predbat to automatically detect battery size from historical charging data (requires several days of data) - Supports both V1 (older firmware) and V2 (newer firmware) time window formats -- **Reserve and holds**: Predbat's reserve is the inverter's **Battery Reserve SOC**. With **switch.predbat_set_reserve_enable** on, Predbat keeps the inverter's Battery Reserve switched on so that SOC is a real discharge floor, and uses it to hold the battery - while a car or iBoost charges, and for freeze or hold charging. With it off, Predbat leaves the Battery Reserve switch as you set it -- **Freeze charging**: inside a charge slot with the battery already at the slot's target SOC, Predbat sets the storage mode to `Self-Use - No Grid Charging`, so the slot holds the battery without importing from the grid while solar can still charge it +- **Reserve and holds**: Predbat's reserve is the inverter's **Battery Reserve SOC**, and Predbat keeps the inverter's Battery Reserve switched on so that SOC is always honoured as a discharge floor. With **switch.predbat_set_reserve_enable** on, Predbat also changes it - `set_reserve_min` normally, and just above the current SoC to hold the battery while a car or iBoost charges and for freeze or hold charging. With it off, Predbat leaves the Battery Reserve SOC as you set it and plans against it +- **Manual configuration** (`solis_automatic: false`): bind `reserve` to `number.predbat_solis__reserve_soc`, not the over-discharge SOC. Predbat warns at startup if `reserve` still points at `..._over_discharge_soc`, as it would otherwise raise that safety floor to hold the battery +- **Freeze charging**: inside a charge slot with the battery already at the slot's target SOC, Predbat sets the storage mode to `Self-Use - No Grid Charging`, so the slot holds the battery without importing from the grid while solar can still charge it. It keeps the hold through a dip of 1% below the target before charging again. On V2 firmware this mode has the same register value as `Self-Use - No Timed Charge/Discharge`, which is how the storage mode select shows it - Automatic configuration available - sets up all required Predbat sensors automatically - **PV-only inverters**: an inverter Solis Cloud reports as having no battery is never managed as a battery inverter and is never written to, but its generation is still included in `pv_today` and `pv_power` so the array total covers the whole roof. Its load and grid readings are left out, as those registers can overlap with the battery inverter's on a shared-CT installation - set `pv_today`/`pv_power`/`load_today` manually with `solis_cloud_pv_load_ignore: true` if you need something different - **Inverter timezone must match Predbat's `timezone` setting**: charge/discharge slot times are written to the inverter as plain `HH:MM` values with no timezone attached. The inverter interprets these using its own configured timezone, not Predbat's. If your inverter's timezone is set to UTC (or anything other than Predbat's `timezone`, `Europe/London` by default), the resulting charge/discharge windows will be offset by the difference - for example, a full hour out whenever British Summer Time is in effect. Set the inverter's own timezone to match Predbat's `timezone` setting to avoid this. diff --git a/tools/debug-journal.md b/tools/debug-journal.md index 86211f6c6..571b6b47d 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -101,7 +101,7 @@ Grep for the named symbol rather than trusting a line number. | Area | What past debugging found | Targeted test | |------|---------------------------|---------------| | Fox (`fox.py`) | The cloud API returns errno 42015/44096 for settings a given device does not support (`FOX_SETTINGS_UNSUPPORTED_ERRNO`); those are marked unavailable and never polled or written again. Entity type matters — WorkMode is a select, ExportLimit a number. Two later capacity/schedule traps, both still live: `publish_data()` sums every `batteryList` entry unconditionally (`fox.py:1779-1781`), and an AIO ESS returns one physical pack as four `bmu` entries all carrying the inverter's own `batterySN`, so `soc_max` comes out at 4x the correct `batteryDesignCapacity` and `soc_kw` with it (GH#4919, read out of the reporter's own API response). `fox_automatic: true` re-`set_arg`s `soc_max` every cycle and `soc_max` is not in `CONFIG_API_OVERRIDE`, so apps.yaml cannot override it - the escape hatch is `fox_automatic: false`. With the Mode Scheduler deleted the scheduler `enable` flag reads 0 and `compute_schedule()` (`fox.py:1080`) derives the displayed charge window from the legacy `forceChargeTime` read, which Predbat cannot clear: `set_battery_charging_time()` (`fox.py:1009`), the only writer of that endpoint, has zero production callers - re-verified on main, where it is referenced only by `test_fox_api.py` (GH#4939). Note this is the *residual* half of that issue: the blocker the reporter actually hit was HA event routing filtering on the literal string `predbat` rather than the configured entity prefix, **fixed in PR #4962**. Unlike the self-referential HA verify trap below, a Fox `didn't complete got X` is a genuine failure - the read-back polls entities the component republishes from Fox Cloud. A separate trap on the **HA/modbus path** (inverter type `FoxESS`, not `FoxCloud`): the stock template binds `reserve:` to `number.foxess_min_soc_on_grid` (`templates/fox.yaml`), and that file's own mode table ties both Freeze charging and Hold charging to that entity. A reporter had `reserve:` bound to `number.foxess_inverter_min_soc` instead - a name that appears nowhere in `templates/` or `docs/` - so Predbat never read or wrote `min_soc_on_grid`; when the inverter stranded it at 100 (observed sitting there six days) Predbat could neither see it nor clear it, and `min_soc_on_grid` at 100% stops the battery discharging while grid-connected. `FoxESS` is `has_idle_time: False` (`config.py`), so the `idle time is ...` line is bookkeeping only and never programs a demand period - discharge outside a charge window depends entirely on the inverter's own settings, which is what makes a wrong reserve binding silent (GH#4961). FoxCloud freeze export (GH#5015, **fixed in PR #5038**, v9.0.2 - keep the mechanism for pre-v9.0.2 logs): `support_feedin_first: True` makes `prediction.py`'s freeze branch model freeze export as genuine Feed-in-First — load exports up to the limit, only the surplus beyond it charges the battery — but the cloud path used to never select the `Feedin` work mode: `apply_battery_schedule()` (`fox.py`) only emitted `SelfUse`/`ForceCharge`/`ForceDischarge` groups and `adjust_inverter_mode()` hardcodes `SelfUse` for Fox (`inverter.py`), so the inverter sat in SelfUse where surplus PV charges the battery *before* exporting and the plan's freeze-export revenue didn't match the hardware. #5038 makes `apply_battery_schedule()` select `Feedin` as the baseline work mode whenever freeze export is requested; note the cloud API's verify read is slow there — a write can log success, read back pre-write 3s later and read back the new mode after ~18s (recorded in a `fox.py` comment), so a quick verify read is not evidence of failure. `support_feedin_first` therefore means two different things depending on connection method: the modelling is correct on the FoxESS HA/modbus path, where `discharge_freeze_service` genuinely selects Feed-in First (#4207, fixed by #4425), while #4582 extended the flag to the cloud defs with no equivalent execution path. Log check: a freeze-export day shows hundreds of full-day `SelfUse` groups in `Fox: New schedule` lines and never a `ForceDischarge` or `Feedin` group — and `ForceDischarge` appearing in a `Fetch scheduler V1 returned` enum list is **not** evidence a discharge window was written. Workaround: turn `set_export_freeze` off so the planner stops selecting freeze-export slots. #4182 is the neighbouring open issue. | `fox_api`, `fox_oauth` | -| Solis (`solis.py`) | `SOLIS_CID_STORAGE_MODE = 636` is Modbus 43110, a bit mask. On firmware "4B and above" (what `is_tou_v2_mode()` detects: CID 6798 reads 43605) the timed charge/discharge enable moved to the per-slot registers — `SOLIS_CID_CHARGE_ENABLE_BASE`/`..._DISCHARGE_ENABLE_BASE`, Modbus 43707, six slots not three — and every mode value carrying TOU bit 1 (3/35/43/51/98) was dropped; 35 became 33, 98 became 96. Such an inverter answers a CID 636 write with code 0 and reads back without bit 1, so a log full of CID 636 verification warnings is a refused bit, not a failed write (GH#4707). `set_storage_mode_if_needed()` decides the bit from `is_tou_v2_mode()`: never asked for on V2, always asked for and retried on V1, where bit 1 still *is* the timed charge/discharge enable. Do not try to learn it from the read-back instead — GH#4710 did, and GH#4774 showed why that cannot work: on six inverters the same write of 179 verifies minutes before and minutes after the one that reads back 177, so a single post-write read is not evidence of a firmware property. Latching it was also silent rather than noisy: the verify read refreshes `cached_values`, so after a stripped write the cache held the stripped value, the suppressed computed value matched it, and `set_storage_mode_if_needed()` stopped writing CID 636 at all for the whole 8-hour verdict — across an overnight charge window. GH#4239's "only retained while a window is configured" is not the whole story — it was refused with slot 1 enabled and its window in force. `read_and_write_cid()` re-reads once after `verify_settle_seconds` before calling a mismatch a failure, because the immediate verify read is taken about half a second after the write. On the SolisCloud select path specifically, **PR #5247 (merged 2026-09-26) makes a discovered inverter's slot-time writes run their handler immediately** so Predbat's read-back sees them in the same cycle — pre-#5247 the select callback only queued the event (GH#4875) and the queue drained at the top of `run()` up to a minute later, so the write verified against the stale value and logged a spurious `didn't complete` once per moved slot time (pass 1, V1 and the slot SoC/current writes keep their every-minute retry). Do not "stop the failed verify poisoning the cache" — `write_cid()` caches the value it *requested* and the post-write `read_cid()` deliberately overwrites it with what the inverter actually reports, so the cache mirrors the inverter rather than Predbat's intent; skipping that update would leave the cache agreeing with the write that just failed, and change detection would then never retry it. The two control paths choose the storage mode differently and it matters: V1 picks from `in_charge_slot`/`in_discharge_slot`, which are clock tests, so CID 636 changes value and gets written the moment a window opens; V2 picks from `slot1_active`, which is only "slot 1 has a window configured", so the mode is written when the slot is *programmed* and nothing at all happens at the window boundary. On the GH#4774 night the last CID 636 write was 22 minutes before the window opened, and the comparison night that worked had one near the window - so `claim_window_mode_assertion()` now asserts the mode once per window on both paths. Slot registers are polled hourly, plus every 5 minutes while `is_inside_active_window()` is true, so drift during the window that matters is caught in the same cycle it happens. GH#4774 also left a useful negative result: that inverter grid-charged from 20% to 91% overnight with bit 1 clear throughout, confirming the per-slot enables alone drive timed charging on V2 — so a "charge window never started" report on V2 firmware is not a CID 636 problem and needs looking at elsewhere. Separately, an inverter reporting `batteryType 'No Battery'` stays in `self.inverter_sn` for polling; `is_battery_inverter()` keeps control writes off it, which was most of the warning volume in GH#4707. That explanation doesn't cover every CID 636 report, though: a later GH#4707 comment showed zero `setting storage mode to` idle-mode log lines, and both V1 mode-decision idle paths always log that line — so a report with neither line cannot be coming from `write_time_windows_if_changed()` at all. The only remaining write path is the HA select handler, `set_storage_mode_value()` at `solis.py:2574`. Check which write path actually ran before assuming this entry's explanation applies. Separately, on inverters using `H M` time format (`GS_fb00`, most cloud subtypes) `adjust_force_export()` used to re-commit a stable export window every cycle — `is_hm_format` alone made `changed_start_end` true, so `press_and_poll_button()` fired on every cycle of an unchanged export window (GH#4709, confirmed live on main at the time) — and PR #4713 fixed the twin idle-cycle press where the times being managed came back as `None` and never compared equal to what the inverter still reported (GH#4712, #2328). **Both merged together in PR #4711 (`bc853a0e`, 2026-09-12, post-v9.0.2)**: `adjust_force_export()` re-commits only on a real schedule change through the commit-once ledger (`last_committed`/`commit_pending`, `commit_needed()`/`record_commit()` in `inverter.py` — the attribute this entry used to name, `last_export_schedule_committed`, is gone), and a commit is only recorded when the writes *and* the button press both succeeded; **the guard only became real when PR #5126 (merged 2026-09-26, unreleased at the time of writing) stopped rebuilding Inverter objects every cycle** — pre-#5126 the rebuild reset the latch to empty each cycle, so the guard was decorative and the every-cycle export commit (the #2328 ~288 TOU-register-write-batches/day signature) stayed live; keep that reading for pre-#5126 logs, where counting `Successfully pressed button` lines settles the actual volume. (The every-cycle `H M` time-entity rewrite remains, deliberately — #1529 write reliability); `press_and_poll_button()` takes a `side` argument so a split-button config no longer presses the unrelated side's button (which is what cleared `timed_charge_current` alongside the intended one); and `adjust_inverter_mode()` now sleeps 30s after a real window change as a GivTCP settle workaround, so expect a 30s pause per real window change on those types. Keep the mechanism for pre-merge logs, where the signature is a button press logged every 5 minutes inside a window. Away from the CID work, `automatic_config()` has two arg-binding defects. It binds `battery_rate_max` - which feeds *both* the charge and the discharge cap - to the per-device `max_charge_power` entity (`solis.py:1516`), while the `max_discharge_power` entity it also creates is bound to nothing; `inverter.py:429`'s `min(inverter_limit_discharge, battery_rate_max_raw)` then caps discharge at the charge limit on an asymmetric inverter, and `inverter_limit_discharge` cannot lift it because the raw value is the binding constraint (GH#4940, confirmed against the reporter's debug yaml, still live). Auto-discovery also always beats a manual apps.yaml value here (`component_base.py:96-110`). Separately, on Solis Cloud/TOU-V2 the per-slot current write is clamped by `write_time_windows()` (`solis.py`, `new_current = min(slot_current, max_discharge_current_amps)`), where the cap is the *minimum* `sysCommand.max` metadata across **all six** slot CIDs from `cached_infos` — deliberately, because a slot 1 value above the others is not accepted. On GH#5068 that bound slot 1 at 60A against a ~93A plan, and the trap is that the number entity is republished from the local `charge_discharge_time_windows` cache, so the write verify reads back the *clamped* value converted at the **live** battery voltage (60A × ~51.6V ≈ 3094W, not 48V) and logs `didn't complete got 3094.0` every cycle — while a "Wrote N successfully now N" line can also be self-referential (cached 60 == clamped 60 skips the CID write entirely). Grep `CID 5967 ... is set to` to see what actually went to the inverter. The planner models `battery_rate_max_discharge` from apps.yaml/entity max and knows nothing of the clamp, so the plan is systematically optimistic while it binds; workaround is `inverter_limit_discharge` at 60A × pack voltage. #4220 is the same 60A clamp from the user's angle. Separately it built every arg list - PV, load and grid included - from the battery-filtered `devices` list (`solis.py:1456`, narrowed by `cd6e7c79`), dropping a PV-only inverter's generation from `pv_today`/`pv_power`; the PV half was fixed in PR #4923, load/grid deliberately left battery-only because those registers can overlap on a shared-CT install. One more shape worth remembering: the entity-event queue used to drain at the top of `run()`, before the `first` block created the `ClientSession` and discovered inverters, so an event queued during startup executed against `session=None` with an empty `inverter_sn` - and was popped before execution, making it a silently lost write (fixed in `9fe1f7e0`). Three September fixes and one still-live trap. **PR #5089 (merged 2026-09-14)** closed the SolisCloud API-allowance family (GH#5087/#5091): `B0115` ("Datalogger offline or disconnected") and `R0000` ("Daily API request allowance exhausted") are now classified in `SOLIS_API_CODES` and excluded from retry (`SOLIS_API_CODES_NO_RETRY`) - retrying cannot change the answer and every attempt still spends one of the 200 daily requests - with a per-inverter datalogger-offline cooldown (`datalogger_offline()`) so one offline datalogger backs off alone rather than the fleet; and `automatic_config()` is no longer first-cycle-only: `run()` retries it every cycle until it returns True (`automatic_config_done`), so an API outage at startup no longer leaves `load_today` unset until a Predbat restart (the ValueError-at-fetch symptom this produced has its own row in the symptom table). Keep the mechanism for pre-#5089 logs: B0115 was treated as rate limiting (10s sleep + retry, the `ad26f95a` "Quick rate limit hack") and any R-series code fell through to `Unknown code` with the full retry ladder - an offline datalogger drained the 200/day allowance and R0000 arrived hours later. `automatic_config()` makes no API calls of its own - it reads only `self.inverter_details` - and returns False when details are still missing, which is the retryable case. **PR #5095 (merged 2026-09-15)** fixed the amp↔watt conversion voltage (GH#5090): `get_nominal_voltage()` now prefers `solis_nominal_voltage` from apps.yaml (the only stated physical property), then a live reading above `SOLIS_HV_BATTERY_VOLTAGE` (HV packs keep the live voltage as they have since #4493), then BMS charge voltage classification (16S at/above 55V charge voltage, 15S below - settings rather than measurements, so the result holds across polls), then the 48V fallback; the live voltage had swung 10-14% over a month on every sampled system, dragging `battery_rate_max`, the write tolerance computed from it and the read-back of every rate setpoint along. No workaround existed pre-#5095 - `solis_nominal_voltage` fed only the capacity path (`get_capacity_voltage()`), so telling a user to set it fixed capacity but not the rate paths. Diagnostic tell for pre-#5095 logs: failed-write readbacks at a constant ratio of the target (e.g. exactly 66/70) are consistent with write-time vs read-time voltage difference alone. **Still live (GH#5093):** `SolisAPI.run()` folds per-inverter failures into one `poll_success` bool - discovery failure, any inverter's detail fetch, TOU window reads, the in-window re-read - and `ComponentBase.start()` gates startup on that return: False → `api_started` never set, and the startup burst (discovery, details, `startup_reset_registers()`, the infrequent poll) re-runs on a doubling backoff from 60s to a 128-minute cap, so a multi-inverter fleet is held hostage to the least reachable device and every retry re-spends quota. Per-inverter success booleans already exist at every `poll_success` site, so the fix is contained to `run()`; the interim workaround is listing only working SNs in `solis_inverter_sn`. PR #5185 (merged 2026-09-23) also backs off `startup_reset_registers()` per-inverter with a persistent B0600 pause, and a refused startup register read no longer aborts startup — the try/except now sitting at the `run()` call site covers both triggers, the #5177 B0600 refusal and the SolisCloud timeout (GH#5202, closed as its dup: the traceback matched main line-for-line, the same `SolisAPIError` escaping the same call). Three Modbus-vs-component bindings and a mode-domain split (GH#5127/#5128/#5129, triaged 2026-09-17). On `charge_time_format: "H M"` types (`GS_fb00`, most cloud subtypes) `adjust_charge_window()` rewrites the charge start/end time entities on every 5-minute cycle of a programmed window (the format check in its write condition, inverter.py) - deliberately, the same #1529 write-reliability workaround the export side has - so "Predbat hammers my Solis with writes" is this first, not SoC-hold micromanagement: `adjust_battery_target()` is change-gated (logs `already at target` and writes nothing when equal) and the hold path's `disable_charge_window()` writes nothing unless the enable switch was on. The Modbus-path types (`GS`, `GS_fb00`, `SX4`, Sofar) have **no Predbat component at all** - every control arg is bound only by the hand-edited template; `create_missing_arg()` only ever *dummies* absent capabilities (a dummy `charge_limit` 100 when `has_target_soc` is False), so no INVERTER_DEF default binds a real entity, and a GS_fb00 user who misses the template's `charge_limit` uncomment gets a silent 100.0 target plus `No entity_id for charge_limit to write N` on every target change while the inverter keeps charging to its own default (GH#5129 - the signature is the repeated `No entity_id` line, not a startup error). On the Solis Cloud component the opposite applies: `automatic_config()` binds `charge_limit` with `set_arg_auto(..., overwrite=True)` (solis.py), so an apps.yaml `charge_limit:` pointing at the inverter's own timed-charge-SoC entity is replaced and logged `auto-discovery wins for this setting` - the first thing to grep a reporter's log for when #2328's guidance "didn't help" (GH#5127). Separately, Solis has **two mode-switch control paths whose mode domains do not overlap** (GH#5128): the HA `solax_modbus` integration (`inverter_type: "GS"`) writes a mode *name* from `SOLAX_SOLIS_MODES`/`SOLAX_SOLIS_MODES_NEW` through `alt_charge_discharge_enable()` and only ever computes 33/35 - the 64/96/98 "Feed-in priority" entries are dead in that table - while the SolisCloud API path above does use feed-in priority in its idle paths; so a "why doesn't Predbat use feed-in priority" report is GH#5128's gap on modbus and already-covered on the API component. Trap for any fix there: 33/35 mean different things in the two tables (`SOLAX_SOLIS_MODES` vs `_NEW`, the `solax_modbus_new` switch), so writes must go through the table-derived name, never a bare number; the reusable pattern is `support_feedin_first` (PR #5038, Fox). Check `fox.py`/`gecloud.py` for the same fold-everything-into-one-bool shape - GE Cloud still runs its own auto-config under `if first:` only, with no retry, so its self-heal after an outage is the pre-#5089-era slow one. **GH#5187, fixed in PR #5189 (merged 2026-09-24) - keep the mechanism for pre-#5189 logs.** Slot currents are now capped at the inverter's rated power (from the `inverter_size` sensor, logged `Capping slot currents on ... at the most the inverter can deliver at its rated power`); pre-#5189 the bound was only CID 7226 × count and the per-slot `sysCommand.max` metadata, so a 3.6 kW AC inverter reading 100 A at CID 7226 refused every 100 A slot-current write and left the slot enabled at 0 A, while the planner side (auto-config binds `battery_rate_max` to `max_charge_power`, GH#4940, and sets no `inverter_limit_discharge`) never saw the AC rating — `inverter_limit_discharge: 3600` remains the plan-side workaround where the modelling matters. Implausible recovery SoC (CID 7229) no longer defeats the #4706 clamp: 0/1/≤over-discharge/>100 now falls back to `over_discharge_soc + 1` with a log line; pre-#5189 a 65521 read passed the guard and made Predbat attempt to write the recovery register itself down every cycle. **Still live:** the write-skip on a never-read slot current CID — `cached_values.get(cid, cap)` defaults to the cap, so a register the poll has never returned reads as "no change" and **no write is attempted at all** (`solis.py`, both charge and discharge paths); if a fixture shows no write of the slot CID, check whether the fixture seeded it in `cached_values` before assuming a bug. Test notes from #5177: `test_solis.py`'s first-cycle tests mock `startup_reset_registers` via instance attribute (`del api.startup_reset_registers` restores the real method); a canned `api.session` set before `run(0, True)` reaches the real request path; and do not patch `asyncio.sleep` to speed up a retried error — `_with_retry` budgets with `time.monotonic()`, so a no-op sleep just burns the real ~30 s budget across ~8 attempts. The hold lever on Solis modbus is window-scoped (GH#5152, code-verified): the only discharge-suppression write is `set_current_from_power()`'s `timed_{charge,discharge}_current` (re-asserted on every rate adjustment, #4415); the standing `battery_discharge_current` registers are never driven anywhere in the repo, and GS makes the timed write the *only* lever (`has_reserve_soc: False`, `has_timed_pause: False`) — so a hold's 0A binds only inside the timed slot, and a "hold/freeze doesn't stop discharge" report on Solis-modbus points at GH#5152 before any plan logic: the execute-side hold gates fire correctly, the lever they pull is what is window-scoped. (The "only binds during the slot" firmware semantics is read off the register names in the solax_modbus integration, not yet live-log confirmed.) Same firmware on the **solax_modbus** path: `inverter_type: GS` with the integration's plain "Solis" plugin on V2/FB00 firmware shows as `Setting Solis Energy Control Switch to 35 Self-Use from 33 Self-Use - No Timed Charge/Discharge` every cycle, the write "succeeds", and the next read is 33 again — a `Control interference: N change(s) in the last 24h, sustained on [...energy_storage_control_switch]` count in the hundreds. Charging still worked (SoC 28→100% overnight) but exports never ran: the discharge slot's times were written but its per-slot enable (43707) stayed 0, which the `solis.py` V2 time-window decode showed as `discharge_enable: 0`. Not a code bug — the user needs the solax_modbus "Solis FB00" plugin (per-slot enable switches, no mode 35) and `inverter_type: GS_fb00` via `templates/ginlong_solis_fb00.yaml`; confirmed fixed on a live system on 26 Sep 2026. Adding Solis Cloud credentials *without* `solis_automatic: True` on such a system leaves inverter 0 bound as GS while `solis.py` still runs its slot/mode logic with no plan — it logs `no active slot`, clears discharge slot 1 and sets 'Self-Use - No Timed Charge/Discharge', fighting the modbus path. **GH#5190 (verified on main): PEP 515 underscores defeat every numeric-parse guard.** Python ≥3.6 `int()`/`float()` accept `_` digit separators, so a junk register string such as `"1234567890_00"` (datalogger off the remote-control platform; polls fail with `B0063 "Lack of iot platform three elements"`) parses as `123456789000` with no exception — `parse_cid_int()`, `cid_value_matches()` and the raw `int(...)`/`float(...)` coercion all accept it, so every `try/except ValueError` fallback silently passes. Consequences seen in the reporter's config: `reserve`/`battery_min_soc` auto-bound to the junk percent (inverter.py raises `set_reserve_min` to it), CID 636 never converging (every-minute write + failed-verify repeat), and no `B0063` handling anywhere in `solis.py`. The same gap is in the core `get_arg()` float coercion (`userinterface.py`), so it generalises past Solis: start there on any "value reads as an absurdly huge number / the fallback never fires" report. Fix shape per the issue: one strict-parse helper (plain decimal only), range checks, publish `None` for unparseable reads, never write CID 636 from an unreadable current mode. Holds and freeze (PR after #5258): `reserve` is now bound to the **Battery Reserve SOC** (CID 157, `reserve_soc`) and the row declares `has_reserve_soc` True - `battery_min_soc` stays the over-discharge SOC (CID 158). Solis only treats the Battery Reserve SOC as a discharge floor while CID 636 bit 4 (`SOLIS_BIT_BACKUP_MODE`) is on, so `set_storage_mode_if_needed()` sets that bit while `set_reserve_enable` is on (and leaves it alone otherwise - turning it on unmanaged would enforce whatever reserve SOC the inverter already held); a hold outside a slot needs no extra grid-charging logic because outside slots the component already writes a No-Grid-Charging mode. Inside an open charge slot with the battery at or above the slot's SOC (`is_holding_in_charge_slot()` - a freeze charge, or a charge that reached its target) it writes `Self-Use - No Grid Charging` (`ENUM_SELF_USE_HOLD`: 3 on V1 where bit 1 is the slot enable, 1 on V2) ahead of the 0A -> Feed-in rule, which on V1 used to select Feed-in priority - exporting PV - during a freeze charge. | `solis` | +| Solis (`solis.py`) | `SOLIS_CID_STORAGE_MODE = 636` is Modbus 43110, a bit mask. On firmware "4B and above" (what `is_tou_v2_mode()` detects: CID 6798 reads 43605) the timed charge/discharge enable moved to the per-slot registers — `SOLIS_CID_CHARGE_ENABLE_BASE`/`..._DISCHARGE_ENABLE_BASE`, Modbus 43707, six slots not three — and every mode value carrying TOU bit 1 (3/35/43/51/98) was dropped; 35 became 33, 98 became 96. Such an inverter answers a CID 636 write with code 0 and reads back without bit 1, so a log full of CID 636 verification warnings is a refused bit, not a failed write (GH#4707). `set_storage_mode_if_needed()` decides the bit from `is_tou_v2_mode()`: never asked for on V2, always asked for and retried on V1, where bit 1 still *is* the timed charge/discharge enable. Do not try to learn it from the read-back instead — GH#4710 did, and GH#4774 showed why that cannot work: on six inverters the same write of 179 verifies minutes before and minutes after the one that reads back 177, so a single post-write read is not evidence of a firmware property. Latching it was also silent rather than noisy: the verify read refreshes `cached_values`, so after a stripped write the cache held the stripped value, the suppressed computed value matched it, and `set_storage_mode_if_needed()` stopped writing CID 636 at all for the whole 8-hour verdict — across an overnight charge window. GH#4239's "only retained while a window is configured" is not the whole story — it was refused with slot 1 enabled and its window in force. `read_and_write_cid()` re-reads once after `verify_settle_seconds` before calling a mismatch a failure, because the immediate verify read is taken about half a second after the write. On the SolisCloud select path specifically, **PR #5247 (merged 2026-09-26) makes a discovered inverter's slot-time writes run their handler immediately** so Predbat's read-back sees them in the same cycle — pre-#5247 the select callback only queued the event (GH#4875) and the queue drained at the top of `run()` up to a minute later, so the write verified against the stale value and logged a spurious `didn't complete` once per moved slot time (pass 1, V1 and the slot SoC/current writes keep their every-minute retry). Do not "stop the failed verify poisoning the cache" — `write_cid()` caches the value it *requested* and the post-write `read_cid()` deliberately overwrites it with what the inverter actually reports, so the cache mirrors the inverter rather than Predbat's intent; skipping that update would leave the cache agreeing with the write that just failed, and change detection would then never retry it. The two control paths choose the storage mode differently and it matters: V1 picks from `in_charge_slot`/`in_discharge_slot`, which are clock tests, so CID 636 changes value and gets written the moment a window opens; V2 picks from `slot1_active`, which is only "slot 1 has a window configured", so the mode is written when the slot is *programmed* and nothing at all happens at the window boundary. On the GH#4774 night the last CID 636 write was 22 minutes before the window opened, and the comparison night that worked had one near the window - so `claim_window_mode_assertion()` now asserts the mode once per window on both paths. Slot registers are polled hourly, plus every 5 minutes while `is_inside_active_window()` is true, so drift during the window that matters is caught in the same cycle it happens. GH#4774 also left a useful negative result: that inverter grid-charged from 20% to 91% overnight with bit 1 clear throughout, confirming the per-slot enables alone drive timed charging on V2 — so a "charge window never started" report on V2 firmware is not a CID 636 problem and needs looking at elsewhere. Separately, an inverter reporting `batteryType 'No Battery'` stays in `self.inverter_sn` for polling; `is_battery_inverter()` keeps control writes off it, which was most of the warning volume in GH#4707. That explanation doesn't cover every CID 636 report, though: a later GH#4707 comment showed zero `setting storage mode to` idle-mode log lines, and both V1 mode-decision idle paths always log that line — so a report with neither line cannot be coming from `write_time_windows_if_changed()` at all. The only remaining write path is the HA select handler, `set_storage_mode_value()` at `solis.py:2574`. Check which write path actually ran before assuming this entry's explanation applies. Separately, on inverters using `H M` time format (`GS_fb00`, most cloud subtypes) `adjust_force_export()` used to re-commit a stable export window every cycle — `is_hm_format` alone made `changed_start_end` true, so `press_and_poll_button()` fired on every cycle of an unchanged export window (GH#4709, confirmed live on main at the time) — and PR #4713 fixed the twin idle-cycle press where the times being managed came back as `None` and never compared equal to what the inverter still reported (GH#4712, #2328). **Both merged together in PR #4711 (`bc853a0e`, 2026-09-12, post-v9.0.2)**: `adjust_force_export()` re-commits only on a real schedule change through the commit-once ledger (`last_committed`/`commit_pending`, `commit_needed()`/`record_commit()` in `inverter.py` — the attribute this entry used to name, `last_export_schedule_committed`, is gone), and a commit is only recorded when the writes *and* the button press both succeeded; **the guard only became real when PR #5126 (merged 2026-09-26, unreleased at the time of writing) stopped rebuilding Inverter objects every cycle** — pre-#5126 the rebuild reset the latch to empty each cycle, so the guard was decorative and the every-cycle export commit (the #2328 ~288 TOU-register-write-batches/day signature) stayed live; keep that reading for pre-#5126 logs, where counting `Successfully pressed button` lines settles the actual volume. (The every-cycle `H M` time-entity rewrite remains, deliberately — #1529 write reliability); `press_and_poll_button()` takes a `side` argument so a split-button config no longer presses the unrelated side's button (which is what cleared `timed_charge_current` alongside the intended one); and `adjust_inverter_mode()` now sleeps 30s after a real window change as a GivTCP settle workaround, so expect a 30s pause per real window change on those types. Keep the mechanism for pre-merge logs, where the signature is a button press logged every 5 minutes inside a window. Away from the CID work, `automatic_config()` has two arg-binding defects. It binds `battery_rate_max` - which feeds *both* the charge and the discharge cap - to the per-device `max_charge_power` entity (`solis.py:1516`), while the `max_discharge_power` entity it also creates is bound to nothing; `inverter.py:429`'s `min(inverter_limit_discharge, battery_rate_max_raw)` then caps discharge at the charge limit on an asymmetric inverter, and `inverter_limit_discharge` cannot lift it because the raw value is the binding constraint (GH#4940, confirmed against the reporter's debug yaml, still live). Auto-discovery also always beats a manual apps.yaml value here (`component_base.py:96-110`). Separately, on Solis Cloud/TOU-V2 the per-slot current write is clamped by `write_time_windows()` (`solis.py`, `new_current = min(slot_current, max_discharge_current_amps)`), where the cap is the *minimum* `sysCommand.max` metadata across **all six** slot CIDs from `cached_infos` — deliberately, because a slot 1 value above the others is not accepted. On GH#5068 that bound slot 1 at 60A against a ~93A plan, and the trap is that the number entity is republished from the local `charge_discharge_time_windows` cache, so the write verify reads back the *clamped* value converted at the **live** battery voltage (60A × ~51.6V ≈ 3094W, not 48V) and logs `didn't complete got 3094.0` every cycle — while a "Wrote N successfully now N" line can also be self-referential (cached 60 == clamped 60 skips the CID write entirely). Grep `CID 5967 ... is set to` to see what actually went to the inverter. The planner models `battery_rate_max_discharge` from apps.yaml/entity max and knows nothing of the clamp, so the plan is systematically optimistic while it binds; workaround is `inverter_limit_discharge` at 60A × pack voltage. #4220 is the same 60A clamp from the user's angle. Separately it built every arg list - PV, load and grid included - from the battery-filtered `devices` list (`solis.py:1456`, narrowed by `cd6e7c79`), dropping a PV-only inverter's generation from `pv_today`/`pv_power`; the PV half was fixed in PR #4923, load/grid deliberately left battery-only because those registers can overlap on a shared-CT install. One more shape worth remembering: the entity-event queue used to drain at the top of `run()`, before the `first` block created the `ClientSession` and discovered inverters, so an event queued during startup executed against `session=None` with an empty `inverter_sn` - and was popped before execution, making it a silently lost write (fixed in `9fe1f7e0`). Three September fixes and one still-live trap. **PR #5089 (merged 2026-09-14)** closed the SolisCloud API-allowance family (GH#5087/#5091): `B0115` ("Datalogger offline or disconnected") and `R0000` ("Daily API request allowance exhausted") are now classified in `SOLIS_API_CODES` and excluded from retry (`SOLIS_API_CODES_NO_RETRY`) - retrying cannot change the answer and every attempt still spends one of the 200 daily requests - with a per-inverter datalogger-offline cooldown (`datalogger_offline()`) so one offline datalogger backs off alone rather than the fleet; and `automatic_config()` is no longer first-cycle-only: `run()` retries it every cycle until it returns True (`automatic_config_done`), so an API outage at startup no longer leaves `load_today` unset until a Predbat restart (the ValueError-at-fetch symptom this produced has its own row in the symptom table). Keep the mechanism for pre-#5089 logs: B0115 was treated as rate limiting (10s sleep + retry, the `ad26f95a` "Quick rate limit hack") and any R-series code fell through to `Unknown code` with the full retry ladder - an offline datalogger drained the 200/day allowance and R0000 arrived hours later. `automatic_config()` makes no API calls of its own - it reads only `self.inverter_details` - and returns False when details are still missing, which is the retryable case. **PR #5095 (merged 2026-09-15)** fixed the amp↔watt conversion voltage (GH#5090): `get_nominal_voltage()` now prefers `solis_nominal_voltage` from apps.yaml (the only stated physical property), then a live reading above `SOLIS_HV_BATTERY_VOLTAGE` (HV packs keep the live voltage as they have since #4493), then BMS charge voltage classification (16S at/above 55V charge voltage, 15S below - settings rather than measurements, so the result holds across polls), then the 48V fallback; the live voltage had swung 10-14% over a month on every sampled system, dragging `battery_rate_max`, the write tolerance computed from it and the read-back of every rate setpoint along. No workaround existed pre-#5095 - `solis_nominal_voltage` fed only the capacity path (`get_capacity_voltage()`), so telling a user to set it fixed capacity but not the rate paths. Diagnostic tell for pre-#5095 logs: failed-write readbacks at a constant ratio of the target (e.g. exactly 66/70) are consistent with write-time vs read-time voltage difference alone. **Still live (GH#5093):** `SolisAPI.run()` folds per-inverter failures into one `poll_success` bool - discovery failure, any inverter's detail fetch, TOU window reads, the in-window re-read - and `ComponentBase.start()` gates startup on that return: False → `api_started` never set, and the startup burst (discovery, details, `startup_reset_registers()`, the infrequent poll) re-runs on a doubling backoff from 60s to a 128-minute cap, so a multi-inverter fleet is held hostage to the least reachable device and every retry re-spends quota. Per-inverter success booleans already exist at every `poll_success` site, so the fix is contained to `run()`; the interim workaround is listing only working SNs in `solis_inverter_sn`. PR #5185 (merged 2026-09-23) also backs off `startup_reset_registers()` per-inverter with a persistent B0600 pause, and a refused startup register read no longer aborts startup — the try/except now sitting at the `run()` call site covers both triggers, the #5177 B0600 refusal and the SolisCloud timeout (GH#5202, closed as its dup: the traceback matched main line-for-line, the same `SolisAPIError` escaping the same call). Three Modbus-vs-component bindings and a mode-domain split (GH#5127/#5128/#5129, triaged 2026-09-17). On `charge_time_format: "H M"` types (`GS_fb00`, most cloud subtypes) `adjust_charge_window()` rewrites the charge start/end time entities on every 5-minute cycle of a programmed window (the format check in its write condition, inverter.py) - deliberately, the same #1529 write-reliability workaround the export side has - so "Predbat hammers my Solis with writes" is this first, not SoC-hold micromanagement: `adjust_battery_target()` is change-gated (logs `already at target` and writes nothing when equal) and the hold path's `disable_charge_window()` writes nothing unless the enable switch was on. The Modbus-path types (`GS`, `GS_fb00`, `SX4`, Sofar) have **no Predbat component at all** - every control arg is bound only by the hand-edited template; `create_missing_arg()` only ever *dummies* absent capabilities (a dummy `charge_limit` 100 when `has_target_soc` is False), so no INVERTER_DEF default binds a real entity, and a GS_fb00 user who misses the template's `charge_limit` uncomment gets a silent 100.0 target plus `No entity_id for charge_limit to write N` on every target change while the inverter keeps charging to its own default (GH#5129 - the signature is the repeated `No entity_id` line, not a startup error). On the Solis Cloud component the opposite applies: `automatic_config()` binds `charge_limit` with `set_arg_auto(..., overwrite=True)` (solis.py), so an apps.yaml `charge_limit:` pointing at the inverter's own timed-charge-SoC entity is replaced and logged `auto-discovery wins for this setting` - the first thing to grep a reporter's log for when #2328's guidance "didn't help" (GH#5127). Separately, Solis has **two mode-switch control paths whose mode domains do not overlap** (GH#5128): the HA `solax_modbus` integration (`inverter_type: "GS"`) writes a mode *name* from `SOLAX_SOLIS_MODES`/`SOLAX_SOLIS_MODES_NEW` through `alt_charge_discharge_enable()` and only ever computes 33/35 - the 64/96/98 "Feed-in priority" entries are dead in that table - while the SolisCloud API path above does use feed-in priority in its idle paths; so a "why doesn't Predbat use feed-in priority" report is GH#5128's gap on modbus and already-covered on the API component. Trap for any fix there: 33/35 mean different things in the two tables (`SOLAX_SOLIS_MODES` vs `_NEW`, the `solax_modbus_new` switch), so writes must go through the table-derived name, never a bare number; the reusable pattern is `support_feedin_first` (PR #5038, Fox). Check `fox.py`/`gecloud.py` for the same fold-everything-into-one-bool shape - GE Cloud still runs its own auto-config under `if first:` only, with no retry, so its self-heal after an outage is the pre-#5089-era slow one. **GH#5187, fixed in PR #5189 (merged 2026-09-24) - keep the mechanism for pre-#5189 logs.** Slot currents are now capped at the inverter's rated power (from the `inverter_size` sensor, logged `Capping slot currents on ... at the most the inverter can deliver at its rated power`); pre-#5189 the bound was only CID 7226 × count and the per-slot `sysCommand.max` metadata, so a 3.6 kW AC inverter reading 100 A at CID 7226 refused every 100 A slot-current write and left the slot enabled at 0 A, while the planner side (auto-config binds `battery_rate_max` to `max_charge_power`, GH#4940, and sets no `inverter_limit_discharge`) never saw the AC rating — `inverter_limit_discharge: 3600` remains the plan-side workaround where the modelling matters. Implausible recovery SoC (CID 7229) no longer defeats the #4706 clamp: 0/1/≤over-discharge/>100 now falls back to `over_discharge_soc + 1` with a log line; pre-#5189 a 65521 read passed the guard and made Predbat attempt to write the recovery register itself down every cycle. **Still live:** the write-skip on a never-read slot current CID — `cached_values.get(cid, cap)` defaults to the cap, so a register the poll has never returned reads as "no change" and **no write is attempted at all** (`solis.py`, both charge and discharge paths); if a fixture shows no write of the slot CID, check whether the fixture seeded it in `cached_values` before assuming a bug. Test notes from #5177: `test_solis.py`'s first-cycle tests mock `startup_reset_registers` via instance attribute (`del api.startup_reset_registers` restores the real method); a canned `api.session` set before `run(0, True)` reaches the real request path; and do not patch `asyncio.sleep` to speed up a retried error — `_with_retry` budgets with `time.monotonic()`, so a no-op sleep just burns the real ~30 s budget across ~8 attempts. The hold lever on Solis modbus is window-scoped (GH#5152, code-verified): the only discharge-suppression write is `set_current_from_power()`'s `timed_{charge,discharge}_current` (re-asserted on every rate adjustment, #4415); the standing `battery_discharge_current` registers are never driven anywhere in the repo, and GS makes the timed write the *only* lever (`has_reserve_soc: False`, `has_timed_pause: False`) — so a hold's 0A binds only inside the timed slot, and a "hold/freeze doesn't stop discharge" report on Solis-modbus points at GH#5152 before any plan logic: the execute-side hold gates fire correctly, the lever they pull is what is window-scoped. (The "only binds during the slot" firmware semantics is read off the register names in the solax_modbus integration, not yet live-log confirmed.) Same firmware on the **solax_modbus** path: `inverter_type: GS` with the integration's plain "Solis" plugin on V2/FB00 firmware shows as `Setting Solis Energy Control Switch to 35 Self-Use from 33 Self-Use - No Timed Charge/Discharge` every cycle, the write "succeeds", and the next read is 33 again — a `Control interference: N change(s) in the last 24h, sustained on [...energy_storage_control_switch]` count in the hundreds. Charging still worked (SoC 28→100% overnight) but exports never ran: the discharge slot's times were written but its per-slot enable (43707) stayed 0, which the `solis.py` V2 time-window decode showed as `discharge_enable: 0`. Not a code bug — the user needs the solax_modbus "Solis FB00" plugin (per-slot enable switches, no mode 35) and `inverter_type: GS_fb00` via `templates/ginlong_solis_fb00.yaml`; confirmed fixed on a live system on 26 Sep 2026. Adding Solis Cloud credentials *without* `solis_automatic: True` on such a system leaves inverter 0 bound as GS while `solis.py` still runs its slot/mode logic with no plan — it logs `no active slot`, clears discharge slot 1 and sets 'Self-Use - No Timed Charge/Discharge', fighting the modbus path. **GH#5190 (verified on main): PEP 515 underscores defeat every numeric-parse guard.** Python ≥3.6 `int()`/`float()` accept `_` digit separators, so a junk register string such as `"1234567890_00"` (datalogger off the remote-control platform; polls fail with `B0063 "Lack of iot platform three elements"`) parses as `123456789000` with no exception — `parse_cid_int()`, `cid_value_matches()` and the raw `int(...)`/`float(...)` coercion all accept it, so every `try/except ValueError` fallback silently passes. Consequences seen in the reporter's config: `reserve`/`battery_min_soc` auto-bound to the junk percent (inverter.py raises `set_reserve_min` to it), CID 636 never converging (every-minute write + failed-verify repeat), and no `B0063` handling anywhere in `solis.py`. The same gap is in the core `get_arg()` float coercion (`userinterface.py`), so it generalises past Solis: start there on any "value reads as an absurdly huge number / the fallback never fires" report. Fix shape per the issue: one strict-parse helper (plain decimal only), range checks, publish `None` for unparseable reads, never write CID 636 from an unreadable current mode. Holds and freeze (PR #5265): `reserve` is now bound to the **Battery Reserve SOC** (CID 157, `reserve_soc`) and the row declares `has_reserve_soc` True - `battery_min_soc` stays the over-discharge SOC (CID 158). Solis only treats the Battery Reserve SOC as a discharge floor while CID 636 bit 4 (`SOLIS_BIT_BACKUP_MODE`) is on, so `set_storage_mode_if_needed()` always sets that bit: `set_reserve_enable` only decides whether Predbat *changes* the reserve, and with it off `inverter.py` plans against the live reserve, so the inverter must enforce it too (gating the bit on `set_reserve_enable` left the plan flooring at a register the inverter ignored). Reserve writes are shown in the cache and entity at once by `show_soc_limit_now()` and written from the event queue - non-slot events wait for the next `run()`, far beyond Predbat's 2s write verify. Hold detection has a 1% hysteresis (`SOLIS_HOLD_HYSTERESIS`) so the mode does not flip across the target; a hold outside a slot needs no extra grid-charging logic because outside slots the component already writes a No-Grid-Charging mode. Inside an open charge slot with the battery at or above the slot's SOC (`is_holding_in_charge_slot()` - a freeze charge, or a charge that reached its target) it writes `Self-Use - No Grid Charging` (`ENUM_SELF_USE_HOLD`: 3 on V1 where bit 1 is the slot enable, 1 on V2) ahead of the 0A -> Feed-in rule, which on V1 used to select Feed-in priority - exporting PV - during a freeze charge. | `solis` | | Solis Modbus (`GS`, `GS_fb00` in `inverter.py`) | The Energy Storage Control Switch (Modbus 43110, `energy_control_switch`) is driven by `alt_charge_discharge_enable()`, gated on the `has_solis_energy_control` capability. `GS` reaches it through `mimic_target_soc()` and uses the Timed Charge/Discharge bit as the charge enable (35/33). `GS_fb00` has a target SoC, so it never reached `mimic_target_soc()` - until the fix it never wrote the switch at all, and a switch left on `Self-Use - No Grid Charging` (1, bit 5 clear) made every charge slot hold the battery at 0 W while Predbat logged a correct slot, target and rate. For FB00 it is driven through `write_solis_energy_control()` from `adjust_charge_immediate()` on non-exporting cycles - `Backup/Reserve` (49) to charge or idle, `Backup/Reserve - No Grid Charging` (17) for a freeze or a car/iBoost/reserve hold - and from `adjust_export_immediate()` on exporting cycles - `Feed-in priority - No Grid Charging` (64) for a freeze export, `Backup/Reserve` for a real export (PR #5258). The Battery Reserve bit (bit 4) is what makes the template's `reserve` (`backup_mode_soc`, 43024) a discharge floor, so the reserve holds in `execute_plan()` work unchanged; grid charging goes off during holds because the reserve is raised to SoC + 1 and the inverter would import to reach it, again each cycle. `execute_plan()` never calls both immediate methods with a real target in one cycle, and the idle `adjust_export_immediate(100)` deliberately leaves the switch alone; GS still writes from `mimic_target_soc()`. Adding a writer anywhere else (e.g. `adjust_battery_target()`) flips the switch twice per cycle. Older logs: PR #5256 (merged 2026-09-26) held it on `Self-Use` (33) from `adjust_battery_target()`, and before that GS_fb00 never wrote the switch at all. A SolisCloud session is the usual way the switch ends up at 1: `solis.py` maps `"Self-Use - No Timed Charge/Discharge"` to `ENUM_SELF_USE_NO_GRID_CHARGING`, which clears bit 5, and writes it whenever no slot is active - so stopping the cloud component outside a slot leaves grid charging disallowed. The FB00 Solax Modbus plugin's option names differ from the pre-FB00 plugin's (`Self-Use` is 33 there, 35 here) - `SOLAX_SOLIS_MODES_FB00`. Verified from a user log (26 Sep 2026) and the plugin source. | `./run_all --test inverter` (`solis_fb00_*`, `solis_gs_*`) | | SolaX (`solax.py`) | Code `10402` is a token/auth failure, retried in-request rather than waiting for the next cycle. SolaX clamps battery minimum SOC at 10% (`SOLAX_MIN_RESERVE_PERCENT`), so `battery_min_soc` is auto-configured to stop Predbat writing limits the inverter will reject. **Follow-up on the GH#4993 thread (2026-09-21, from a 23-hour reporter log):** `10402` repeating every cycle with data still flowing is **server-side token invalidation between cycles**, not broken auth or lost data — Predbat's in-request refresh recovers every cycle. The tell: the same token value succeeds mid-cycle and is rejected next cycle while Predbat holds it untouched in memory between cycles (the auth-error branch of `_request_get_impl()` clears it, and `request_wrapper()` retries with a fresh token instead of failing the fetch), and a stated ~30-day `expires_in` against a real ~1-cycle token lifetime. Prime suspect is a second client sharing the same `solax_client_id`/`solax_client_secret` — the reporter ran the separate SolaX Developer API HA integration — **suspected, not proven** (one-active-token-per-app is common but could not be confirmed against SolaX docs); the decisive test is disabling the second client for an hour or registering each client its own app. Count `error 10402` lines against `Fetched ... successfully` lines before calling data lost, and note each 10402 also increments the component's lifetime error counter (`solax.py` auth-error branch), which is what accumulates in the Components panel — do not "fix" this by removing the in-request retry. Secondary, same thread: `Warn: SolarAPI: No solar forecast data was returned from HA sensors.` with the SolaX Cloud template means **no PV forecast source is configured at all** — with `forecast_solar`/`open_meteo`/`solcast_host`+`solcast_api_key` all absent, `fetch_pv_forecast()` falls through to the HA-sensor branch (`solcast.py` sets `active_source = "ha_sensors"`) and the template's `pv_forecast_today`-style `re:` regexes (`templates/solax_cloud.yaml`) match nothing unless the Solcast HA integration is installed. Check which source the log names ("Obtaining solar forecast from Forecast Solar API" vs "Using Solcast integration from inside HA") — a working source dropped during a template migration looks exactly like this. | `solax` | | Sigenergy (`sigenergy.py`) | Lifetime/history totals are cumulative server-side and reset around EU midnight, which showed up as an overnight dip; `fetch_history_totals` applies a monotonic clamp. "Energy totals went backwards overnight" starts here. Separately, a user `apps.yaml` override of `inverter.has_reserve_soc: true` (stock `SIG` default is `False`, deliberately — reverted in `5bfc5c80` after #2873/#3124) bypasses two guards that normally make reserve writes inert for Sigenergy (`execute.py:947-950`, `inverter.py:547-549`), so `adjust_reserve()` writes straight to `number.sigen_plant_ess_discharge_cut_off_state_of_charge`. Confirmed from a reporter's log: zero occurrences of the "Inverter does not support reserve" disable message the first guard would normally log, plus 56 reserve writes landing on that register (GH#4728). The stock template's `reserve:` line also points at that same entity rather than `ess_backup_state_of_charge`, which is what makes the override immediately destructive rather than merely inert. `Applying mode=eco ... duration=720min` is emitted only by the no-window fall-through branch (`sigenergy.py:2199`), so seeing it while a plan export window is live means the component's control snapshot never saw the window. Two code-verified routes to that, both still live: with `sigenergy_automatic: False`, `automatic_config()` - the only wiring of the component's control entities into Predbat - never runs, so Predbat's plan writes land on its own simulated dummy entities (`inverter.py:539-545`) and a `True == True` read-back is Predbat comparing against itself rather than reading a register; and `fetch_controls` runs on the first tick only (`if first:`, `sigenergy.py:2658`), so a single missed `call_service` event leaves the component's internal state permanently out of step while Predbat's write-skip comparison guarantees the healing write is never re-issued (GH#4894, probe-verified in `test_sigenergy.py`). `_publish_mqtt` logged the live `accessToken` on every command until PR #4926 added `SigenergyAPI.redact()`. The docs template still writes `number.sigen_plant_grid_import_limitation` to 0 in both freeze branches (`docs/inverter-setup.md:1936-1940` and `:1971-1975`), and Sigenergy's own EVAC/EVDC chargers are EMS-throttled to the plant import limit - so the block lands exactly when Predbat asks for Freeze Charging to hold for a car (GH#4911). | `sigenergy` |