Skip to content

Commit 12a38d9

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 12a38d9

18 files changed

Lines changed: 629 additions & 184 deletions

File tree

custom_components/solax_modbus/__init__.py

Lines changed: 134 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
@@ -95,12 +96,21 @@
9596
WRITE_SINGLE_MODBUS,
9697
PollOutcome,
9798
)
99+
from .const import (
100+
CONF_ENERGY_DASHBOARD_DEVICE as CONF_ENERGY_DASHBOARD_DEVICE,
101+
)
98102
from .const import (
99103
CONF_READ_DCB as CONF_READ_DCB,
100104
)
101105
from .const import (
102106
CONF_READ_EPS as CONF_READ_EPS,
103107
)
108+
from .const import (
109+
CONF_READ_GEN as CONF_READ_GEN,
110+
)
111+
from .const import (
112+
DEFAULT_ENERGY_DASHBOARD_DEVICE as DEFAULT_ENERGY_DASHBOARD_DEVICE,
113+
)
104114
from .const import (
105115
DEFAULT_INTERFACE as DEFAULT_INTERFACE,
106116
)
@@ -119,6 +129,9 @@
119129
from .const import (
120130
DEFAULT_READ_EPS as DEFAULT_READ_EPS,
121131
)
132+
from .const import (
133+
DEFAULT_READ_GEN as DEFAULT_READ_GEN,
134+
)
122135
from .const import (
123136
DEFAULT_SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL,
124137
)
@@ -128,6 +141,9 @@
128141
from .const import (
129142
WRITE_MULTISINGLE_MODBUS as WRITE_MULTISINGLE_MODBUS,
130143
)
144+
from .const import (
145+
matches_active_when as matches_active_when,
146+
)
131147
from .modbus_transport import CoreModbusTransport, ModbusTransport, NativeModbusTransport, UnavailableModbusTransport
132148
from .pymodbus_compat import DataType, convert_from_registers, convert_to_registers, pymodbus_version_info
133149
from .sensor import SolaXModbusSensor
@@ -421,9 +437,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
421437
await hub.async_init()
422438

423439
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
440+
441+
await async_cleanup_disabled_devices(hass, entry, hub)
424442
return True
425443

426444

445+
# Device groups that a config option can switch off, and the option controlling each.
446+
# Display names for sub-devices, where a plain title-case of the group key would read badly.
447+
DEVICE_GROUP_NAMES: dict[str, str] = {
448+
"dry_contact": "Dry Contact",
449+
"external_generator": "External Generator",
450+
"eps": "EPS",
451+
}
452+
453+
GATED_DEVICE_GROUPS: dict[str, tuple[str, bool]] = {
454+
"dry_contact": (CONF_READ_DCB, DEFAULT_READ_DCB),
455+
"external_generator": (CONF_READ_GEN, DEFAULT_READ_GEN),
456+
"eps": (CONF_READ_EPS, DEFAULT_READ_EPS),
457+
"ENERGY_DASHBOARD": (CONF_ENERGY_DASHBOARD_DEVICE, DEFAULT_ENERGY_DASHBOARD_DEVICE),
458+
}
459+
460+
461+
def device_group_of(device_entry: Any) -> str | None:
462+
"""Return the group name encoded in a device's solax identifiers, if any."""
463+
for identifier in device_entry.identifiers:
464+
parts = tuple(identifier)
465+
if parts and parts[0] == DOMAIN and len(parts) > 2:
466+
return str(parts[2])
467+
return None
468+
469+
470+
async def async_cleanup_disabled_devices(hass: HomeAssistant, entry: ConfigEntry, hub: Any) -> None:
471+
"""Remove devices (and their entities) for device groups switched off in the options.
472+
473+
Home Assistant never removes devices on its own, so a device group that is no
474+
longer created would otherwise linger in the registry with stale entities.
475+
"""
476+
configdict = entry.options if entry.options else entry.data
477+
dev_registry = dr.async_get(hass)
478+
for device_entry in dr.async_entries_for_config_entry(dev_registry, entry.entry_id):
479+
group = device_group_of(device_entry)
480+
gate = GATED_DEVICE_GROUPS.get(group) if group else None
481+
if gate is None:
482+
continue
483+
option, default = gate
484+
if not configdict.get(option, default):
485+
_LOGGER.info(f"{hub.name}: removing device {device_entry.name} - option {option} is disabled")
486+
dev_registry.async_remove_device(device_entry.id)
487+
488+
427489
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
428490
"""Unload SolaX modbus entry and tear down transports cleanly."""
429491
name = entry.options.get("name")
@@ -602,6 +664,7 @@ def __init__(
602664
self.selectEntities: dict[Any, Any] = {}
603665
self.switchEntities: dict[Any, Any] = {}
604666
self.timeEntities: dict[Any, Any] = {}
667+
self.gatedEntities: list[dict[str, Any]] = [] # descriptions with active_when, added/removed as their branch activates
605668
self.entity_dependencies: dict[str, list[str]] = {} # Maps a sensor key to a list of data control keys that use the sensor as data source
606669
# self.preventSensors = {} # sensors with prevent_update = True
607670
self.writeLocals: dict[Any, Any] = {} # key to description lookup dict for write_method = WRITE_DATA_LOCAL entities
@@ -918,6 +981,75 @@ def _warn_duplicate_inverter_configuration(self, interval: int) -> None:
918981
describe_modbus_connection(identity),
919982
)
920983

