From 5efffa52fe587d0a4c1c40e29c8ee5767ff7fc03 Mon Sep 17 00:00:00 2001 From: fboundy Date: Sat, 4 Jul 2026 13:03:01 +0100 Subject: [PATCH] Make Timed Charge (43110 bit 1) read-only; Work Mode select uses whole-register values Bit 1 of the Energy Storage Control Switch (register 43110) does not persist as a user-set value on this inverter firmware family: a Modbus write succeeds and is acknowledged, but the device silently clears it within one poll cycle. This produced the reported "Timed Charge bounces off" behaviour. Confirmed live and cross-referenced against wills106/homeassistant-solax-modbus, which never exposes this bit as a control either and drives all TOU enablement through register 43707. - Remove the bit-1 switch entity; expose it read-only as binary_sensor.solis_timed_charge_status via a new binary_sensor platform (SolisBinaryStatusSensor + get_binary_sensors). - Replace the Work Mode select's bit-conflict logic with the 8 field-tested whole-register values from solax_modbus (1/17/33/37/ 41/49/64/96), none of which set bit 1. - Drift baselines unchanged: the bit-1 unique_id simply moves from the switch platform to the binary_sensor platform (same register+bit). BREAKING: the bit-1 Timed Charge switch entity is replaced by a read-only binary_sensor, and the Work Mode select's option strings change entirely. --- custom_components/solisconnect/__init__.py | 2 +- .../solisconnect/binary_sensor.py | 29 ++++++ custom_components/solisconnect/const.py | 1 + .../sensor_data/binary_sensors.py | 26 +++++ .../sensor_data/select_sensors.py | 29 +++--- .../sensor_data/switch_sensors.py | 7 +- .../sensors/solis_binary_status_sensor.py | 79 +++++++++++++++ docs/source/sensors.md | 26 ++--- docs/source/tou.md | 4 +- tests/generate_known_sensors.py | 10 ++ tests/test_ac_coupling.py | 4 +- tests/test_binary_status_sensor.py | 96 +++++++++++++++++++ tests/test_sensor_drift.py | 10 ++ tests/test_solis_select_entity.py | 94 +++++++++--------- tests/test_switch_conflicts_requires.py | 27 ------ 15 files changed, 339 insertions(+), 105 deletions(-) create mode 100644 custom_components/solisconnect/binary_sensor.py create mode 100644 custom_components/solisconnect/sensor_data/binary_sensors.py create mode 100644 custom_components/solisconnect/sensors/solis_binary_status_sensor.py create mode 100644 tests/test_binary_status_sensor.py diff --git a/custom_components/solisconnect/__init__.py b/custom_components/solisconnect/__init__.py index dab1b3f..7f7b2b9 100644 --- a/custom_components/solisconnect/__init__.py +++ b/custom_components/solisconnect/__init__.py @@ -53,7 +53,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.NUMBER, Platform.SWITCH, Platform.TIME, Platform.SELECT] +PLATFORMS = [Platform.NUMBER, Platform.SWITCH, Platform.BINARY_SENSOR, Platform.TIME, Platform.SELECT] SCHEME_HOLDING_REGISTER = vol.Schema( { diff --git a/custom_components/solisconnect/binary_sensor.py b/custom_components/solisconnect/binary_sensor.py new file mode 100644 index 0000000..c0966b4 --- /dev/null +++ b/custom_components/solisconnect/binary_sensor.py @@ -0,0 +1,29 @@ +import logging + +from homeassistant.config_entries import ConfigEntry + +from custom_components.solisconnect import ModbusController +from custom_components.solisconnect.const import BINARY_SENSOR_ENTITIES, DOMAIN, ENTITIES +from custom_components.solisconnect.helpers import get_controller_from_entry +from custom_components.solisconnect.sensor_data.binary_sensors import get_binary_sensors +from custom_components.solisconnect.sensors.solis_binary_status_sensor import SolisBinaryStatusSensor + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_devices): + modbus_controller: ModbusController = get_controller_from_entry(hass, config_entry) + + binary_groups = get_binary_sensors(modbus_controller.inverter_config) + + binary_entities: list[SolisBinaryStatusSensor] = [] + + for main_entity in binary_groups: + for child_entity in main_entity[ENTITIES]: + child_entity["register"] = main_entity.get("register", main_entity.get("read_register")) + binary_entities.append(SolisBinaryStatusSensor(hass, modbus_controller, child_entity)) + + hass.data[DOMAIN].setdefault(BINARY_SENSOR_ENTITIES, []).extend(binary_entities) + async_add_devices(binary_entities, True) + + return True diff --git a/custom_components/solisconnect/const.py b/custom_components/solisconnect/const.py index ff2fe35..84a15a8 100644 --- a/custom_components/solisconnect/const.py +++ b/custom_components/solisconnect/const.py @@ -10,6 +10,7 @@ SENSOR_ENTITIES = "sensor_entities" TIME_ENTITIES = "time_entities" SWITCH_ENTITIES = "switch_entities" +BINARY_SENSOR_ENTITIES = "binary_sensor_entities" NUMBER_ENTITIES = "number_entities" SENSOR_DERIVED_ENTITIES = "sensor_derived_entities" DRIFT_COUNTER = "drift_counter" diff --git a/custom_components/solisconnect/sensor_data/binary_sensors.py b/custom_components/solisconnect/sensor_data/binary_sensors.py new file mode 100644 index 0000000..48ac37f --- /dev/null +++ b/custom_components/solisconnect/sensor_data/binary_sensors.py @@ -0,0 +1,26 @@ +from custom_components.solisconnect.data.enums import InverterType + + +def get_binary_sensors(inverter_config): + """Read-only bit-based status sensors (no write path, unlike switch_sensors.py).""" + binary_sensors = [] + + if inverter_config.type == InverterType.HYBRID: + binary_sensors = [ + { + "register": 43110, + "entities": [ + # Bit 1 of the Energy Storage Control Switch does not persist as a + # user-set value on this firmware family: writing it succeeds and is + # acknowledged, but the device silently clears it again within one poll + # cycle while leaving every other bit untouched. Confirmed live and + # cross-referenced against https://github.com/wills106/homeassistant-solax-modbus + # (plugin_solis_fb00.py), which never exposes this bit as a controllable + # option either. Real TOU enablement lives entirely in the per-slot + # switches on register 43707. Exposed here read-only for visibility only. + {"bit_position": 1, "name": "Timed Charge Status"}, + ], + }, + ] + + return binary_sensors diff --git a/custom_components/solisconnect/sensor_data/select_sensors.py b/custom_components/solisconnect/sensor_data/select_sensors.py index ce06899..4ba5a5c 100644 --- a/custom_components/solisconnect/sensor_data/select_sensors.py +++ b/custom_components/solisconnect/sensor_data/select_sensors.py @@ -36,18 +36,23 @@ def get_select_sensors(inverter_config): "name": "Work Mode", "control": True, "entities": [ - # Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8. - # Each option clears every OTHER mode bit it is incompatible with; the - # previous lists conflicted with their own bit and never cleared the - # Timed Charge bit (1), so switching modes could leave invalid bit - # combos that the inverter rejects/normalises (controls "bounce off"), - # and plain "Self-Use"/"Feed-in" could never turn TOU back off. - {"bit_position": 0, "name": "Self-Use", "conflicts_with": [1, 2, 6, 11]}, - {"bit_position": 0, "name": "Self-Use + TOU", "conflicts_with": [2, 6, 11], "requires": [1]}, - {"bit_position": 2, "name": "Off-Grid Operation", "conflicts_with": [0, 1, 6, 11]}, - {"bit_position": 6, "name": "Feed-in Priority", "conflicts_with": [0, 1, 2, 11]}, - {"bit_position": 6, "name": "Feed-in + TOU", "conflicts_with": [0, 2, 11], "requires": [1]}, - {"bit_position": 11, "name": "Peak Shaving", "conflicts_with": [0, 1, 2, 4, 6]}, + # Whole-register values, not per-bit conflicts_with/requires logic. + # Bit-conflict resolution previously used here could compute a + # technically valid, conflict-free combo (per RS485_MODBUS Appendix 7) + # that the firmware would still silently reject/normalise (e.g. bit 1, + # "Timed Charge", never persists on this firmware family - see + # binary_sensors.py). These 8 values are copied from the field-tested + # option_dict in https://github.com/wills106/homeassistant-solax-modbus + # (plugin_solis_fb00.py, "Energy Storage Control Switch"), which never + # includes bit 1 for the same reason. + {"name": "Self-Use - No Grid Charging", "on_value": 1}, + {"name": "Backup/Reserve - No Grid Charging", "on_value": 17}, + {"name": "Self-Use", "on_value": 33}, + {"name": "Off-Grid Mode", "on_value": 37}, + {"name": "Battery Awaken", "on_value": 41}, + {"name": "Backup/Reserve", "on_value": 49}, + {"name": "Feed-in priority - No Grid Charging", "on_value": 64}, + {"name": "Feed-in priority", "on_value": 96}, ], }, ] diff --git a/custom_components/solisconnect/sensor_data/switch_sensors.py b/custom_components/solisconnect/sensor_data/switch_sensors.py index df9a9d3..a907ff4 100644 --- a/custom_components/solisconnect/sensor_data/switch_sensors.py +++ b/custom_components/solisconnect/sensor_data/switch_sensors.py @@ -36,11 +36,10 @@ def get_switch_sensors(inverter_config): "register": 43110, "entities": [ # Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8 + # Bit 1 (Timed Charge / TOU) is intentionally not exposed as a control + # here - it does not persist on this firmware family. See + # sensor_data/binary_sensors.py for the read-only status equivalent. {"bit_position": 0, "name": "Mode - Self Use", "conflicts_with": [2, 6, 11], "control": True}, - # Timed Charge (TOU) only operates alongside Self-Use (0) or Feed-In (6); - # clear Off-Grid/Peak-Shaving first or the fallback Self-Use bit forms a - # combo the firmware rejects and the switch bounces off on the next poll. - {"bit_position": 1, "name": "Mode - Timed Charge", "requires_any": [0, 6], "conflicts_with": [2, 11], "control": True}, {"bit_position": 2, "name": "Mode - Off-Grid", "conflicts_with": [0, 1, 6, 11], "control": True}, {"bit_position": 3, "name": "Mode - Battery Wake Up", "control": True}, {"bit_position": 4, "name": "Mode - Backup", "conflicts_with": [11], "control": True}, diff --git a/custom_components/solisconnect/sensors/solis_binary_status_sensor.py b/custom_components/solisconnect/sensors/solis_binary_status_sensor.py new file mode 100644 index 0000000..660ecbb --- /dev/null +++ b/custom_components/solisconnect/sensors/solis_binary_status_sensor.py @@ -0,0 +1,79 @@ +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity + +from custom_components.solisconnect import ModbusController +from custom_components.solisconnect.const import REGISTER, VALUE +from custom_components.solisconnect.helpers import ( + cache_get, + is_correct_controller, + register_update_signal, + unique_id_generator_binary, +) +from custom_components.solisconnect.sensors.solis_binary_sensor import get_bit_bool + +_LOGGER = logging.getLogger(__name__) + + +class SolisBinaryStatusSensor(RestoreEntity, BinarySensorEntity): + """Read-only bit status. No write path - see SolisBinaryEntity for controllable switches.""" + + def __init__(self, hass, modbus_controller, entity_definition): + self._hass = hass + self._modbus_controller: ModbusController = modbus_controller + self._register: int = entity_definition.get("register", entity_definition.get("read_register")) + entity_definition.get("offset", 0) + self._bit_position = entity_definition.get("bit_position", None) + self._on_value = entity_definition.get("on_value", None) + self._inverted = entity_definition.get("inverted", None) + self._attr_unique_id = unique_id_generator_binary(modbus_controller, self._register, self._bit_position, self._on_value) + self._attr_name = entity_definition["name"] + self._attr_available = False + + async def async_added_to_hass(self) -> None: + """Called when entity is added to HA.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self._hass, + register_update_signal(self._modbus_controller, self._register), + self.handle_modbus_update, + ) + ) + + @callback + def handle_modbus_update(self, data): + """Callback when register data is available (per-register dispatcher).""" + updated_register = int(data.get(REGISTER)) + + if not is_correct_controller(self._modbus_controller, data): + return # meant for a different sensor/inverter combo + + if updated_register != self._register: + return + + updated_value = data.get(VALUE) + value = int(updated_value) if updated_value is not None else cache_get(self._hass, self._modbus_controller, self._register) + if value is None: + return + + self._attr_available = True + if self._bit_position is not None: + raw_bit = get_bit_bool(value, self._bit_position) + self._attr_is_on = (not raw_bit) if self._inverted else raw_bit + elif self._on_value is not None: + self._attr_is_on = value == self._on_value + + self.schedule_update_ha_state() + + @property + def is_on(self): + return self._attr_is_on + + @property + def device_info(self): + """Return device info.""" + return self._modbus_controller.device_info diff --git a/docs/source/sensors.md b/docs/source/sensors.md index 81e1736..64c07a3 100644 --- a/docs/source/sensors.md +++ b/docs/source/sensors.md @@ -5,7 +5,7 @@ myst: The following sensors are provided in the integration. -Local Modbus entries can populate the full register set for the selected inverter model. SolisCloud entries use the same entity definitions, but only registers covered by the current cloud field/CID mappings update in cloud-only mode; unsupported cloud-only entities are marked unavailable. TOU V2 timed-slot CIDs are mapped into the local `43707-43791` register model, but automatic TOU V2 feature gating from cloud CID `6798` is still pending. +Local Modbus entries can populate the full register set for the selected inverter model. SolisCloud entries use the same entity definitions, but only registers covered by the current cloud field/CID mappings update in cloud-only mode; unsupported cloud-only entities are marked unavailable. TOU V2 timed-slot CIDs are mapped into the local `43707-43791` register model, and automatic TOU V2 feature gating from cloud CID `6798` disables those entities in cloud-only mode when the inverter does not support V2. # String Inverter Registers The string inverter uses the following register ranges: @@ -35,18 +35,18 @@ The hybrid inverter uses the following register ranges: # Switch Control Sensors | Name | Register | Bit Position | Note | |-------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Solis Self-Use Mode | 43110 | 0 | -| Solis Time Of Use Mode | 43110 | 1 | -| Solis OFF-Grid Mode | 43110 | 2 | -| Solis Battery Wakeup Switch | 43110 | 3 | -| Solis Reserve Battery Mode | 43110 | 4 | -| Solis Allow Grid To Charge The Battery | 43110 | 5 | -| Solis Feed In Priority Mode | 43110 | 6 | -| Solis Batt OVC | 43110 | 7 | -| Solis Battery Forcecharge Peakshaving | 43110 | 8 | -| Solis Battery current correction | 43110 | 9 | -| Solis Battery healing mode | 43110 | 10 | -| Solis Peak-shaving mode | 43110 | 11 | +| Mode - Self Use | 43110 | 0 | +| Timed Charge Status | 43110 | 1 | **Read-only** `binary_sensor` (not a switch): this bit does not persist as a user-set value on this firmware family. Use register `43707` per-slot switches for real TOU enablement. | +| Mode - Off-Grid | 43110 | 2 | +| Mode - Battery Wake Up | 43110 | 3 | +| Mode - Backup | 43110 | 4 | +| Mode - Grid Charge | 43110 | 5 | +| Mode - Feed In Priority | 43110 | 6 | +| Mode - Night OVD Retain | 43110 | 7 | +| Battery Forcecharge Peakshaving | 43110 | 8 | Disabled by default | +| Battery Current Correction | 43110 | 9 | Disabled by default | +| Mode - Battery Condition | 43110 | 10 | +| Mode - Peak Shaving | 43110 | 11 | | Solis Generator connection position | 43365 | 0 | | Solis With Generator | 43365 | 1 | | Solis Generator enable signal | 43365 | 2 | diff --git a/docs/source/tou.md b/docs/source/tou.md index 6bf633a..76949d9 100644 --- a/docs/source/tou.md +++ b/docs/source/tou.md @@ -8,7 +8,7 @@ The config flow's "V2 support" toggle is auto-detected from the inverter's HMI f | | Time-Charging (V1) | Grid Time of Use V2 | | --- | --- | --- | -| Enable switch | `switch.solis_time_of_use` — bit 1 of storage-mode register `43110` | `switch.solis_time_of_use_v2_switch` — bitfield register `43707`, one bit per slot | +| Enable switch | **Read-only** — bit 1 of storage-mode register `43110`, exposed as `binary_sensor.solis_timed_charge_status` (see note below; not user-settable on this firmware family) | Per-slot `switch.solis_timed_charge_enable_N` / `switch.solis_timed_discharge_enable_N` — bitfield register `43707`, one bit per slot | | Slot count | 3 | 6 | | Per-slot fields | Start/end hour+minute for charge and discharge | Start/end hour+minute, target SOC, cut-off voltage, battery current, per slot, for charge and discharge | | Charge/discharge current | One global pair (`43141`/`43142`), not per-slot | Per-slot (register offset `+1` in each slot block) | @@ -22,7 +22,7 @@ The config flow's "V2 support" toggle is auto-detected from the inverter's HMI f The older scheduling mechanism. One shared charge current and one shared discharge current apply across all slots; only the start/end times vary per slot. -- Enable: bit 1 (`Time of Use`) of the storage-mode bitfield at register `43110` (`switch_sensors.py`). This bit requires either `Self-Use Mode` (bit 0) or `Feed-In Priority Mode` (bit 6) to be conflict-free with the other storage-mode bits. +- Enable: bit 1 (`Time of Use`) of the storage-mode bitfield at register `43110`. **This bit is read-only in SolisConnect** — exposed as `binary_sensor.solis_timed_charge_status` (`sensor_data/binary_sensors.py`), not a controllable switch. On this inverter firmware family the bit does not persist as a user-set value: a Modbus write to set it succeeds and is acknowledged, but the device silently clears it again within one poll cycle while leaving every other storage-mode bit untouched. This was confirmed live and cross-referenced against [wills106/homeassistant-solax-modbus](https://github.com/wills106/homeassistant-solax-modbus) (`plugin_solis_fb00.py`), which never exposes this bit as a controllable option either and drives all real TOU enablement through register `43707`'s per-slot switches. The `Work Mode` select (register `43110`) likewise now writes only the 8 field-tested whole-register values from that project (`1, 17, 33, 37, 41, 49, 64, 96`), none of which set bit 1. - Global settings: `Time-Charging Charge Current` (`43141`) and `Time-Charging Discharge Current` (`43142`), both in amps, editable as `number` entities. - Per slot, each slot is a block of paired hour/minute registers exposed as HA `time` entities: - Charge Start / Charge End diff --git a/tests/generate_known_sensors.py b/tests/generate_known_sensors.py index 02899d6..4a614e5 100644 --- a/tests/generate_known_sensors.py +++ b/tests/generate_known_sensors.py @@ -35,6 +35,7 @@ class MockController: known_sensors.append(uid) from custom_components.solisconnect.data.solis_config import InverterConfig, InverterType + from custom_components.solisconnect.sensor_data.binary_sensors import get_binary_sensors from custom_components.solisconnect.sensor_data.select_sensors import get_select_sensors from custom_components.solisconnect.sensor_data.switch_sensors import get_switch_sensors from custom_components.solisconnect.sensor_data.time_sensors import get_time_sensors @@ -53,6 +54,15 @@ class MockController: uid = unique_id_generator_binary(controller, register, bit_position, on_value) known_sensors.append(uid) + # Binary sensor (read-only bit status; same unique_id scheme as switch) + for group in get_binary_sensors(config): + for entity in group["entities"]: + register = group.get("register", group.get("read_register")) + entity.get("offset", 0) + bit_position = entity.get("bit_position") + on_value = entity.get("on_value") + uid = unique_id_generator_binary(controller, register, bit_position, on_value) + known_sensors.append(uid) + # Select select_groups = get_select_sensors(config) for entity in select_groups: diff --git a/tests/test_ac_coupling.py b/tests/test_ac_coupling.py index 4e994d8..54bd223 100644 --- a/tests/test_ac_coupling.py +++ b/tests/test_ac_coupling.py @@ -120,7 +120,9 @@ def test_mode_switch_names_and_disabled_bits(): by_bit = {entity["bit_position"]: entity for entity in _switch_entities(config, 43110)} assert by_bit[0]["name"] == "Mode - Self Use" - assert by_bit[1]["name"] == "Mode - Timed Charge" + # Bit 1 (Timed Charge / TOU) is intentionally NOT a switch entity: it does not persist + # on this firmware family (see sensor_data/binary_sensors.py for the read-only status). + assert 1 not in by_bit assert by_bit[2]["name"] == "Mode - Off-Grid" assert by_bit[3]["name"] == "Mode - Battery Wake Up" assert by_bit[4]["name"] == "Mode - Backup" diff --git a/tests/test_binary_status_sensor.py b/tests/test_binary_status_sensor.py new file mode 100644 index 0000000..f944e92 --- /dev/null +++ b/tests/test_binary_status_sensor.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock + +import pytest + +from custom_components.solisconnect.const import REGISTER, SCOPE, VALUE +from custom_components.solisconnect.helpers import controller_scope +from custom_components.solisconnect.sensor_data.binary_sensors import get_binary_sensors +from custom_components.solisconnect.sensors.solis_binary_status_sensor import SolisBinaryStatusSensor + + +@pytest.fixture +def controller(): + mock = MagicMock() + mock.host = "inverter.local" + mock.device_id = 1 + mock.identification = None + mock.device_serial_number = "SN123" + mock.serial_number = "SN123" + mock.model = "S6" + mock.device_identification = "XYZ" + mock.sw_version = "1.0" + return mock + + +@pytest.fixture +def mock_hass(): + hass = MagicMock() + return hass + + +def _update(controller, register, value): + return {REGISTER: register, VALUE: value, SCOPE: controller_scope(controller)} + + +def test_binary_status_sensor_has_no_write_methods(mock_hass, controller): + entity = SolisBinaryStatusSensor(mock_hass, controller, {"register": 43110, "bit_position": 1, "name": "Timed Charge Status"}) + assert not hasattr(entity, "async_turn_on") + assert not hasattr(entity, "async_turn_off") + assert not hasattr(entity, "set_register_bit") + + +def test_binary_status_sensor_bit_update_sets_is_on(mock_hass, controller): + entity = SolisBinaryStatusSensor(mock_hass, controller, {"register": 43110, "bit_position": 1, "name": "Timed Charge Status"}) + entity.schedule_update_ha_state = MagicMock() + + entity.handle_modbus_update(_update(controller, 43110, 0b11)) # bit 1 set + assert entity.is_on is True + assert entity._attr_available is True + entity.schedule_update_ha_state.assert_called_once() + + entity.handle_modbus_update(_update(controller, 43110, 0b01)) # bit 1 clear + assert entity.is_on is False + + +def test_binary_status_sensor_ignores_other_register(mock_hass, controller): + entity = SolisBinaryStatusSensor(mock_hass, controller, {"register": 43110, "bit_position": 1, "name": "Timed Charge Status"}) + entity.schedule_update_ha_state = MagicMock() + + entity.handle_modbus_update(_update(controller, 43111, 0b11)) + entity.schedule_update_ha_state.assert_not_called() + + +def test_binary_status_sensor_ignores_wrong_controller(mock_hass, controller): + entity = SolisBinaryStatusSensor(mock_hass, controller, {"register": 43110, "bit_position": 1, "name": "Timed Charge Status"}) + entity.schedule_update_ha_state = MagicMock() + + entity.handle_modbus_update({REGISTER: 43110, VALUE: 0b11, SCOPE: "some-other-inverter"}) + entity.schedule_update_ha_state.assert_not_called() + + +def test_binary_status_sensor_device_info_delegates(mock_hass, controller): + entity = SolisBinaryStatusSensor(mock_hass, controller, {"register": 43110, "bit_position": 1, "name": "Timed Charge Status"}) + assert entity.device_info is controller.device_info + + +def test_get_binary_sensors_exposes_timed_charge_status(): + from custom_components.solisconnect.data.solis_config import SOLIS_INVERTERS, InverterOptions + + template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P") + config = template.clone_with_options(InverterOptions(), "S2_WL_ST") + + groups = get_binary_sensors(config) + entities = [e for group in groups for e in group["entities"]] + names = {e["name"] for e in entities} + + assert "Timed Charge Status" in names + tou = next(e for group in groups for e in group["entities"] if e["name"] == "Timed Charge Status") + assert group_register(groups, "Timed Charge Status") == 43110 + assert tou["bit_position"] == 1 + + +def group_register(groups, name): + for group in groups: + if any(e["name"] == name for e in group["entities"]): + return group["register"] + return None diff --git a/tests/test_sensor_drift.py b/tests/test_sensor_drift.py index d5e2c25..230d978 100644 --- a/tests/test_sensor_drift.py +++ b/tests/test_sensor_drift.py @@ -41,6 +41,7 @@ class MockController: # New Entity Types from custom_components.solisconnect.data.solis_config import InverterConfig, InverterType + from custom_components.solisconnect.sensor_data.binary_sensors import get_binary_sensors from custom_components.solisconnect.sensor_data.select_sensors import get_select_sensors from custom_components.solisconnect.sensor_data.switch_sensors import get_switch_sensors from custom_components.solisconnect.sensor_data.time_sensors import get_time_sensors @@ -56,6 +57,15 @@ class MockController: uid = unique_id_generator_binary(controller, register, bit_position, on_value) current_sensors.add(uid) + # Binary sensor (read-only bit status; same unique_id scheme as switch) + for group in get_binary_sensors(config): + for entity in group["entities"]: + register = group.get("register", group.get("read_register")) + entity.get("offset", 0) + bit_position = entity.get("bit_position") + on_value = entity.get("on_value") + uid = unique_id_generator_binary(controller, register, bit_position, on_value) + current_sensors.add(uid) + # Select for entity in get_select_sensors(config): uid = unique_id_generator(controller, entity["register"], "select") diff --git a/tests/test_solis_select_entity.py b/tests/test_solis_select_entity.py index 159e346..136bd75 100644 --- a/tests/test_solis_select_entity.py +++ b/tests/test_solis_select_entity.py @@ -257,54 +257,58 @@ def _work_mode_definition(): return next(group for group in get_select_sensors(config) if group["name"] == "Work Mode") -def _apply_option_bits(current: int, option: dict) -> int: - """Mirror SolisSelectEntity.set_register_bit's bit arithmetic for one option.""" - value = current - for bit in option.get("conflicts_with", []): - value = set_bit(value, bit, False) - for bit in option.get("requires", []): - value = set_bit(value, bit, True) - return set_bit(value, option["bit_position"], True) - - -def _resolve_option(register_value: int, options: list[dict]) -> str | None: - """Mirror SolisSelectEntity.current_option's resolution logic.""" - sorted_options = sorted(options, key=lambda e: len(e.get("requires", [])) if "requires" in e else 0, reverse=True) - for e in sorted_options: - bit_position = e.get("bit_position") - requires = e.get("requires") - if bit_position is not None and (register_value >> bit_position) & 1: - if requires: - if all((register_value >> rbit) & 1 for rbit in requires): - return e["name"] - else: - return e["name"] - return None - - -@pytest.mark.parametrize("start_bits", [0b1, 0b11, 0b100, 0b1000010, 0b100000000001, 0b100011]) -def test_work_mode_options_round_trip_from_any_mode(start_bits): - """Every Work Mode option must land on a register state that resolves back to itself, - regardless of the mode the inverter was in before (the old definitions left stale mode - bits set, e.g. picking "Self-Use" never cleared TOU so it read back as "Self-Use + TOU", - and cross-mode transitions produced combos the firmware rejects).""" +# The 8 field-tested whole-register values copied from wills106/homeassistant-solax-modbus +# (plugin_solis_fb00.py). None includes bit 1 (Timed Charge / TOU), which does not persist +# on this firmware family. +_WORK_MODE_VALUES = { + "Self-Use - No Grid Charging": 1, + "Backup/Reserve - No Grid Charging": 17, + "Self-Use": 33, + "Off-Grid Mode": 37, + "Battery Awaken": 41, + "Backup/Reserve": 49, + "Feed-in priority - No Grid Charging": 64, + "Feed-in priority": 96, +} + + +def test_work_mode_definition_matches_solax_values(): + """The Work Mode options must be the exact solax_modbus-validated whole-register values, + with no bit-conflict logic and no bit-1 (Timed Charge) option.""" definition = _work_mode_definition() - options = definition["entities"] + by_name = {e["name"]: e for e in definition["entities"]} - for option in options: - result = _apply_option_bits(start_bits, option) - assert _resolve_option(result, options) == option["name"], ( - f"Selecting {option['name']!r} from {start_bits:#014b} produced {result:#014b}, which resolves to {_resolve_option(result, options)!r}" - ) + assert set(by_name) == set(_WORK_MODE_VALUES) + for name, expected in _WORK_MODE_VALUES.items(): + assert by_name[name]["on_value"] == expected + assert "bit_position" not in by_name[name] + assert "conflicts_with" not in by_name[name] + # No option sets bit 1 (Timed Charge) - it doesn't persist on this firmware. + assert (expected >> 1) & 1 == 0 -def test_work_mode_plain_options_clear_tou_bit(): - """Picking a mode without "+ TOU" must actually turn the Timed Charge bit off.""" +@pytest.mark.asyncio +@pytest.mark.parametrize(("name", "value"), list(_WORK_MODE_VALUES.items())) +async def test_work_mode_option_writes_whole_register_value(mock_hass, mock_controller, name, value): + """Selecting a Work Mode option writes its whole-register value directly (no bit math).""" + definition = _work_mode_definition() + + with ( + patch("custom_components.solisconnect.sensors.solis_select_entity.cache_get", return_value=0), + patch("custom_components.solisconnect.sensors.solis_select_entity.cache_save"), + ): + entity = SolisSelectEntity(mock_hass, mock_controller, definition) + entity.async_write_ha_state = MagicMock() + await entity.async_select_option(name) + + mock_controller.async_write_holding_register.assert_awaited_once_with(43110, value) + + +@pytest.mark.parametrize(("name", "value"), list(_WORK_MODE_VALUES.items())) +def test_work_mode_current_option_resolves_each_value(mock_hass, mock_controller, name, value): + """current_option must map each whole-register value back to its option name.""" definition = _work_mode_definition() - by_name = {e["name"]: e for e in definition["entities"]} - tou_on = 0b11 # Self-Use + TOU active - assert (_apply_option_bits(tou_on, by_name["Self-Use"]) >> 1) & 1 == 0 - assert (_apply_option_bits(tou_on, by_name["Feed-in Priority"]) >> 1) & 1 == 0 - assert (_apply_option_bits(tou_on, by_name["Peak Shaving"]) >> 1) & 1 == 0 - assert (_apply_option_bits(tou_on, by_name["Off-Grid Operation"]) >> 1) & 1 == 0 + with patch("custom_components.solisconnect.sensors.solis_select_entity.cache_get", return_value=value): + entity = SolisSelectEntity(mock_hass, mock_controller, definition) + assert entity.current_option == name diff --git a/tests/test_switch_conflicts_requires.py b/tests/test_switch_conflicts_requires.py index 69527b1..8162c82 100644 --- a/tests/test_switch_conflicts_requires.py +++ b/tests/test_switch_conflicts_requires.py @@ -75,33 +75,6 @@ async def test_requires_tou(mock_hass, controller): controller.async_write_holding_register.assert_awaited_once_with(43110, expected) -@pytest.mark.asyncio -async def test_timed_charge_switch_clears_incompatible_modes(mock_hass, controller): - """Turning on Mode - Timed Charge while in Peak Shaving must clear bit 11 and fall - back to Self-Use, not write an invalid combo the firmware rejects (the bounce).""" - from custom_components.solisconnect.data.solis_config import SOLIS_INVERTERS, InverterOptions - from custom_components.solisconnect.sensor_data.switch_sensors import get_switch_sensors - - template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P") - config = template.clone_with_options(InverterOptions(), "S2_WL_ST") - entity_def = next( - entity for group in get_switch_sensors(config) if group.get("register") == 43110 for entity in group["entities"] if entity["bit_position"] == 1 - ) - entity_def = {**entity_def, "register": 43110, "write_register": None} - - initial = set_bit(0, 11, True) # Peak Shaving active, no Self-Use/Feed-In - - with ( - patch("custom_components.solisconnect.sensors.solis_binary_sensor.cache_get", return_value=initial), - patch("custom_components.solisconnect.sensors.solis_binary_sensor.cache_save"), - ): - entity = SolisBinaryEntity(mock_hass, controller, entity_def) - await entity.set_register_bit(True) - - expected = set_bit(set_bit(0, 0, True), 1, True) # Peak Shaving cleared, Self-Use + TOU set - controller.async_write_holding_register.assert_awaited_once_with(43110, expected) - - @pytest.mark.asyncio async def test_conflicts_and_requires_combined(mock_hass, controller): entity_def = {