Skip to content

Commit 8181547

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 8181547

17 files changed

Lines changed: 421 additions & 175 deletions

File tree

custom_components/solax_modbus/__init__.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from homeassistant.core import HomeAssistant, callback
3636
from homeassistant.exceptions import HomeAssistantError
37+
from homeassistant.helpers import device_registry as dr
3738
from homeassistant.helpers import entity_registry as er
3839
from homeassistant.helpers.device_registry import DeviceInfo
3940
from homeassistant.helpers.event import async_track_time_interval
@@ -101,6 +102,9 @@
101102
from .const import (
102103
CONF_READ_EPS as CONF_READ_EPS,
103104
)
105+
from .const import (
106+
CONF_READ_GEN as CONF_READ_GEN,
107+
)
104108
from .const import (
105109
DEFAULT_INTERFACE as DEFAULT_INTERFACE,
106110
)
@@ -119,6 +123,9 @@
119123
from .const import (
120124
DEFAULT_READ_EPS as DEFAULT_READ_EPS,
121125
)
126+
from .const import (
127+
DEFAULT_READ_GEN as DEFAULT_READ_GEN,
128+
)
122129
from .const import (
123130
DEFAULT_SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL,
124131
)
@@ -421,9 +428,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
421428
await hub.async_init()
422429

423430
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
431+
432+
await async_cleanup_disabled_devices(hass, entry, hub)
424433
return True
425434

426435

436+
# Device groups that a config option can switch off, and the option controlling each.
437+
# The Energy Dashboard device is deliberately absent: it also hosts the dashboard
438+
# configuration switches, which exist independently of the dashboard option.
439+
GATED_DEVICE_GROUPS: dict[str, tuple[str, bool]] = {
440+
"dry_contact": (CONF_READ_DCB, DEFAULT_READ_DCB),
441+
"external_generator": (CONF_READ_GEN, DEFAULT_READ_GEN),
442+
}
443+
444+
445+
def device_group_of(device_entry: Any) -> str | None:
446+
"""Return the group name encoded in a device's solax identifiers, if any."""
447+
for identifier in device_entry.identifiers:
448+
parts = tuple(identifier)
449+
if parts and parts[0] == DOMAIN and len(parts) > 2:
450+
return str(parts[2])
451+
return None
452+
453+
454+
async def async_cleanup_disabled_devices(hass: HomeAssistant, entry: ConfigEntry, hub: Any) -> None:
455+
"""Remove devices (and their entities) for device groups switched off in the options.
456+
457+
Home Assistant never removes devices on its own, so a device group that is no
458+
longer created would otherwise linger in the registry with stale entities.
459+
"""
460+
configdict = entry.options if entry.options else entry.data
461+
dev_registry = dr.async_get(hass)
462+
for device_entry in dr.async_entries_for_config_entry(dev_registry, entry.entry_id):
463+
group = device_group_of(device_entry)
464+
gate = GATED_DEVICE_GROUPS.get(group) if group else None
465+
if gate is None:
466+
continue
467+
option, default = gate
468+
if not configdict.get(option, default):
469+
_LOGGER.info(f"{hub.name}: removing device {device_entry.name} - option {option} is disabled")
470+
dev_registry.async_remove_device(device_entry.id)
471+
472+
473+
async def async_remove_config_entry_device(hass: HomeAssistant, entry: ConfigEntry, device_entry: Any) -> bool:
474+
"""Allow the user to delete devices this integration no longer provides.
475+
476+
The main inverter device of a running hub is kept; everything else (sub-devices
477+
of a disabled feature, renamed hubs, removed battery packs) may be deleted.
478+
"""
479+
group = device_group_of(device_entry)
480+
if group != INVERTER_IDENT:
481+
return True
482+
hub_name = (entry.options or entry.data).get(CONF_NAME)
483+
return not any(tuple(i)[:2] == (DOMAIN, hub_name) for i in device_entry.identifiers)
484+
485+
427486
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
428487
"""Unload SolaX modbus entry and tear down transports cleanly."""
429488
name = entry.options.get("name")
@@ -918,6 +977,26 @@ def _warn_duplicate_inverter_configuration(self, interval: int) -> None:
918977
describe_modbus_connection(identity),
919978
)
920979

