Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions custom_components/solax_modbus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
Expand Down Expand Up @@ -95,12 +96,21 @@
WRITE_SINGLE_MODBUS,
PollOutcome,
)
from .const import (
CONF_ENERGY_DASHBOARD_DEVICE as CONF_ENERGY_DASHBOARD_DEVICE,
)
from .const import (
CONF_READ_DCB as CONF_READ_DCB,
)
from .const import (
CONF_READ_EPS as CONF_READ_EPS,
)
from .const import (
CONF_READ_PM as CONF_READ_PM,
)
from .const import (
DEFAULT_ENERGY_DASHBOARD_DEVICE as DEFAULT_ENERGY_DASHBOARD_DEVICE,
)
from .const import (
DEFAULT_INTERFACE as DEFAULT_INTERFACE,
)
Expand All @@ -119,6 +129,9 @@
from .const import (
DEFAULT_READ_EPS as DEFAULT_READ_EPS,
)
from .const import (
DEFAULT_READ_PM as DEFAULT_READ_PM,
)
from .const import (
DEFAULT_SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL,
)
Expand All @@ -128,6 +141,9 @@
from .const import (
WRITE_MULTISINGLE_MODBUS as WRITE_MULTISINGLE_MODBUS,
)
from .const import (
matches_active_when as matches_active_when,
)
from .modbus_transport import CoreModbusTransport, ModbusTransport, NativeModbusTransport, UnavailableModbusTransport
from .pymodbus_compat import DataType, convert_from_registers, convert_to_registers, pymodbus_version_info
from .sensor import SolaXModbusSensor
Expand Down Expand Up @@ -423,9 +439,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hub.async_init()

entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))

await async_cleanup_disabled_devices(hass, entry, hub)
return True


# Device groups that a config option can switch off, and the option controlling each.
# Display names for sub-devices, where a plain title-case of the group key would read badly.
DEVICE_GROUP_NAMES: dict[str, str] = {
"eps": "EPS",
"pm": "Parallel",
}

GATED_DEVICE_GROUPS: dict[str, tuple[str, bool]] = {
"eps": (CONF_READ_EPS, DEFAULT_READ_EPS),
"pm": (CONF_READ_PM, DEFAULT_READ_PM),
"ENERGY_DASHBOARD": (CONF_ENERGY_DASHBOARD_DEVICE, DEFAULT_ENERGY_DASHBOARD_DEVICE),
}


def device_group_of(device_entry: Any) -> str | None:
"""Return the group name encoded in a device's solax identifiers, if any."""
for identifier in device_entry.identifiers:
parts = tuple(identifier)
if parts and parts[0] == DOMAIN and len(parts) > 2:
return str(parts[2])
return None


async def async_cleanup_disabled_devices(hass: HomeAssistant, entry: ConfigEntry, hub: Any) -> None:
"""Remove devices (and their entities) for device groups switched off in the options.

Home Assistant never removes devices on its own, so a device group that is no
longer created would otherwise linger in the registry with stale entities.
"""
configdict = entry.options if entry.options else entry.data
dev_registry = dr.async_get(hass)
for device_entry in dr.async_entries_for_config_entry(dev_registry, entry.entry_id):
group = device_group_of(device_entry)
gate = GATED_DEVICE_GROUPS.get(group) if group else None
if gate is None:
continue
option, default = gate
if not configdict.get(option, default):
_LOGGER.info("%s: removing device %s - option %s is disabled", hub.name, device_entry.name, option)
dev_registry.async_remove_device(device_entry.id)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload SolaX modbus entry and tear down transports cleanly."""
name = entry.options.get("name")
Expand Down Expand Up @@ -604,6 +664,7 @@ def __init__(
self.selectEntities: dict[Any, Any] = {}
self.switchEntities: dict[Any, Any] = {}
self.timeEntities: dict[Any, Any] = {}
self.gatedEntities: list[dict[str, Any]] = [] # descriptions with active_when, added/removed as their branch activates
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
# self.preventSensors = {} # sensors with prevent_update = True
self.writeLocals: dict[Any, Any] = {} # key to description lookup dict for write_method = WRITE_DATA_LOCAL entities
Expand Down Expand Up @@ -923,6 +984,75 @@ def _warn_duplicate_inverter_configuration(self, interval: int) -> None:
describe_modbus_connection(identity),
)

def device_group_enabled(self, group: str | None) -> bool:
"""Return whether a named device group is enabled in this entry's options."""
if not group:
return True
configdict = self.entry.options if self.entry.options else self.entry.data
gate = GATED_DEVICE_GROUPS.get(group)
if gate is None:
return True
option, default = gate
return bool(configdict.get(option, default))