984+
def device_group_enabled(self, group: str | None) -> bool:
985+
"""Return whether a named device group is enabled in this entry's options."""
986+
if not group:
987+
return True
988+
configdict = self.entry.options if self.entry.options else self.entry.data
989+
if group == "dry_contact":
990+
return bool(configdict.get(CONF_READ_DCB, DEFAULT_READ_DCB))
991+
if group == "external_generator":
992+
return bool(configdict.get(CONF_READ_GEN, DEFAULT_READ_GEN))
993+
return True
994+
995+
def device_group_display_name(self, group: str) -> str:
996+
"""Return the human readable name of a device group."""
997+
return DEVICE_GROUP_NAMES.get(group, group.replace("_", " ").title())
998+
999+
def group_device_info(self, group: str) -> DeviceInfo:
1000+
"""Return the DeviceInfo for a named sub-device (e.g. "dry_contact")."""
1001+
return DeviceInfo(
1002+
identifiers=cast(set[tuple[str, str]], {(DOMAIN, self._name, group)}),
1003+
name=f"{self._name} {DEVICE_GROUP_NAMES.get(group, group.replace('_', ' ').title())}",
1004+
manufacturer=self.plugin.plugin_manufacturer,
1005+
via_device=cast(tuple[str, str], (DOMAIN, self._name, INVERTER_IDENT)),
1006+
)
1007+
1008+
def register_gated_entity(self, descr: Any, factory: Any, add_entities: Any, holder: dict[Any, Any], platform: str, entity: Any = None) -> None:
1009+
"""Track a description whose entity only exists while its active_when conditions hold."""
1010+
self.gatedEntities.append({"descr": descr, "factory": factory, "add": add_entities, "holder": holder, "platform": platform, "entity": entity})
1011+
if entity is None:
1012+
self._purge_registry_entry(platform, descr.key)
1013+
1014+
def _purge_registry_entry(self, platform: str, key: Any) -> None:
1015+
"""Drop a stale registry entry so a gated-out entity disappears instead of showing as unavailable."""
1016+
try:
1017+
ent_registry = er.async_get(self._hass)
1018+
entity_id = ent_registry.async_get_entity_id(platform, DOMAIN, f"{self._name}_{key}")
1019+
if entity_id:
1020+
ent_registry.async_remove(entity_id)
1021+
except Exception as ex:
1022+
_LOGGER.debug(f"{self._name}: cannot purge registry entry for {key}: {ex}")
1023+
1024+
async def async_refresh_gated_entities(self) -> None:
1025+
"""Create entities whose conditions became true and remove those that became false."""
1026+
for gated in self.gatedEntities:
1027+
descr = gated["descr"]
1028+
wanted = matches_active_when(self, descr)
1029+
entity = gated.get("entity")
1030+
if wanted:
1031+
gated["misses"] = 0
1032+
if wanted and entity is None:
1033+
entity = gated["factory"]()
1034+
gated["entity"] = entity
1035+
gated["holder"][descr.key] = entity
1036+
gated["add"]([entity])
1037+
_LOGGER.debug(f"{self._name}: added {descr.key} (conditions met)")
1038+
elif not wanted and entity is not None:
1039+
# a single stale readback right after a write must not make entities flicker
1040+
gated["misses"] = gated.get("misses", 0) + 1
1041+
if gated["misses"] < 2:
1042+
continue
1043+
gated["entity"] = None
1044+
if gated["holder"].get(descr.key) is entity:
1045+
gated["holder"].pop(descr.key, None)
1046+
try:
1047+
await entity.async_remove(force_remove=True)
1048+
except Exception as ex:
1049+
_LOGGER.debug(f"{self._name}: cannot remove {descr.key}: {ex}")
1050+
self._purge_registry_entry(gated["platform"], descr.key)
1051+
_LOGGER.debug(f"{self._name}: removed {descr.key} (conditions no longer met)")
1052+
9211053
def device_group_key(self, device_info: DeviceInfo) -> str:
9221054
"""Extract device group key from device_info identifiers.
9231055
@@ -1114,6 +1246,8 @@ async def _refresh_interval_group_once(self, interval_group: Any, bypass_slowdow
11141246
for sensor in group.sensors:
11151247
sensor.modbus_data_updated()
11161248
updated_sensors += len(group.sensors)
1249+
if getattr(self, "gatedEntities", None):
1250+
await self.async_refresh_gated_entities()
11171251
_LOGGER.debug(f"{self._name}: device group read done with outcome={group_outcome.value}")
11181252

11191253
if PollOutcome.FAILED in outcomes:

custom_components/solax_modbus/config_flow.py

Lines changed: 75 additions & 1 deletion
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
}
@@ -320,6 +324,76 @@ async def _duplicate_inverter_schema(handler: SchemaCommonFlowHandler) -> vol.Sc
320324
return vol.Schema({})
321325

322326

327+
ENTITY_TYPE_ATTRIBUTES = ("SENSOR_TYPES", "NUMBER_TYPES", "SELECT_TYPES", "SWITCH_TYPES", "TIME_TYPES", "BUTTON_TYPES")
328+
329+
330+
def _plugin_uses_feature_flag(plugin_name: str | None, flag_name: str) -> bool:
331+
"""Return whether a plugin declares any entity gated by the named allowedtypes flag."""
332+
if not plugin_name:
333+
return True
334+
try:
335+
plugin = _load_plugin(plugin_name)
336+
except Exception:
337+
return True
338+
flag = getattr(plugin, flag_name, None)
339+
if not isinstance(flag, int) or not flag:
340+
return False
341+
instance = getattr(plugin, "plugin_instance", plugin)
342+
for attribute in ENTITY_TYPE_ATTRIBUTES:
343+
for source in (instance, plugin):
344+
for description in getattr(source, attribute, None) or []:
345+
if getattr(description, "allowedtypes", 0) & flag:
346+
return True
347+
return False
348+
349+
350+
def _plugin_supports_energy_dashboard(plugin_name: str | None) -> bool:
351+
"""Return whether a plugin provides Energy Dashboard mappings."""
352+
if not plugin_name:
353+
return True
354+
try:
355+
plugin = _load_plugin(plugin_name)
356+
except Exception:
357+
return True
358+
instance = getattr(plugin, "plugin_instance", plugin)
359+
return getattr(instance, "ENERGY_DASHBOARD_MAPPING", None) is not None or getattr(plugin, "ENERGY_DASHBOARD_MAPPING", None) is not None
360+
361+
362+
def _plugin_supports_device_group(plugin_name: str | None, group: str) -> bool:
363+
"""Return whether a plugin declares any entity belonging to a named device group."""
364+
if not plugin_name:
365+
return True
366+
try:
367+
plugin = _load_plugin(plugin_name)
368+
except Exception:
369+
return True
370+
instance = getattr(plugin, "plugin_instance", plugin)
371+
for attribute in ("NUMBER_TYPES", "SELECT_TYPES", "SWITCH_TYPES", "TIME_TYPES", "BUTTON_TYPES"):
372+
for source in (instance, plugin):
373+
for description in getattr(source, attribute, None) or []:
374+
if getattr(description, "device_group", None) == group:
375+
return True
376+
return False
377+
378+
379+
async def _option_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
380+
"""Options schema without the feature switches the selected plugin does not implement."""
381+
plugin_name = handler.options.get(CONF_PLUGIN)
382+
hidden = {
383+
option
384+
for option, group in ((CONF_READ_DCB, "dry_contact"), (CONF_READ_GEN, "external_generator"))
385+
if not _plugin_supports_device_group(plugin_name, group)
386+
}
387+
if not _plugin_supports_energy_dashboard(plugin_name):
388+
hidden.add(CONF_ENERGY_DASHBOARD_DEVICE)
389+
for option, flag_name in ((CONF_READ_EPS, "EPS"), (CONF_READ_PM, "PM")):
390+
if not _plugin_uses_feature_flag(plugin_name, flag_name):
391+
hidden.add(option)
392+
if not hidden:
393+
return OPTION_SCHEMA
394+
return vol.Schema({marker: value for marker, value in OPTION_SCHEMA.schema.items() if getattr(marker, "schema", None) not in hidden})
395+
396+
323397
def _load_plugin(plugin_name: str) -> ModuleType:
324398
_LOGGER.info("trying to load plugin - plugin_name: %s", plugin_name)
325399
plugin = importlib.import_module(f".plugin_{plugin_name}", "custom_components.solax_modbus")
@@ -339,7 +413,7 @@ def _load_plugin(plugin_name: str) -> ModuleType:
339413
"duplicate_inverter": SchemaFlowFormStep(_duplicate_inverter_schema),
340414
}
341415
OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = {
342-
"init": SchemaFlowFormStep(OPTION_SCHEMA, next_step=_next_step_modbus),
416+
"init": SchemaFlowFormStep(_option_schema, next_step=_next_step_modbus),
343417
"serial": SchemaFlowFormStep(SERIAL_SCHEMA, next_step=_next_step_battery),
344418
"tcp": SchemaFlowFormStep(TCP_SCHEMA, validate_user_input=_validate_host, next_step=_next_step_battery),
345419
"core": SchemaFlowFormStep(CORE_SCHEMA, validate_user_input=_validate_core_modbus_hub, next_step=_next_step_battery),

0 commit comments

Comments
 (0)