980+
def device_group_enabled(self, group: str | None) -> bool:
981+
"""Return whether a named device group is enabled in this entry's options."""
982+
if not group:
983+
return True
984+
configdict = self.entry.options if self.entry.options else self.entry.data
985+
if group == "dry_contact":
986+
return bool(configdict.get(CONF_READ_DCB, DEFAULT_READ_DCB))
987+
if group == "external_generator":
988+
return bool(configdict.get(CONF_READ_GEN, DEFAULT_READ_GEN))
989+
return True
990+
991+
def group_device_info(self, group: str) -> DeviceInfo:
992+
"""Return the DeviceInfo for a named sub-device (e.g. "dry_contact")."""
993+
return DeviceInfo(
994+
identifiers=cast(set[tuple[str, str]], {(DOMAIN, self._name, group)}),
995+
name=f"{self._name} {group.replace('_', ' ').title()}",
996+
manufacturer=self.plugin.plugin_manufacturer,
997+
via_device=cast(tuple[str, str], (DOMAIN, self._name, INVERTER_IDENT)),
998+
)
999+
9211000
def device_group_key(self, device_info: DeviceInfo) -> str:
9221001
"""Extract device group key from device_info identifiers.
9231002

custom_components/solax_modbus/config_flow.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
CONF_READ_BATTERY,
4747
CONF_READ_DCB,
4848
CONF_READ_EPS,
49+
CONF_READ_GEN,
4950
CONF_READ_PM,
5051
CONF_SCAN_INTERVAL_FAST,
5152
CONF_SCAN_INTERVAL_MEDIUM,
@@ -64,6 +65,7 @@
6465
DEFAULT_READ_BATTERY,
6566
DEFAULT_READ_DCB,
6667
DEFAULT_READ_EPS,
68+
DEFAULT_READ_GEN,
6769
DEFAULT_READ_PM,
6870
DEFAULT_SCAN_INTERVAL,
6971
DEFAULT_SERIAL_PORT,
@@ -155,6 +157,7 @@ def _configured_hub_names(handler: SchemaCommonFlowHandler) -> set[str]:
155157
vol.Optional(CONF_ENERGY_DASHBOARD_DEVICE, default=DEFAULT_ENERGY_DASHBOARD_DEVICE): bool,
156158
vol.Optional(CONF_READ_EPS, default=DEFAULT_READ_EPS): bool,
157159
vol.Optional(CONF_READ_DCB, default=DEFAULT_READ_DCB): bool,
160+
vol.Optional(CONF_READ_GEN, default=DEFAULT_READ_GEN): bool,
158161
vol.Optional(CONF_READ_PM, default=DEFAULT_READ_PM): bool,
159162
vol.Optional(CONF_TIME_OUT, default=DEFAULT_TIME_OUT): int,
160163
}
@@ -177,6 +180,7 @@ def _configured_hub_names(handler: SchemaCommonFlowHandler) -> set[str]:
177180
vol.Optional(CONF_ENERGY_DASHBOARD_DEVICE, default=DEFAULT_ENERGY_DASHBOARD_DEVICE): bool,
178181
vol.Optional(CONF_READ_EPS, default=DEFAULT_READ_EPS): bool,
179182
vol.Optional(CONF_READ_DCB, default=DEFAULT_READ_DCB): bool,
183+
vol.Optional(CONF_READ_GEN, default=DEFAULT_READ_GEN): 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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class UnitOfReactivePower(StrEnum): # type: ignore[no-redef]
5050
CONF_INVERTER_POWER_KW = "inverter_power_kw"
5151
CONF_READ_EPS = "read_eps"
5252
CONF_READ_DCB = "read_dcb"
53+
CONF_READ_GEN = "read_gen"
5354
CONF_READ_PM = "read_pm"
5455
CONF_MODBUS_ADDR = "read_modbus_addr"
5556
CONF_INTERFACE = "interface"
@@ -66,6 +67,7 @@ class UnitOfReactivePower(StrEnum): # type: ignore[no-redef]
6667
DEFAULT_SERIAL_PORT = "/dev/ttyUSB0"
6768
DEFAULT_READ_EPS = False
6869
DEFAULT_READ_DCB = False
70+
DEFAULT_READ_GEN = True
6971
DEFAULT_READ_PM = False
7072
DEFAULT_BAUDRATE = "19200"
7173
DEFAULT_PLUGIN = "solax"
@@ -238,6 +240,8 @@ class BaseModbusSensorEntityDescription(SensorEntityDescription):
238240
"""Base class for modbus sensor declarations."""
239241

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

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

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

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

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

408420

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

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)