def device_group_display_name(self, group: str) -> str:
"""Return the human readable name of a device group."""
return DEVICE_GROUP_NAMES.get(group, group.replace("_", " ").title())

def group_device_info(self, group: str) -> DeviceInfo:
"""Return the DeviceInfo for a named sub-device (e.g. "dry_contact")."""
return DeviceInfo(
identifiers=cast(set[tuple[str, str]], {(DOMAIN, self._name, group)}),
name=f"{self._name} {DEVICE_GROUP_NAMES.get(group, group.replace('_', ' ').title())}",
manufacturer=self.plugin.plugin_manufacturer,
via_device=cast(tuple[str, str], (DOMAIN, self._name, INVERTER_IDENT)),
)

def register_gated_entity(self, descr: Any, factory: Any, add_entities: Any, holder: dict[Any, Any], platform: str, entity: Any = None) -> None:
"""Track a description whose entity only exists while its active_when conditions hold."""
self.gatedEntities.append({"descr": descr, "factory": factory, "add": add_entities, "holder": holder, "platform": platform, "entity": entity})
if entity is None:
self._purge_registry_entry(platform, descr.key)

def _purge_registry_entry(self, platform: str, key: Any) -> None:
"""Drop a stale registry entry so a gated-out entity disappears instead of showing as unavailable."""
try:
ent_registry = er.async_get(self._hass)
entity_id = ent_registry.async_get_entity_id(platform, DOMAIN, f"{self._name}_{key}")
if entity_id:
ent_registry.async_remove(entity_id)
except Exception as ex:
_LOGGER.debug("%s: cannot purge registry entry for %s: %s", self._name, key, ex)

async def async_refresh_gated_entities(self) -> None:
"""Create entities whose conditions became true and remove those that became false."""
for gated in self.gatedEntities:
descr = gated["descr"]
wanted = matches_active_when(self, descr)
entity = gated.get("entity")
if wanted:
gated["misses"] = 0
if wanted and entity is None:
entity = gated["factory"]()
gated["entity"] = entity
gated["holder"][descr.key] = entity
gated["add"]([entity])
_LOGGER.debug("%s: added %s (conditions met)", self._name, descr.key)
elif not wanted and entity is not None:
# a single stale readback right after a write must not make entities flicker
gated["misses"] = gated.get("misses", 0) + 1
if gated["misses"] < 2:
continue
gated["entity"] = None
if gated["holder"].get(descr.key) is entity:
gated["holder"].pop(descr.key, None)
try:
await entity.async_remove(force_remove=True)
except Exception as ex:
_LOGGER.debug("%s: cannot remove %s: %s", self._name, descr.key, ex)
self._purge_registry_entry(gated["platform"], descr.key)
_LOGGER.debug("%s: removed %s (conditions no longer met)", self._name, descr.key)

