Skip to content

Commit 6c41872

Browse files
committed
Hybrid Gen4: dry-contact section as a sub-device with dependency-driven availability
The Gen4 dry-contact load-management controls become their own HA device ("<hub> Dry Contact", linked to the inverter via via_device, same pattern as the battery pack devices), and each entity is only available while its full parent chain in the app's dependency tree is active: Dry Contact Mode (0xC3 -> 0x12F): Generator Control | Load Management Load Management (0xC2 -> 0x12E): Disabled | Manual Mode | Smart Save Manual Mode switch (0xB6 -> 0x122): only in Manual Mode Threshold On Feed-in Power (0xB7 -> 0x123) Threshold Off Consumption (0xB9 -> 0x125) | Threshold Off Battery SOC (0xBA -> 0x126) | only in Smart Save Minimum Per On Signal (0xBB -> 0x127) | Maximum Per Day (0xBC -> 0x128) | Schedule (0xBD -> 0x129) / Work Start/End Time 1 and 2 (0xBE-0xC1 -> 0x12A-0x12D): only while Schedule is enabled All register mappings were verified live on an X3-Hybrid-G4 15kW by changing each setting in the SolaX app and tracking the readback. New generic infrastructure, reusable by any plugin: entity descriptions gain device_group (assigns the entity to a named sub-device) and active_when ({sensor_key: allowed values} evaluated against polled hub.data; keys not polled on a model never block availability). The number, select, switch and time platforms honour both. The "Read Dry Contact Box" setup question is removed: the dry-contact entities are managed dynamically by the availability tree, so the DCB feature flag is no longer needed for them and no longer gates anything in the SolaX plugin (other brand plugins never had DCB-gated entities; their unused boilerplate is untouched). Display names follow the app: Work Mode -> Load Management (option Manual -> Manual Mode), Manual Mode Control -> Manual Mode, Feedin On Power -> Threshold On Feed-in Power, Consume Off Power -> Threshold Off Consumption, Switch Off SOC -> Threshold Off Battery SOC, Maximum Per Day On -> Maximum Per Day. Keys and entity ids are unchanged. Generator management entities are intentionally untouched and stay on the main inverter device.
1 parent 27875b3 commit 6c41872

17 files changed

Lines changed: 356 additions & 193 deletions

File tree

custom_components/solax_modbus/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,22 @@ def _warn_duplicate_inverter_configuration(self, interval: int) -> None:
918918
describe_modbus_connection(identity),
919919
)
920920

