Skip to content

Commit 1b83570

Browse files
committed
Energy Dashboard fix, sub-device infrastructure, and a plugin-aware options dialog
Energy Dashboard: the refresh only marked sensors inactive when a feature switch was turned off - the entity stayed alive and its registry entry was never removed, so the device accumulated sensors stuck at unavailable (grid-to-battery, home-consumption and PV-variant sensors). They are now removed and their registry entries purged. Generic sub-device infrastructure, used by follow-up PRs that move the EPS, Parallel, External Generator and Dry Contact families into their own devices: entity descriptions gain device_group (assigns the entity to a named sub-device linked to the inverter with via_device, the same pattern as the battery pack devices) and active_when ({sensor_key: allowed values} evaluated against polled hub.data; keys not polled on a model never block availability). The hub tracks gated entities and adds or removes them as conditions change, with a two-poll hysteresis so a stale readback right after a write cannot make entities flicker. A sub-device whose option is switched off is removed from the registry instead of lingering. Switch and number writes publish the written value immediately, as selects and times already did. Numbers gain max_key, letting a sensor value override native_max_value. The number, select, switch, time and sensor platforms honour all of it. No entities move in this PR. The options dialog no longer shows feature switches the selected plugin does not implement, and all feature options now default to off for new entries, including the Energy Dashboard virtual device.
1 parent 27875b3 commit 1b83570

8 files changed

Lines changed: 337 additions & 24 deletions

File tree

custom_components/solax_modbus/__init__.py

Lines changed: 132 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_PM as CONF_READ_PM,
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_PM as DEFAULT_READ_PM,
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,53 @@ 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+
"eps": "EPS",
449+
"pm": "Parallel",
450+
}
451+
452+
GATED_DEVICE_GROUPS: dict[str, tuple[str, bool]] = {
453+
"eps": (CONF_READ_EPS, DEFAULT_READ_EPS),
454+
"pm": (CONF_READ_PM, DEFAULT_READ_PM),
455+
"ENERGY_DASHBOARD": (CONF_ENERGY_DASHBOARD_DEVICE, DEFAULT_ENERGY_DASHBOARD_DEVICE),
456+
}
457+
458+
459+
def device_group_of(device_entry: Any) -> str | None:
460+
"""Return the group name encoded in a device's solax identifiers, if any."""
461+
for identifier in device_entry.identifiers:
462+
parts = tuple(identifier)
463+
if parts and parts[0] == DOMAIN and len(parts) > 2:
464+
return str(parts[2])
465+
return None
466+
467+
468+
async def async_cleanup_disabled_devices(hass: HomeAssistant, entry: ConfigEntry, hub: Any) -> None:
469+
"""Remove devices (and their entities) for device groups switched off in the options.
470+
471+
Home Assistant never removes devices on its own, so a device group that is no
472+
longer created would otherwise linger in the registry with stale entities.
473+
"""
474+
configdict = entry.options if entry.options else entry.data
475+
dev_registry = dr.async_get(hass)
476+
for device_entry in dr.async_entries_for_config_entry(dev_registry, entry.entry_id):
477+
group = device_group_of(device_entry)
478+
gate = GATED_DEVICE_GROUPS.get(group) if group else None
479+
if gate is None:
480+
continue
481+
option, default = gate
482+
if not configdict.get(option, default):
483+
_LOGGER.info(f"{hub.name}: removing device {device_entry.name} - option {option} is disabled")
484+
dev_registry.async_remove_device(device_entry.id)
485+
486+
427487
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
428488
"""Unload SolaX modbus entry and tear down transports cleanly."""
429489
name = entry.options.get("name")
@@ -602,6 +662,7 @@ def __init__(
602662
self.selectEntities: dict[Any, Any] = {}
603663
self.switchEntities: dict[Any, Any] = {}
604664
self.timeEntities: dict[Any, Any] = {}
665+
self.gatedEntities: list[dict[str, Any]] = [] # descriptions with active_when, added/removed as their branch activates
605666
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
606667
# self.preventSensors = {} # sensors with prevent_update = True
607668
self.writeLocals: dict[Any, Any] = {} # key to description lookup dict for write_method = WRITE_DATA_LOCAL entities
@@ -918,6 +979,75 @@ def _warn_duplicate_inverter_configuration(self, interval: int) -> None:
918979
describe_modbus_connection(identity),
919980
)
920981

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

11191251
if PollOutcome.FAILED in outcomes:

custom_components/solax_modbus/config_flow.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,72 @@ async def _duplicate_inverter_schema(handler: SchemaCommonFlowHandler) -> vol.Sc
320320
return vol.Schema({})
321321

322322

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

custom_components/solax_modbus/const.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class UnitOfReactivePower(StrEnum): # type: ignore[no-redef]
7272
DEFAULT_READ_BATTERY = False
7373
ENERGY_DASHBOARD_DEVICE_ENABLED = True
7474
ENERGY_DASHBOARD_DEVICE_DISABLED = False
75-
DEFAULT_ENERGY_DASHBOARD_DEVICE = ENERGY_DASHBOARD_DEVICE_ENABLED
75+
DEFAULT_ENERGY_DASHBOARD_DEVICE = ENERGY_DASHBOARD_DEVICE_DISABLED
7676
PLUGIN_PATH = f"{pathlib.Path(__file__).parent.absolute()}/plugin_*.py"
7777
SLEEPMODE_NONE = None
7878
SLEEPMODE_ZERO = 0 # when no communication at all
@@ -238,6 +238,8 @@ class BaseModbusSensorEntityDescription(SensorEntityDescription):
238238
"""Base class for modbus sensor declarations."""
239239

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

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

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

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

375383
allowedtypes: int = 0 # overload with ALLDEFAULT from plugin
384+
device_group: str | None = None # assign the entity to a named sub-device instead of the main inverter device
385+
active_when: dict[str, tuple[Any, ...]] | None = None # {sensor_key: allowed values}; entity is unavailable unless every polled key matches
376386
modbus_min: int | None = None # Minimum protocol version as reported by register 0x82 (e.g. 100 for V001.00); not the document revision.
377387
modbus_max: int | None = None # Maximum protocol version as reported by register 0x82.
378388
register: int | None = None
@@ -390,6 +400,7 @@ class BaseModbusNumberEntityDescription(NumberEntityDescription):
390400
prevent_update: bool = False # if set to True, value will not be re-read/updated with each polling cycle;
391401
# update only when read value changes
392402
sensor_key: str | None = None # only specify this if corresponding sensor has a different key name
403+
max_key: str | None = None # key of a sensor whose value dynamically overrides native_max_value
393404
depends_on: list[str] | None = None # list of modbus register keys that must be read
394405
display_as_box: bool = True # display numbers as an input box (default); set False for a slider.
395406
suggested_display_precision: int | None = None
@@ -406,6 +417,22 @@ def modbus_protocol_version(hub: Any) -> int:
406417
return 0
407418

408419

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

0 commit comments

Comments
 (0)