def device_group_key(self, device_info: DeviceInfo) -> str:
"""Extract device group key from device_info identifiers.

Expand Down Expand Up @@ -1133,6 +1263,8 @@ async def _refresh_interval_group_once(self, interval_group: Any, bypass_slowdow
for sensor in group.sensors:
sensor.modbus_data_updated()
updated_sensors += len(group.sensors)
if getattr(self, "gatedEntities", None):
await self.async_refresh_gated_entities()
_LOGGER.debug("%s: device group read done with outcome=%s", self._name, group_outcome.value)

if PollOutcome.FAILED in outcomes:
Expand Down
68 changes: 67 additions & 1 deletion custom_components/solax_modbus/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,72 @@ async def _duplicate_inverter_schema(handler: SchemaCommonFlowHandler) -> vol.Sc
return vol.Schema({})


ENTITY_TYPE_ATTRIBUTES = ("SENSOR_TYPES", "NUMBER_TYPES", "SELECT_TYPES", "SWITCH_TYPES", "TIME_TYPES", "BUTTON_TYPES")


def _plugin_uses_feature_flag(plugin_name: str | None, flag_name: str) -> bool:
"""Return whether a plugin declares any entity gated by the named allowedtypes flag."""
if not plugin_name:
return True
try:
plugin = _load_plugin(plugin_name)
except Exception:
return True
flag = getattr(plugin, flag_name, None)
if not isinstance(flag, int) or not flag:
return False
instance = getattr(plugin, "plugin_instance", plugin)
for attribute in ENTITY_TYPE_ATTRIBUTES:
for source in (instance, plugin):
for description in getattr(source, attribute, None) or []:
if getattr(description, "allowedtypes", 0) & flag:
return True
return False


def _plugin_supports_energy_dashboard(plugin_name: str | None) -> bool:
"""Return whether a plugin provides Energy Dashboard mappings."""
if not plugin_name:
return True
try:
plugin = _load_plugin(plugin_name)
except Exception:
return True
instance = getattr(plugin, "plugin_instance", plugin)
return getattr(instance, "ENERGY_DASHBOARD_MAPPING", None) is not None or getattr(plugin, "ENERGY_DASHBOARD_MAPPING", None) is not None


def _plugin_supports_device_group(plugin_name: str | None, group: str) -> bool:
"""Return whether a plugin declares any entity belonging to a named device group."""
if not plugin_name:
return True
try:
plugin = _load_plugin(plugin_name)
except Exception:
return True
instance = getattr(plugin, "plugin_instance", plugin)
for attribute in ("NUMBER_TYPES", "SELECT_TYPES", "SWITCH_TYPES", "TIME_TYPES", "BUTTON_TYPES"):
for source in (instance, plugin):
for description in getattr(source, attribute, None) or []:
if getattr(description, "device_group", None) == group:
return True
return False


async def _option_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Options schema without the feature switches the selected plugin does not implement."""
plugin_name = handler.options.get(CONF_PLUGIN)
hidden: set[str] = set()
if not _plugin_supports_energy_dashboard(plugin_name):
hidden.add(CONF_ENERGY_DASHBOARD_DEVICE)
for option, flag_name in ((CONF_READ_EPS, "EPS"), (CONF_READ_PM, "PM")):
if not _plugin_uses_feature_flag(plugin_name, flag_name):
hidden.add(option)
if not hidden:
return OPTION_SCHEMA
return vol.Schema({marker: value for marker, value in OPTION_SCHEMA.schema.items() if getattr(marker, "schema", None) not in hidden})


def _load_plugin(plugin_name: str) -> ModuleType:
_LOGGER.info("trying to load plugin - plugin_name: %s", plugin_name)
plugin = importlib.import_module(f".plugin_{plugin_name}", "custom_components.solax_modbus")
Expand All @@ -339,7 +405,7 @@ def _load_plugin(plugin_name: str) -> ModuleType:
"duplicate_inverter": SchemaFlowFormStep(_duplicate_inverter_schema),
}
OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = {
"init": SchemaFlowFormStep(OPTION_SCHEMA, next_step=_next_step_modbus),
"init": SchemaFlowFormStep(_option_schema, next_step=_next_step_modbus),
"serial": SchemaFlowFormStep(SERIAL_SCHEMA, next_step=_next_step_battery),
"tcp": SchemaFlowFormStep(TCP_SCHEMA, validate_user_input=_validate_host, next_step=_next_step_battery),
"core": SchemaFlowFormStep(CORE_SCHEMA, validate_user_input=_validate_core_modbus_hub, next_step=_next_step_battery),
Expand Down
29 changes: 28 additions & 1 deletion custom_components/solax_modbus/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class UnitOfReactivePower(StrEnum): # type: ignore[no-redef]
DEFAULT_READ_BATTERY = False
ENERGY_DASHBOARD_DEVICE_ENABLED = True
ENERGY_DASHBOARD_DEVICE_DISABLED = False
DEFAULT_ENERGY_DASHBOARD_DEVICE = ENERGY_DASHBOARD_DEVICE_ENABLED
DEFAULT_ENERGY_DASHBOARD_DEVICE = ENERGY_DASHBOARD_DEVICE_DISABLED
PLUGIN_PATH = f"{pathlib.Path(__file__).parent.absolute()}/plugin_*.py"
SLEEPMODE_NONE = None
SLEEPMODE_ZERO = 0 # when no communication at all
Expand Down Expand Up @@ -238,6 +238,8 @@ class BaseModbusSensorEntityDescription(SensorEntityDescription):
"""Base class for modbus sensor declarations."""

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

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

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

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

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


def matches_active_when(hub: Any, description: Any) -> bool:
"""Return whether an entity's active_when conditions are currently met.

Conditions reference polled hub.data keys; a key that is absent from
hub.data (not polled on this model) does not block availability.
"""
conditions = getattr(description, "active_when", None)
if not conditions:
return True
data = getattr(hub, "data", {})
for key, allowed in conditions.items():
if key in data and data[key] not in allowed:
return False
return True


def matches_modbus_protocol(hub: Any, description: Any) -> bool:
"""Return whether a description applies to the detected Modbus protocol version.

Expand Down
Loading
Loading