921+
def device_group_enabled(self, group: str | None) -> bool:
922+
"""Return whether a named device group is enabled in this entry's options."""
923+
if not group:
924+
return True
925+
configdict = self.entry.options if self.entry.options else self.entry.data
926+
return bool(configdict.get(f"{group}_device", True))
927+
928+
def group_device_info(self, group: str) -> DeviceInfo:
929+
"""Return the DeviceInfo for a named sub-device (e.g. "dry_contact")."""
930+
return DeviceInfo(
931+
identifiers=cast(set[tuple[str, str]], {(DOMAIN, self._name, group)}),
932+
name=f"{self._name} {group.replace('_', ' ').title()}",
933+
manufacturer=self.plugin.plugin_manufacturer,
934+
via_device=cast(tuple[str, str], (DOMAIN, self._name, INVERTER_IDENT)),
935+
)
936+
921937
def device_group_key(self, device_info: DeviceInfo) -> str:
922938
"""Extract device group key from device_info identifiers.
923939

custom_components/solax_modbus/config_flow.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,15 @@
3737
from .const import (
3838
CONF_BAUDRATE,
3939
CONF_CORE_HUB,
40+
CONF_DRY_CONTACT_DEVICE,
4041
CONF_ENERGY_DASHBOARD_DEVICE,
42+
CONF_EXTERNAL_GENERATOR_DEVICE,
4143
CONF_INTERFACE,
4244
CONF_INVERTER_NAME_SUFFIX,
4345
CONF_INVERTER_POWER_KW,
4446
CONF_MODBUS_ADDR,
4547
CONF_PLUGIN,
4648
CONF_READ_BATTERY,
47-
CONF_READ_DCB,
4849
CONF_READ_EPS,
4950
CONF_READ_PM,
5051
CONF_SCAN_INTERVAL_FAST,
@@ -53,7 +54,9 @@
5354
CONF_TCP_TYPE,
5455
CONF_TIME_OUT,
5556
DEFAULT_BAUDRATE,
57+
DEFAULT_DRY_CONTACT_DEVICE,
5658
DEFAULT_ENERGY_DASHBOARD_DEVICE,
59+
DEFAULT_EXTERNAL_GENERATOR_DEVICE,
5760
# PLUGIN_PATH_OLDSTYLE,
5861
DEFAULT_INVERTER_NAME_SUFFIX,
5962
DEFAULT_INVERTER_POWER_KW,
@@ -62,7 +65,6 @@
6265
DEFAULT_PLUGIN,
6366
DEFAULT_PORT,
6467
DEFAULT_READ_BATTERY,
65-
DEFAULT_READ_DCB,
6668
DEFAULT_READ_EPS,
6769
DEFAULT_READ_PM,
6870
DEFAULT_SCAN_INTERVAL,
@@ -153,8 +155,9 @@ def _configured_hub_names(handler: SchemaCommonFlowHandler) -> set[str]:
153155
vol.Optional(CONF_INVERTER_NAME_SUFFIX, description={"suggested_value": DEFAULT_INVERTER_NAME_SUFFIX}): str,
154156
vol.Optional(CONF_INVERTER_POWER_KW, default=DEFAULT_INVERTER_POWER_KW): cv.positive_int,
155157
vol.Optional(CONF_ENERGY_DASHBOARD_DEVICE, default=DEFAULT_ENERGY_DASHBOARD_DEVICE): bool,
158+
vol.Optional(CONF_DRY_CONTACT_DEVICE, default=DEFAULT_DRY_CONTACT_DEVICE): bool,
159+
vol.Optional(CONF_EXTERNAL_GENERATOR_DEVICE, default=DEFAULT_EXTERNAL_GENERATOR_DEVICE): bool,
156160
vol.Optional(CONF_READ_EPS, default=DEFAULT_READ_EPS): bool,
157-
vol.Optional(CONF_READ_DCB, default=DEFAULT_READ_DCB): bool,
158161
vol.Optional(CONF_READ_PM, default=DEFAULT_READ_PM): bool,
159162
vol.Optional(CONF_TIME_OUT, default=DEFAULT_TIME_OUT): int,
160163
}
@@ -175,8 +178,9 @@ def _configured_hub_names(handler: SchemaCommonFlowHandler) -> set[str]:
175178
vol.Optional(CONF_INVERTER_NAME_SUFFIX): str,
176179
vol.Optional(CONF_INVERTER_POWER_KW, default=DEFAULT_INVERTER_POWER_KW): cv.positive_int,
177180
vol.Optional(CONF_ENERGY_DASHBOARD_DEVICE, default=DEFAULT_ENERGY_DASHBOARD_DEVICE): bool,
181+
vol.Optional(CONF_DRY_CONTACT_DEVICE, default=DEFAULT_DRY_CONTACT_DEVICE): bool,
182+
vol.Optional(CONF_EXTERNAL_GENERATOR_DEVICE, default=DEFAULT_EXTERNAL_GENERATOR_DEVICE): bool,
178183
vol.Optional(CONF_READ_EPS, default=DEFAULT_READ_EPS): bool,
179-
vol.Optional(CONF_READ_DCB, default=DEFAULT_READ_DCB): bool,
180184
vol.Optional(CONF_READ_PM, default=DEFAULT_READ_PM): bool,
181185
vol.Optional(CONF_TIME_OUT, default=DEFAULT_TIME_OUT): int,
182186
}

custom_components/solax_modbus/const.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ class UnitOfReactivePower(StrEnum): # type: ignore[no-redef]
6060
CONF_READ_BATTERY = "read_battery"
6161
CONF_CORE_HUB = "read_core_hub"
6262
CONF_ENERGY_DASHBOARD_DEVICE = "energy_dashboard_device"
63+
CONF_DRY_CONTACT_DEVICE = "dry_contact_device"
64+
CONF_EXTERNAL_GENERATOR_DEVICE = "external_generator_device"
6365
CONF_DEBUG_SETTINGS = "debug_settings"
6466
ATTR_MANUFACTURER = "SolaX Power"
6567
DEFAULT_INTERFACE = "tcp"
@@ -73,6 +75,8 @@ class UnitOfReactivePower(StrEnum): # type: ignore[no-redef]
7375
ENERGY_DASHBOARD_DEVICE_ENABLED = True
7476
ENERGY_DASHBOARD_DEVICE_DISABLED = False
7577
DEFAULT_ENERGY_DASHBOARD_DEVICE = ENERGY_DASHBOARD_DEVICE_ENABLED
78+
DEFAULT_DRY_CONTACT_DEVICE = True
79+
DEFAULT_EXTERNAL_GENERATOR_DEVICE = True
7680
PLUGIN_PATH = f"{pathlib.Path(__file__).parent.absolute()}/plugin_*.py"
7781
SLEEPMODE_NONE = None
7882
SLEEPMODE_ZERO = 0 # when no communication at all
@@ -238,6 +242,8 @@ class BaseModbusSensorEntityDescription(SensorEntityDescription):
238242
"""Base class for modbus sensor declarations."""
239243

240244
allowedtypes: int = 0 # overload with ALLDEFAULT from plugin
245+
device_group: str | None = None # assign the entity to a named sub-device instead of the main inverter device
246+
active_when: dict[str, tuple[Any, ...]] | None = None # {sensor_key: allowed values}; entity is unavailable unless every polled key matches
241247
order32: str | None = None # per-sensor 32-bit word order override ("big"/"little"); None = plugin default
242248
modbus_min: int | None = None # Minimum protocol version as reported by register 0x82 (e.g. 100 for V001.00); not the document revision.
243249
modbus_max: int | None = None # Maximum protocol version as reported by register 0x82.
@@ -305,6 +311,8 @@ class BaseModbusSelectEntityDescription(SelectEntityDescription):
305311
"""Base class for modbus select declarations."""
306312

307313
allowedtypes: int = 0 # overload with ALLDEFAULT from plugin
314+
device_group: str | None = None # assign the entity to a named sub-device instead of the main inverter device
315+
active_when: dict[str, tuple[Any, ...]] | None = None # {sensor_key: allowed values}; entity is unavailable unless every polled key matches
308316
modbus_min: int | None = None # Minimum protocol version as reported by register 0x82 (e.g. 100 for V001.00); not the document revision.
309317
modbus_max: int | None = None # Maximum protocol version as reported by register 0x82.
310318
register: int | None = None
@@ -325,6 +333,8 @@ class BaseModbusSwitchEntityDescription(SwitchEntityDescription):
325333
"""Base class for modbus switch declarations."""
326334

327335
allowedtypes: int = 0 # overload with ALLDEFAULT from plugin
336+
device_group: str | None = None # assign the entity to a named sub-device instead of the main inverter device
337+
active_when: dict[str, tuple[Any, ...]] | None = None # {sensor_key: allowed values}; entity is unavailable unless every polled key matches
328338
modbus_min: int | None = None # Minimum protocol version as reported by register 0x82 (e.g. 100 for V001.00); not the document revision.
329339
modbus_max: int | None = None # Maximum protocol version as reported by register 0x82.
330340
register: int | None = None
@@ -344,6 +354,8 @@ class BaseModbusTimeEntityDescription(TimeEntityDescription):
344354
"""Base class for modbus time declarations."""
345355

346356
allowedtypes: int = 0 # overload with ALLDEFAULT from plugin
357+
device_group: str | None = None # assign the entity to a named sub-device instead of the main inverter device
358+
active_when: dict[str, tuple[Any, ...]] | None = None # {sensor_key: allowed values}; entity is unavailable unless every polled key matches
347359
modbus_min: int | None = None # Minimum protocol version as reported by register 0x82 (e.g. 100 for V001.00); not the document revision.
348360
modbus_max: int | None = None # Maximum protocol version as reported by register 0x82.
349361
scale: float | dict[Any, Any] | Callable[[Any, Any, dict[str, Any]], Any] = 1
@@ -373,6 +385,8 @@ class BaseModbusNumberEntityDescription(NumberEntityDescription):
373385
"""Base class for modbus number declarations."""
374386

375387
allowedtypes: int = 0 # overload with ALLDEFAULT from plugin
388+
device_group: str | None = None # assign the entity to a named sub-device instead of the main inverter device
389+
active_when: dict[str, tuple[Any, ...]] | None = None # {sensor_key: allowed values}; entity is unavailable unless every polled key matches
376390
modbus_min: int | None = None # Minimum protocol version as reported by register 0x82 (e.g. 100 for V001.00); not the document revision.
377391
modbus_max: int | None = None # Maximum protocol version as reported by register 0x82.
378392
register: int | None = None
@@ -406,6 +420,22 @@ def modbus_protocol_version(hub: Any) -> int:
406420
return 0
407421

408422

423+
def matches_active_when(hub: Any, description: Any) -> bool:
424+
"""Return whether an entity's active_when conditions are currently met.
425+
426+
Conditions reference polled hub.data keys; a key that is absent from
427+
hub.data (not polled on this model) does not block availability.
428+
"""
429+
conditions = getattr(description, "active_when", None)
430+
if not conditions:
431+
return True
432+
data = getattr(hub, "data", {})
433+
for key, allowed in conditions.items():
434+
if key in data and data[key] not in allowed:
435+
return False
436+
return True
437+
438+
409439
def matches_modbus_protocol(hub: Any, description: Any) -> bool:
410440
"""Return whether a description applies to the detected Modbus protocol version.
411441

