From df323302ee3704bca2dfcce7b747fa40901f03b6 Mon Sep 17 00:00:00 2001 From: fboundy Date: Sat, 4 Jul 2026 00:17:14 +0100 Subject: [PATCH] Distinguish operational controls from configuration entities Follow-up to the EntityCategory work in #28: introduces a "control" flag on sensor/switch/select/time entity definitions so genuine operational controls (storage-mode bits, TOU V2 enable switches, TOU V2 SOC/current/voltage/time entities, Work Mode select, the active- protocol select) stay in the default category instead of being lumped into EntityCategory.CONFIG alongside one-time settings. Also renames storage-mode switch bits to a shorter "Mode - X" scheme, disables two rarely-used bits (Battery Forcecharge Peakshaving, Battery Current Correction) by default, and unifies TOU V2 entity names to "Timed Charge {field} N" (N=1-6 charge slots, 7-12 discharge slots), mirroring how V1 TOU already uses "Time-Charging" as an umbrella term for both directions. A new _apply_control_metadata() helper in hybrid_sensors.py tags and renames entities by register instead of hand-annotating each dict. --- .../sensor_data/hybrid_sensors.py | 54 +++++++++++++++ .../sensor_data/select_sensors.py | 1 + .../sensor_data/switch_sensors.py | 48 ++++++------- .../solisconnect/sensor_data/time_sensors.py | 48 ++++++------- .../solisconnect/sensors/entity_category.py | 4 +- .../solisconnect/sensors/solis_base_sensor.py | 3 + .../sensors/solis_binary_sensor.py | 4 +- .../sensors/solis_number_sensor.py | 5 +- .../sensors/solis_protocol_select.py | 2 - .../sensors/solis_select_entity.py | 2 +- .../solisconnect/sensors/solis_sensor.py | 5 +- custom_components/solisconnect/time.py | 3 +- tests/test_ac_coupling.py | 65 +++++++++++++++++ tests/test_entity_categories_and_versions.py | 69 +++++++++++++++++-- 14 files changed, 253 insertions(+), 60 deletions(-) diff --git a/custom_components/solisconnect/sensor_data/hybrid_sensors.py b/custom_components/solisconnect/sensor_data/hybrid_sensors.py index b82a27b..1c7831e 100644 --- a/custom_components/solisconnect/sensor_data/hybrid_sensors.py +++ b/custom_components/solisconnect/sensor_data/hybrid_sensors.py @@ -3443,6 +3443,60 @@ ] ## Remove 33132 in next update if this works (43110) + +def _apply_control_metadata() -> None: + direct_control_names = { + "Max Charge SOC", + "Overdischarge SOC", + "Max Charge Current", + "Max Discharge Current", + "Force Charge SOC", + "Battery Max Charge Current", + "Battery Max Discharge Current", + } + direct_control_renames = { + "Backup SOC": "Backup Mode SOC", + } + + timed_charge_registers: dict[int, tuple[str, int]] = {} + for slot in range(1, 7): + charge_base = 43708 + ((slot - 1) * 7) + timed_charge_registers[charge_base] = ("SOC", slot) + timed_charge_registers[charge_base + 1] = ("Current", slot) + timed_charge_registers[charge_base + 2] = ("Voltage", slot) + + discharge_slot = slot + 6 + discharge_base = 43750 + ((slot - 1) * 7) + timed_charge_registers[discharge_base] = ("SOC", discharge_slot) + timed_charge_registers[discharge_base + 1] = ("Current", discharge_slot) + timed_charge_registers[discharge_base + 2] = ("Voltage", discharge_slot) + + for group in hybrid_sensors: + for entity in group.get("entities", []): + if entity.get("type") == "reserve": + continue + + if entity["name"] in direct_control_renames: + entity["name"] = direct_control_renames[entity["name"]] + + if entity["name"] in direct_control_names or entity["name"] in direct_control_renames.values(): + entity["control"] = True + + registers = entity.get("register", []) + if len(registers) != 1: + continue + + register = int(registers[0]) + timed_control = timed_charge_registers.get(register) + if timed_control: + field, slot = timed_control + entity["name"] = f"Timed Charge {field} {slot}" + entity["control"] = True + + +_apply_control_metadata() + + hybrid_sensors_derived = [ { "name": "Last Modbus Success", diff --git a/custom_components/solisconnect/sensor_data/select_sensors.py b/custom_components/solisconnect/sensor_data/select_sensors.py index afdbee9..ce7297d 100644 --- a/custom_components/solisconnect/sensor_data/select_sensors.py +++ b/custom_components/solisconnect/sensor_data/select_sensors.py @@ -34,6 +34,7 @@ def get_select_sensors(inverter_config): { "register": 43110, "name": "Work Mode", + "control": True, "entities": [ # Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8 {"bit_position": 0, "name": "Self-Use", "conflicts_with": [0, 6, 11]}, diff --git a/custom_components/solisconnect/sensor_data/switch_sensors.py b/custom_components/solisconnect/sensor_data/switch_sensors.py index bd83352..8409978 100644 --- a/custom_components/solisconnect/sensor_data/switch_sensors.py +++ b/custom_components/solisconnect/sensor_data/switch_sensors.py @@ -36,18 +36,18 @@ def get_switch_sensors(inverter_config): "register": 43110, "entities": [ # Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8 - {"bit_position": 0, "name": "Self-Use Mode", "conflicts_with": [2, 6, 11]}, - {"bit_position": 1, "name": "Time of Use", "requires_any": [0, 6]}, - {"bit_position": 2, "name": "Off-Grid Mode", "conflicts_with": [0, 1, 6, 11]}, - {"bit_position": 3, "name": "Battery Wakeup Switch"}, - {"bit_position": 4, "name": "Reserve Battery Mode", "conflicts_with": [11]}, - {"bit_position": 5, "name": "Allow Grid to Charge the Battery"}, - {"bit_position": 6, "name": "Feed-In Priority Mode", "conflicts_with": [0, 11]}, - {"bit_position": 7, "name": "Batt OVC"}, - {"bit_position": 8, "name": "Battery Forcecharge Peakshaving"}, - {"bit_position": 9, "name": "Battery Current Correction"}, - {"bit_position": 10, "name": "Battery Healing Mode"}, - {"bit_position": 11, "name": "Peak Shaving Mode", "conflicts_with": [0, 4, 6]}, + {"bit_position": 0, "name": "Mode - Self Use", "conflicts_with": [2, 6, 11], "control": True}, + {"bit_position": 1, "name": "Mode - Timed Charge", "requires_any": [0, 6], "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}, + {"bit_position": 5, "name": "Mode - Grid Charge", "control": True}, + {"bit_position": 6, "name": "Mode - Feed In Priority", "conflicts_with": [0, 11], "control": True}, + {"bit_position": 7, "name": "Mode - Night OVD Retain", "control": True}, + {"bit_position": 8, "name": "Battery Forcecharge Peakshaving", "enabled": False}, + {"bit_position": 9, "name": "Battery Current Correction", "enabled": False}, + {"bit_position": 10, "name": "Mode - Battery Condition", "control": True}, + {"bit_position": 11, "name": "Mode - Peak Shaving", "conflicts_with": [0, 4, 6], "control": True}, ], }, { @@ -146,18 +146,18 @@ def get_switch_sensors(inverter_config): { "register": 43707, "entities": [ - {"name": "Grid Time of Use Charging Period 1", "bit_position": 0}, - {"name": "Grid Time of Use Charging Period 2", "bit_position": 1}, - {"name": "Grid Time of Use Charging Period 3", "bit_position": 2}, - {"name": "Grid Time of Use Charging Period 4", "bit_position": 3}, - {"name": "Grid Time of Use Charging Period 5", "bit_position": 4}, - {"name": "Grid Time of Use Charging Period 6", "bit_position": 5}, - {"name": "Grid Time of Use Discharge Period 1", "bit_position": 6}, - {"name": "Grid Time of Use Discharge Period 2", "bit_position": 7}, - {"name": "Grid Time of Use Discharge Period 3", "bit_position": 8}, - {"name": "Grid Time of Use Discharge Period 4", "bit_position": 9}, - {"name": "Grid Time of Use Discharge Period 5", "bit_position": 10}, - {"name": "Grid Time of Use Discharge Period 6", "bit_position": 11}, + {"name": "Timed Charge Enable 1", "bit_position": 0, "control": True}, + {"name": "Timed Charge Enable 2", "bit_position": 1, "control": True}, + {"name": "Timed Charge Enable 3", "bit_position": 2, "control": True}, + {"name": "Timed Charge Enable 4", "bit_position": 3, "control": True}, + {"name": "Timed Charge Enable 5", "bit_position": 4, "control": True}, + {"name": "Timed Charge Enable 6", "bit_position": 5, "control": True}, + {"name": "Timed Charge Enable 7", "bit_position": 6, "control": True}, + {"name": "Timed Charge Enable 8", "bit_position": 7, "control": True}, + {"name": "Timed Charge Enable 9", "bit_position": 8, "control": True}, + {"name": "Timed Charge Enable 10", "bit_position": 9, "control": True}, + {"name": "Timed Charge Enable 11", "bit_position": 10, "control": True}, + {"name": "Timed Charge Enable 12", "bit_position": 11, "control": True}, ], }, ] diff --git a/custom_components/solisconnect/sensor_data/time_sensors.py b/custom_components/solisconnect/sensor_data/time_sensors.py index 5a4188a..52195cc 100644 --- a/custom_components/solisconnect/sensor_data/time_sensors.py +++ b/custom_components/solisconnect/sensor_data/time_sensors.py @@ -23,30 +23,30 @@ def get_time_sensors(inverter_config): if inverter_config.type == InverterType.HYBRID or InverterFeature.V2 in inverter_config.features: time_definitions.extend( [ - {"name": "Grid Time of Use Charge Start (Slot 1)", "register": 43711, "enabled": True}, - {"name": "Grid Time of Use Charge End (Slot 1)", "register": 43713, "enabled": True}, - {"name": "Grid Time of Use Discharge Start (Slot 1)", "register": 43753, "enabled": True}, - {"name": "Grid Time of Use Discharge End (Slot 1)", "register": 43755, "enabled": True}, - {"name": "Grid Time of Use Charge Start (Slot 2)", "register": 43718, "enabled": True}, - {"name": "Grid Time of Use Charge End (Slot 2)", "register": 43720, "enabled": True}, - {"name": "Grid Time of Use Discharge Start (Slot 2)", "register": 43760, "enabled": True}, - {"name": "Grid Time of Use Discharge End (Slot 2)", "register": 43762, "enabled": True}, - {"name": "Grid Time of Use Charge Start (Slot 3)", "register": 43725, "enabled": True}, - {"name": "Grid Time of Use Charge End (Slot 3)", "register": 43727, "enabled": True}, - {"name": "Grid Time of Use Discharge Start (Slot 3)", "register": 43767, "enabled": True}, - {"name": "Grid Time of Use Discharge End (Slot 3)", "register": 43769, "enabled": True}, - {"name": "Grid Time of Use Charge Start (Slot 4)", "register": 43732, "enabled": True}, - {"name": "Grid Time of Use Charge End (Slot 4)", "register": 43734, "enabled": True}, - {"name": "Grid Time of Use Discharge Start (Slot 4)", "register": 43774, "enabled": True}, - {"name": "Grid Time of Use Discharge End (Slot 4)", "register": 43776, "enabled": True}, - {"name": "Grid Time of Use Charge Start (Slot 5)", "register": 43739, "enabled": True}, - {"name": "Grid Time of Use Charge End (Slot 5)", "register": 43741, "enabled": True}, - {"name": "Grid Time of Use Discharge Start (Slot 5)", "register": 43781, "enabled": True}, - {"name": "Grid Time of Use Discharge End (Slot 5)", "register": 43783, "enabled": True}, - {"name": "Grid Time of Use Charge Start (Slot 6)", "register": 43746, "enabled": True}, - {"name": "Grid Time of Use Charge End (Slot 6)", "register": 43748, "enabled": True}, - {"name": "Grid Time of Use Discharge Start (Slot 6)", "register": 43788, "enabled": True}, - {"name": "Grid Time of Use Discharge End (Slot 6)", "register": 43790, "enabled": True}, + {"name": "Timed Charge Start 1", "register": 43711, "enabled": True, "control": True}, + {"name": "Timed Charge End 1", "register": 43713, "enabled": True, "control": True}, + {"name": "Timed Charge Start 7", "register": 43753, "enabled": True, "control": True}, + {"name": "Timed Charge End 7", "register": 43755, "enabled": True, "control": True}, + {"name": "Timed Charge Start 2", "register": 43718, "enabled": True, "control": True}, + {"name": "Timed Charge End 2", "register": 43720, "enabled": True, "control": True}, + {"name": "Timed Charge Start 8", "register": 43760, "enabled": True, "control": True}, + {"name": "Timed Charge End 8", "register": 43762, "enabled": True, "control": True}, + {"name": "Timed Charge Start 3", "register": 43725, "enabled": True, "control": True}, + {"name": "Timed Charge End 3", "register": 43727, "enabled": True, "control": True}, + {"name": "Timed Charge Start 9", "register": 43767, "enabled": True, "control": True}, + {"name": "Timed Charge End 9", "register": 43769, "enabled": True, "control": True}, + {"name": "Timed Charge Start 4", "register": 43732, "enabled": True, "control": True}, + {"name": "Timed Charge End 4", "register": 43734, "enabled": True, "control": True}, + {"name": "Timed Charge Start 10", "register": 43774, "enabled": True, "control": True}, + {"name": "Timed Charge End 10", "register": 43776, "enabled": True, "control": True}, + {"name": "Timed Charge Start 5", "register": 43739, "enabled": True, "control": True}, + {"name": "Timed Charge End 5", "register": 43741, "enabled": True, "control": True}, + {"name": "Timed Charge Start 11", "register": 43781, "enabled": True, "control": True}, + {"name": "Timed Charge End 11", "register": 43783, "enabled": True, "control": True}, + {"name": "Timed Charge Start 6", "register": 43746, "enabled": True, "control": True}, + {"name": "Timed Charge End 6", "register": 43748, "enabled": True, "control": True}, + {"name": "Timed Charge Start 12", "register": 43788, "enabled": True, "control": True}, + {"name": "Timed Charge End 12", "register": 43790, "enabled": True, "control": True}, ] ) diff --git a/custom_components/solisconnect/sensors/entity_category.py b/custom_components/solisconnect/sensors/entity_category.py index 679ce21..a8811f3 100644 --- a/custom_components/solisconnect/sensors/entity_category.py +++ b/custom_components/solisconnect/sensors/entity_category.py @@ -19,7 +19,9 @@ } -def entity_category_for_sensor_category(category: Category | None) -> EntityCategory | None: +def entity_category_for_sensor_category(category: Category | None, control: bool = False) -> EntityCategory | None: + if control: + return None if category in SETTING_CATEGORIES: return EntityCategory.CONFIG return None diff --git a/custom_components/solisconnect/sensors/solis_base_sensor.py b/custom_components/solisconnect/sensors/solis_base_sensor.py index 167fd1a..0037053 100644 --- a/custom_components/solisconnect/sensors/solis_base_sensor.py +++ b/custom_components/solisconnect/sensors/solis_base_sensor.py @@ -53,6 +53,7 @@ def __init__( poll_speed=PollSpeed.NORMAL, data_type: str | None = None, value_format: str | None = None, + control: bool = False, ): """ :param name: Sensor name @@ -92,6 +93,7 @@ def __init__( self.category = category self.identification = identification self.value_format = value_format + self.control = control self.dynamic_adjustments() @@ -234,6 +236,7 @@ def __init__(self, hass, definition, controller, identification=None): multiplier=entity.get("multiplier", 1), data_type=entity.get("data_type", None), value_format=entity.get("value_format", None), + control=entity.get("control", False), unique_id=unique_id_generator(controller, entity), poll_speed=definition.get("poll_speed", PollSpeed.NORMAL), ), diff --git a/custom_components/solisconnect/sensors/solis_binary_sensor.py b/custom_components/solisconnect/sensors/solis_binary_sensor.py index 916c85d..a83ae85 100644 --- a/custom_components/solisconnect/sensors/solis_binary_sensor.py +++ b/custom_components/solisconnect/sensors/solis_binary_sensor.py @@ -34,10 +34,12 @@ def __init__(self, hass, modbus_controller, entity_definition): self._on_value = entity_definition.get("on_value", None) self._off_value = entity_definition.get("off_value", None) self._inverted = entity_definition.get("inverted", None) + self._control = entity_definition.get("control", False) 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 - self._attr_entity_category = EntityCategory.CONFIG + self._attr_entity_category = None if self._control else EntityCategory.CONFIG + self._attr_entity_registry_enabled_default = entity_definition.get("enabled", True) async def async_added_to_hass(self) -> None: """Called when entity is added to HA.""" diff --git a/custom_components/solisconnect/sensors/solis_number_sensor.py b/custom_components/solisconnect/sensors/solis_number_sensor.py index b6d4fea..66b8523 100644 --- a/custom_components/solisconnect/sensors/solis_number_sensor.py +++ b/custom_components/solisconnect/sensors/solis_number_sensor.py @@ -44,7 +44,10 @@ def __init__(self, hass, sensor: SolisBaseSensor): self._attr_state_class = sensor.state_class self._attr_native_unit_of_measurement = sensor.unit_of_measurement self._attr_available = not sensor.hidden and sensor.enabled - self._attr_entity_category = entity_category_for_sensor_category(getattr(sensor, "category", None)) + self._attr_entity_category = entity_category_for_sensor_category( + getattr(sensor, "category", None), + getattr(sensor, "control", False), + ) self._received_values = {} diff --git a/custom_components/solisconnect/sensors/solis_protocol_select.py b/custom_components/solisconnect/sensors/solis_protocol_select.py index 61737d0..8c25d17 100644 --- a/custom_components/solisconnect/sensors/solis_protocol_select.py +++ b/custom_components/solisconnect/sensors/solis_protocol_select.py @@ -1,7 +1,6 @@ import logging from homeassistant.components.select import SelectEntity -from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.restore_state import RestoreEntity from custom_components.solisconnect.const import DOMAIN, PROTOCOL_CLOUD, PROTOCOL_MODBUS @@ -16,7 +15,6 @@ class SolisProtocolSelect(RestoreEntity, SelectEntity): """Chooses which protocol actively polls and takes writes (manual dual mode only).""" _attr_has_entity_name = True - _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:swap-horizontal" def __init__(self, hass, hub): diff --git a/custom_components/solisconnect/sensors/solis_select_entity.py b/custom_components/solisconnect/sensors/solis_select_entity.py index 948ac82..bb0560b 100644 --- a/custom_components/solisconnect/sensors/solis_select_entity.py +++ b/custom_components/solisconnect/sensors/solis_select_entity.py @@ -17,7 +17,7 @@ def __init__(self, hass, modbus_controller, entity_definition) -> None: self._register = entity_definition["register"] self._attr_name = entity_definition["name"] self._attr_unique_id = unique_id_generator(modbus_controller, entity_definition["register"], "select") - self._attr_entity_category = EntityCategory.CONFIG + self._attr_entity_category = None if entity_definition.get("control", False) else EntityCategory.CONFIG self._attr_options = [e["name"] for e in entity_definition["entities"]] self._attr_options_raw = entity_definition["entities"] diff --git a/custom_components/solisconnect/sensors/solis_sensor.py b/custom_components/solisconnect/sensors/solis_sensor.py index 594c7af..d368e8a 100644 --- a/custom_components/solisconnect/sensors/solis_sensor.py +++ b/custom_components/solisconnect/sensors/solis_sensor.py @@ -35,7 +35,10 @@ def __init__(self, hass: HomeAssistant, sensor: SolisBaseSensor): self._attr_native_unit_of_measurement = sensor.unit_of_measurement self._attr_available = not sensor.hidden and sensor.enabled self._attr_suggested_display_precision = self.decimal_count(sensor.multiplier) - self._attr_entity_category = entity_category_for_sensor_category(getattr(sensor, "category", None)) + self._attr_entity_category = entity_category_for_sensor_category( + getattr(sensor, "category", None), + getattr(sensor, "control", False), + ) self.is_added_to_hass = False self._state = None diff --git a/custom_components/solisconnect/time.py b/custom_components/solisconnect/time.py index 4b76c9e..7999a22 100644 --- a/custom_components/solisconnect/time.py +++ b/custom_components/solisconnect/time.py @@ -55,7 +55,8 @@ def __init__(self, hass, modbus_controller, entity_definition): self._attr_has_entity_name = True self._attr_available = True self._attr_device_class = entity_definition.get("device_class", None) - self._attr_entity_category = EntityCategory.CONFIG + self._attr_entity_category = None if entity_definition.get("control", False) else EntityCategory.CONFIG + self._attr_entity_registry_enabled_default = entity_definition.get("enabled", True) self._attr_available = True self._received_values = {} diff --git a/tests/test_ac_coupling.py b/tests/test_ac_coupling.py index 9bb5293..dd0c156 100644 --- a/tests/test_ac_coupling.py +++ b/tests/test_ac_coupling.py @@ -12,6 +12,10 @@ def _switch_names(inverter_config: InverterConfig) -> set[str]: return {entity["name"] for group in get_switch_sensors(inverter_config) for entity in group["entities"]} +def _switch_entities(inverter_config: InverterConfig, register: int) -> list[dict]: + return [entity for group in get_switch_sensors(inverter_config) if group.get("register") == register for entity in group["entities"]] + + def test_ac_coupling_feature_enabled(): """Test that AC_COUPLING feature is added when option is enabled.""" options = InverterOptions(ac_coupling=True) @@ -109,6 +113,67 @@ def test_ac_coupling_switches_enabled_with_ac_coupling_feature(): assert "With Generator" not in names +def test_mode_switch_names_and_disabled_bits(): + template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P") + config = template.clone_with_options(InverterOptions(), "S2_WL_ST") + + 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" + assert by_bit[2]["name"] == "Mode - Off-Grid" + assert by_bit[3]["name"] == "Mode - Battery Wake Up" + assert by_bit[4]["name"] == "Mode - Backup" + assert by_bit[5]["name"] == "Mode - Grid Charge" + assert by_bit[6]["name"] == "Mode - Feed In Priority" + assert by_bit[7]["name"] == "Mode - Night OVD Retain" + assert by_bit[10]["name"] == "Mode - Battery Condition" + assert by_bit[11]["name"] == "Mode - Peak Shaving" + assert by_bit[8]["enabled"] is False + assert by_bit[9]["enabled"] is False + + +def test_timed_charge_enable_switch_names(): + template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P") + config = template.clone_with_options(InverterOptions(), "S2_WL_ST") + + names = [entity["name"] for entity in _switch_entities(config, 43707)] + + assert names == [f"Timed Charge Enable {slot}" for slot in range(1, 13)] + + +def test_timed_charge_number_names_are_normalized(): + from custom_components.solisconnect.sensor_data.hybrid_sensors import hybrid_sensors + + entities = {int(entity["register"][0]): entity for group in hybrid_sensors for entity in group.get("entities", []) if entity.get("register")} + + assert entities[43708]["name"] == "Timed Charge SOC 1" + assert entities[43709]["name"] == "Timed Charge Current 1" + assert entities[43710]["name"] == "Timed Charge Voltage 1" + assert entities[43750]["name"] == "Timed Charge SOC 7" + assert entities[43751]["name"] == "Timed Charge Current 7" + assert entities[43752]["name"] == "Timed Charge Voltage 7" + assert entities[43785]["name"] == "Timed Charge SOC 12" + assert entities[43786]["name"] == "Timed Charge Current 12" + assert entities[43787]["name"] == "Timed Charge Voltage 12" + assert entities[43024]["name"] == "Backup Mode SOC" + + +def test_timed_charge_time_names_are_normalized(): + from custom_components.solisconnect.sensor_data.time_sensors import get_time_sensors + + template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P") + config = template.clone_with_options(InverterOptions(), "S2_WL_ST") + entities = {entity["register"]: entity for entity in get_time_sensors(config)} + + assert entities[43711]["name"] == "Timed Charge Start 1" + assert entities[43713]["name"] == "Timed Charge End 1" + assert entities[43753]["name"] == "Timed Charge Start 7" + assert entities[43755]["name"] == "Timed Charge End 7" + assert entities[43788]["name"] == "Timed Charge Start 12" + assert entities[43790]["name"] == "Timed Charge End 12" + + def test_parallel_feature_disabled_by_default(): """PARALLEL is opt-in; default installs must not advertise it on the template.""" config = InverterConfig(model="S5-EH1P", wattage=[5000], phases=1, type=InverterType.HYBRID) diff --git a/tests/test_entity_categories_and_versions.py b/tests/test_entity_categories_and_versions.py index 8fc9b0b..6aaf81a 100644 --- a/tests/test_entity_categories_and_versions.py +++ b/tests/test_entity_categories_and_versions.py @@ -6,6 +6,7 @@ from custom_components.solisconnect.sensors.solis_base_sensor import SolisBaseSensor from custom_components.solisconnect.sensors.solis_binary_sensor import SolisBinaryEntity from custom_components.solisconnect.sensors.solis_number_sensor import SolisNumberEntity +from custom_components.solisconnect.sensors.solis_protocol_select import SolisProtocolSelect from custom_components.solisconnect.sensors.solis_select_entity import SolisSelectEntity from custom_components.solisconnect.sensors.solis_sensor import SolisSensor from custom_components.solisconnect.time import SolisTimeEntity @@ -61,18 +62,78 @@ def test_setting_category_sensor_is_configuration_entity(hass): assert SolisNumberEntity(hass, sensor)._attr_entity_category == EntityCategory.CONFIG -def test_configurable_platform_entities_are_configuration_entities(hass): +def test_explicit_control_sensor_is_not_configuration_entity(hass): + sensor = MagicMock(spec=SolisBaseSensor) + sensor.controller = _controller() + sensor.name = "Timed Charge SOC 1" + sensor.registrars = [43708] + sensor.write_register = 43708 + sensor.device_class = None + sensor.unit_of_measurement = "%" + sensor.state_class = None + sensor.multiplier = 1 + sensor.enabled = True + sensor.hidden = False + sensor.unique_id = "timed_charge_soc_1" + sensor.default = 90 + sensor.min_value = 0 + sensor.max_value = 100 + sensor.step = 1 + sensor.category = Category.HYBRID_MODE_SETTING + sensor.control = True + sensor.poll_speed = None + + assert SolisSensor(hass, sensor)._attr_entity_category is None + assert SolisNumberEntity(hass, sensor)._attr_entity_category is None + + +def test_explicit_control_platform_entities_are_not_configuration_entities(hass): controller = _controller() controller.device_id = 1 controller.host = "1.2.3.4" - switch = SolisBinaryEntity(hass, controller, {"register": 43110, "bit_position": 0, "name": "Self-Use Mode"}) + switch = SolisBinaryEntity( + hass, + controller, + {"register": 43110, "bit_position": 0, "name": "Mode - Self Use", "control": True}, + ) select = SolisSelectEntity( hass, controller, - {"register": 43110, "name": "Storage Mode", "entities": [{"name": "Self Use", "bit_position": 0}]}, + {"register": 43110, "name": "Work Mode", "control": True, "entities": [{"name": "Self Use", "bit_position": 0}]}, ) - time = SolisTimeEntity(hass, controller, {"register": 43143, "name": "Charge Start", "unique": "charge_start"}) + time = SolisTimeEntity( + hass, + controller, + {"register": 43711, "name": "Timed Charge Start 1", "unique": "charge_start", "control": True}, + ) + + assert switch._attr_entity_category is None + assert select._attr_entity_category is None + assert time._attr_entity_category is None + + +def test_non_control_platform_entities_are_configuration_entities(hass): + controller = _controller() + controller.device_id = 1 + controller.host = "1.2.3.4" + switch = SolisBinaryEntity(hass, controller, {"register": 43073, "bit_position": 4, "name": "Grid feed in power limit switch"}) + select = SolisSelectEntity( + hass, + controller, + {"register": 43132, "name": "RC Grid Adjustment", "entities": [{"name": "OFF", "on_value": 0}]}, + ) + time = SolisTimeEntity(hass, controller, {"register": 43143, "name": "Time-Charging Charge Start (Slot 1)", "unique": "charge_start"}) assert switch._attr_entity_category == EntityCategory.CONFIG assert select._attr_entity_category == EntityCategory.CONFIG assert time._attr_entity_category == EntityCategory.CONFIG + + +def test_active_protocol_select_is_control_entity(hass): + hub = MagicMock() + hub.serial_number = "SN123" + hub.active_protocol = "modbus" + + entity = SolisProtocolSelect(hass, hub) + + assert getattr(entity, "_attr_entity_category", None) is None