custom_components/solax_modbus/number.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
WRITE_MULTISINGLE_MODBUS,
2222
WRITE_SINGLE_MODBUS,
2323
BaseModbusNumberEntityDescription,
24+
matches_active_when,
2425
matches_modbus_protocol,
2526
)
2627

@@ -52,10 +53,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
5253
) in number_info.read_scale_exceptions:
5354
if hub.seriesnumber.startswith(prefix):
5455
newdescr = replace(number_info, read_scale=value)
55-
if plugin.matchInverterWithMask(hub._invertertype, newdescr.allowedtypes, hub.seriesnumber, newdescr.blacklist) and matches_modbus_protocol(
56-
hub, newdescr
56+
if (
57+
plugin.matchInverterWithMask(hub._invertertype, newdescr.allowedtypes, hub.seriesnumber, newdescr.blacklist)
58+
and matches_modbus_protocol(hub, newdescr)
59+
and hub.device_group_enabled(newdescr.device_group)
5760
):
58-
number = SolaXModbusNumber(hub_name, hub, modbus_addr, hub.device_info, newdescr)
61+
device_info = hub.group_device_info(newdescr.device_group) if newdescr.device_group else hub.device_info
62+
number = SolaXModbusNumber(hub_name, hub, modbus_addr, device_info, newdescr)
5963
if newdescr.write_method == WRITE_DATA_LOCAL:
6064
hub.writeLocals[newdescr.key] = newdescr
6165
# Use the explicit sensor_key if provided, otherwise fall back to the number's own key.
@@ -175,6 +179,10 @@ def should_poll(self) -> bool:
175179
"""Data is delivered by the hub."""
176180
return False
177181

182+
@property
183+
def available(self) -> bool:
184+
return matches_active_when(self._hub, self.entity_description)
185+
178186
@property
179187
def name(self) -> str:
180188
"""Return the entity name (description name only — the device name provides context)."""

0 commit comments

Comments
 (0)