From 9180105e500f4b86b030a8ba8f417df260962fea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Thu, 3 Sep 2026 05:04:44 +0000 Subject: [PATCH] feat(home-assistant): split HSEM into 7 per-subsystem devices Splits the single HSEM device into Controller, Battery & Energy, Hourly Consumption Profile, Financial, Forecast, EV Primary, and EV Secondary devices, so each subsystem can be scoped independently in dashboards, automations, and Areas instead of sharing one ~220-entity device page. - Add devices.py (HSEMDevice enum + get_device_info dispatcher) and wire every entity class's device_info to its target device via a new _hsem_device attribute on HSEMEntity (fixed per-class or resolved per-instance for EV primary/secondary and OCPP charger_index pairs). - Drop redundant "Second"/"2" name markers from EV/OCPP entity names (utils/sensornames/ev.py, ocpp.py, translations/en.json) now that the device name disambiguates them. - Add a one-time entity-registry migration (device_migration.py) that reassigns device_id for pre-existing entities, gated by a migration-version flag on the config entry so it runs exactly once. unique_id is never touched. - Update docs/sensors-reference.md, docs/home.md, and AGENTS.md for the new topology. - Add tests/test_device_split.py (per-entity device_info assertions, unique_id classification, migration idempotency/rename behavior). Fixes #875 Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 21 + custom_components/hsem/__init__.py | 5 + .../hsem/custom_numbers/battery_efficiency.py | 2 + .../hsem/custom_numbers/ev_target_soc.py | 8 + .../custom_selectors/solcast_likelihood.py | 2 + .../hsem/custom_sensors/avg_sensor.py | 2 + .../hsem/custom_sensors/battery_soc_sensor.py | 2 + .../effective_discharge_floor_sensor.py | 2 + .../ev_charger_calculated_power_sensor.py | 4 + .../ev_charger_current_limit_sensor.py | 4 + .../hsem/custom_sensors/ev_charging_sensor.py | 2 + .../ev_optimal_charging_plan_sensor.py | 2 + .../ev_second_optimal_charging_plan_sensor.py | 2 + .../hsem/custom_sensors/financial_sensors.py | 2 + .../forecast_accuracy_sensor.py | 2 + .../house_consumption_power_sensor.py | 2 + .../hsem/custom_sensors/integration_sensor.py | 2 + .../custom_sensors/net_consumption_sensor.py | 2 + .../hsem/custom_sensors/ocpp_sensors.py | 21 +- .../prediction_accuracy_sensor.py | 2 + .../custom_sensors/pv_curtailment_sensor.py | 2 + .../hsem/custom_sensors/savings_sensor.py | 2 + .../custom_sensors/solar_confidence_sensor.py | 2 + .../custom_sensors/utility_meter_sensor.py | 2 + .../hsem/custom_switches/description.py | 7 +- .../hsem/custom_switches/switch.py | 1 + .../hsem/custom_times/description.py | 7 +- custom_components/hsem/custom_times/time.py | 1 + custom_components/hsem/device_migration.py | 209 +++++ custom_components/hsem/devices.py | 70 ++ custom_components/hsem/entity.py | 18 +- custom_components/hsem/number.py | 1 + custom_components/hsem/switch.py | 11 + custom_components/hsem/time.py | 10 + custom_components/hsem/translations/en.json | 22 +- .../hsem/utils/sensornames/ev.py | 27 +- .../hsem/utils/sensornames/ocpp.py | 50 +- docs/home.md | 17 + docs/sensors-reference.md | 35 + tests/custom_sensors/test_ocpp_per_ev.py | 11 +- tests/test_device_split.py | 716 ++++++++++++++++++ 41 files changed, 1262 insertions(+), 50 deletions(-) create mode 100644 custom_components/hsem/device_migration.py create mode 100644 custom_components/hsem/devices.py create mode 100644 tests/test_device_split.py diff --git a/AGENTS.md b/AGENTS.md index 54b9eb3f..9cc55c57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,27 @@ The agent must: - Add or update tests for behavior changes, especially setup flows, coordinator behavior, and entity state handling. +### Device Topology (issue #875) + +HSEM entities are split across **7 devices**, not one: Controller, Battery & Energy, Hourly +Consumption Profile, Financial, Forecast, EV Primary, and EV Secondary. See +`custom_components/hsem/devices.py` for the `HSEMDevice` enum and `DeviceInfo` construction, and +`docs/sensors-reference.md` (Devices section) for the full entity-to-device mapping. + +- Every `HSEMEntity` subclass resolves `device_info` via `self._hsem_device` (a class attribute, + or set dynamically per instance for entities that come in primary/secondary EV pairs — see + `entity.py`). New entities MUST set `_hsem_device` explicitly unless they belong on Controller + (the default). +- The Controller device keeps the pre-split `(DOMAIN, entry_id)` identifier; every other device is + `(DOMAIN, f"{entry_id}_")`. +- `unique_id` is the permanent identity key and must **never** change when moving an entity between + devices — only `device_id` (via `entity_registry.async_update_entity`) changes. +- A one-time entity-registry migration (`custom_components/hsem/device_migration.py`), gated by a + migration-version flag on the config entry, reassigns `device_id` for pre-existing entities. It + runs once per config entry and is a no-op on subsequent calls. +- EV Secondary / OCPP entity **names** (not `unique_id`/`entity_id`) drop redundant `"Second"`/`"2"` + markers — the device name already disambiguates them via `_attr_has_entity_name = True`. + ## Git Workflow Branch naming convention: diff --git a/custom_components/hsem/__init__.py b/custom_components/hsem/__init__.py index d3c6e545..c1b7c867 100644 --- a/custom_components/hsem/__init__.py +++ b/custom_components/hsem/__init__.py @@ -21,6 +21,7 @@ from custom_components.hsem.const import DOMAIN, MIN_HUAWEI_SOLAR_VERSION from custom_components.hsem.coordinator import HSEMDataUpdateCoordinator +from custom_components.hsem.device_migration import async_migrate_devices from custom_components.hsem.services import ( async_register_services, async_unregister_services, @@ -194,6 +195,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HSEMConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # One-time entity-registry migration for the device split (issue #875). + # Gated internally so a second call is a no-op. + await async_migrate_devices(hass, entry) + # Add update listener for options. entry.async_on_unload(entry.add_update_listener(async_update_options)) diff --git a/custom_components/hsem/custom_numbers/battery_efficiency.py b/custom_components/hsem/custom_numbers/battery_efficiency.py index 57b83b57..b75cedb3 100644 --- a/custom_components/hsem/custom_numbers/battery_efficiency.py +++ b/custom_components/hsem/custom_numbers/battery_efficiency.py @@ -18,6 +18,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMEntity from custom_components.hsem.utils.conversion import convert_to_float from custom_components.hsem.utils.misc import get_config_value @@ -63,6 +64,7 @@ def __init__( entity_id: The desired entity_id string for this entity. """ super().__init__(config_entry) + self._hsem_device = HSEMDevice.BATTERY_ENERGY self.hass = hass self._config_entry = config_entry diff --git a/custom_components/hsem/custom_numbers/ev_target_soc.py b/custom_components/hsem/custom_numbers/ev_target_soc.py index f7853bb7..b674b8a0 100644 --- a/custom_components/hsem/custom_numbers/ev_target_soc.py +++ b/custom_components/hsem/custom_numbers/ev_target_soc.py @@ -18,6 +18,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMEntity from custom_components.hsem.utils.conversion import convert_to_float from custom_components.hsem.utils.misc import get_config_value @@ -50,6 +51,7 @@ def __init__( *, unique_id: str = "", entity_id: str = "", + is_second: bool = False, ) -> None: """Initialize the number entity. @@ -61,8 +63,14 @@ def __init__( default: Default value when no config entry value exists yet. unique_id: Stable unique ID for HA entity registry. entity_id: The desired entity_id string for this entity. + is_second: ``True`` when this number targets the second EV, + routing ``device_info`` to the EV Secondary device instead + of EV Primary. """ super().__init__(config_entry) + self._hsem_device = ( + HSEMDevice.EV_SECONDARY if is_second else HSEMDevice.EV_PRIMARY + ) self.hass = hass self._config_entry = config_entry diff --git a/custom_components/hsem/custom_selectors/solcast_likelihood.py b/custom_components/hsem/custom_selectors/solcast_likelihood.py index b3e895c2..76fbc64b 100644 --- a/custom_components/hsem/custom_selectors/solcast_likelihood.py +++ b/custom_components/hsem/custom_selectors/solcast_likelihood.py @@ -16,6 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMEntity from custom_components.hsem.utils.misc import get_config_value from custom_components.hsem.utils.sensornames.diagnostics import ( @@ -55,6 +56,7 @@ def __init__( and ``options``. """ super().__init__(config_entry) + self._hsem_device = HSEMDevice.FORECAST self.hass = hass self._config_entry = config_entry diff --git a/custom_components/hsem/custom_sensors/avg_sensor.py b/custom_components/hsem/custom_sensors/avg_sensor.py index 90bd587c..68a8936b 100644 --- a/custom_components/hsem/custom_sensors/avg_sensor.py +++ b/custom_components/hsem/custom_sensors/avg_sensor.py @@ -26,6 +26,7 @@ ) from homeassistant.helpers.restore_state import RestoreEntity +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMEntity from custom_components.hsem.utils.conversion import convert_to_float from custom_components.hsem.utils.ha_helpers import ha_get_entity_state_and_convert @@ -67,6 +68,7 @@ def __init__( entity_id: Entity ID for the sensor. """ super().__init__(config_entry) + self._hsem_device = HSEMDevice.HOURLY_CONSUMPTION self._hour_start = hour_start self._hour_end = hour_end self._average = avg diff --git a/custom_components/hsem/custom_sensors/battery_soc_sensor.py b/custom_components/hsem/custom_sensors/battery_soc_sensor.py index ac3e51ce..635193db 100644 --- a/custom_components/hsem/custom_sensors/battery_soc_sensor.py +++ b/custom_components/hsem/custom_sensors/battery_soc_sensor.py @@ -34,6 +34,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.diagnostics import ( get_battery_soc_sensor_entity_id, @@ -77,6 +78,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.BATTERY_ENERGY self._config_entry = config_entry diff --git a/custom_components/hsem/custom_sensors/effective_discharge_floor_sensor.py b/custom_components/hsem/custom_sensors/effective_discharge_floor_sensor.py index 82cae704..a8d24d3b 100644 --- a/custom_components/hsem/custom_sensors/effective_discharge_floor_sensor.py +++ b/custom_components/hsem/custom_sensors/effective_discharge_floor_sensor.py @@ -22,6 +22,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.diagnostics import ( get_effective_discharge_floor_sensor_entity_id, @@ -59,6 +60,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.BATTERY_ENERGY self._config_entry = config_entry diff --git a/custom_components/hsem/custom_sensors/ev_charger_calculated_power_sensor.py b/custom_components/hsem/custom_sensors/ev_charger_calculated_power_sensor.py index 36c9d536..11ff00cd 100644 --- a/custom_components/hsem/custom_sensors/ev_charger_calculated_power_sensor.py +++ b/custom_components/hsem/custom_sensors/ev_charger_calculated_power_sensor.py @@ -40,6 +40,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.ev import ( get_ev_charger_calculated_power_sensor_entity_id, @@ -87,6 +88,9 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = ( + HSEMDevice.EV_SECONDARY if self._is_second else HSEMDevice.EV_PRIMARY + ) self._config_entry = config_entry self._restored_state: str | None = None diff --git a/custom_components/hsem/custom_sensors/ev_charger_current_limit_sensor.py b/custom_components/hsem/custom_sensors/ev_charger_current_limit_sensor.py index 9771292f..ff98ca7c 100644 --- a/custom_components/hsem/custom_sensors/ev_charger_current_limit_sensor.py +++ b/custom_components/hsem/custom_sensors/ev_charger_current_limit_sensor.py @@ -44,6 +44,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.models.hourly_recommendation import HourlyRecommendation from custom_components.hsem.utils.phase_power import ( @@ -101,6 +102,9 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = ( + HSEMDevice.EV_SECONDARY if self._is_second else HSEMDevice.EV_PRIMARY + ) self._config_entry = config_entry diff --git a/custom_components/hsem/custom_sensors/ev_charging_sensor.py b/custom_components/hsem/custom_sensors/ev_charging_sensor.py index cb7095c8..7f89cc4a 100644 --- a/custom_components/hsem/custom_sensors/ev_charging_sensor.py +++ b/custom_components/hsem/custom_sensors/ev_charging_sensor.py @@ -29,6 +29,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.ev import ( get_ev_charging_sensor_entity_id, @@ -74,6 +75,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.EV_PRIMARY self._config_entry = config_entry diff --git a/custom_components/hsem/custom_sensors/ev_optimal_charging_plan_sensor.py b/custom_components/hsem/custom_sensors/ev_optimal_charging_plan_sensor.py index b6c4ff83..a90da75f 100644 --- a/custom_components/hsem/custom_sensors/ev_optimal_charging_plan_sensor.py +++ b/custom_components/hsem/custom_sensors/ev_optimal_charging_plan_sensor.py @@ -35,6 +35,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.ev import ( get_ev_optimal_charging_plan_sensor_entity_id, @@ -84,6 +85,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.EV_PRIMARY self._config_entry = config_entry self._attr_unique_id = get_ev_optimal_charging_plan_sensor_unique_id( diff --git a/custom_components/hsem/custom_sensors/ev_second_optimal_charging_plan_sensor.py b/custom_components/hsem/custom_sensors/ev_second_optimal_charging_plan_sensor.py index 0af8b2d5..51b7d309 100644 --- a/custom_components/hsem/custom_sensors/ev_second_optimal_charging_plan_sensor.py +++ b/custom_components/hsem/custom_sensors/ev_second_optimal_charging_plan_sensor.py @@ -20,6 +20,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.ev import ( get_ev_second_optimal_charging_plan_sensor_entity_id, @@ -60,6 +61,7 @@ def __init__( """Initialise the second EV optimal charging plan sensor.""" HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.EV_SECONDARY self._config_entry = config_entry self._attr_unique_id = get_ev_second_optimal_charging_plan_sensor_unique_id( diff --git a/custom_components/hsem/custom_sensors/financial_sensors.py b/custom_components/hsem/custom_sensors/financial_sensors.py index 3a4a90ef..3288855e 100644 --- a/custom_components/hsem/custom_sensors/financial_sensors.py +++ b/custom_components/hsem/custom_sensors/financial_sensors.py @@ -34,6 +34,7 @@ from custom_components.hsem.coordinator import ( HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.models.financial_tracker import FinancialTracker from custom_components.hsem.utils.sensornames.financial import ( @@ -82,6 +83,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.FINANCIAL self._config_entry = config_entry self._restored_state: str | None = None diff --git a/custom_components/hsem/custom_sensors/forecast_accuracy_sensor.py b/custom_components/hsem/custom_sensors/forecast_accuracy_sensor.py index 5fd158fb..065e94af 100644 --- a/custom_components/hsem/custom_sensors/forecast_accuracy_sensor.py +++ b/custom_components/hsem/custom_sensors/forecast_accuracy_sensor.py @@ -37,6 +37,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.datetime_utils import now as hsem_now from custom_components.hsem.utils.forecast_tracker import ForecastTracker @@ -78,6 +79,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.FORECAST self._attr_unique_id = get_forecast_accuracy_sensor_unique_id( config_entry.entry_id ) diff --git a/custom_components/hsem/custom_sensors/house_consumption_power_sensor.py b/custom_components/hsem/custom_sensors/house_consumption_power_sensor.py index c0eef774..e21f9cab 100644 --- a/custom_components/hsem/custom_sensors/house_consumption_power_sensor.py +++ b/custom_components/hsem/custom_sensors/house_consumption_power_sensor.py @@ -39,6 +39,7 @@ from custom_components.hsem.custom_sensors.utility_meter_sensor import ( HSEMUtilityMeterSensor, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMEntity from custom_components.hsem.utils.conversion import convert_to_float from custom_components.hsem.utils.ha_helpers import ha_get_entity_state_and_convert @@ -107,6 +108,7 @@ def __init__( async_add_entities: HA callback to register derived child entities. """ super().__init__(config_entry) + self._hsem_device = HSEMDevice.HOURLY_CONSUMPTION self._available = False self._missing_input_entities = True self._hsem_house_consumption_power = None diff --git a/custom_components/hsem/custom_sensors/integration_sensor.py b/custom_components/hsem/custom_sensors/integration_sensor.py index be59e608..e5f8ad4b 100644 --- a/custom_components/hsem/custom_sensors/integration_sensor.py +++ b/custom_components/hsem/custom_sensors/integration_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntry +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMEntity @@ -56,6 +57,7 @@ def __init__( "config_entry is required for HSEMIntegrationSensor" ) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.HOURLY_CONSUMPTION self._attr_unique_id = id self.entity_id = e_id diff --git a/custom_components/hsem/custom_sensors/net_consumption_sensor.py b/custom_components/hsem/custom_sensors/net_consumption_sensor.py index a9fdd9d5..2999e271 100644 --- a/custom_components/hsem/custom_sensors/net_consumption_sensor.py +++ b/custom_components/hsem/custom_sensors/net_consumption_sensor.py @@ -34,6 +34,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.diagnostics import ( get_net_consumption_sensor_entity_id, @@ -78,6 +79,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.BATTERY_ENERGY self._config_entry = config_entry diff --git a/custom_components/hsem/custom_sensors/ocpp_sensors.py b/custom_components/hsem/custom_sensors/ocpp_sensors.py index 9e0d4a05..347bc003 100644 --- a/custom_components/hsem/custom_sensors/ocpp_sensors.py +++ b/custom_components/hsem/custom_sensors/ocpp_sensors.py @@ -35,6 +35,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.models.sensor_config import SensorConfig from custom_components.hsem.utils.sensornames.ocpp import ( @@ -197,13 +198,16 @@ def __init__( self._config_entry = config_entry self._charger_index = charger_index + self._hsem_device = ( + HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY + ) self._attr_unique_id = get_ocpp_charger_status_sensor_unique_id( config_entry.entry_id, charger_index=charger_index ) self.entity_id = get_ocpp_charger_status_sensor_entity_id( charger_index=charger_index ) - self._name = get_ocpp_charger_status_sensor_name(charger_index) + self._name = get_ocpp_charger_status_sensor_name() self._restored_state: str | None = None @property @@ -349,13 +353,16 @@ def __init__( self._config_entry = config_entry self._charger_index = charger_index + self._hsem_device = ( + HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY + ) self._attr_unique_id = get_ocpp_charger_power_sensor_unique_id( config_entry.entry_id, charger_index=charger_index ) self.entity_id = get_ocpp_charger_power_sensor_entity_id( charger_index=charger_index ) - self._name = get_ocpp_charger_power_sensor_name(charger_index) + self._name = get_ocpp_charger_power_sensor_name() self._restored_state: str | None = None @property @@ -445,13 +452,16 @@ def __init__( self._config_entry = config_entry self._charger_index = charger_index + self._hsem_device = ( + HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY + ) self._attr_unique_id = get_ocpp_charger_info_sensor_unique_id( config_entry.entry_id, charger_index=charger_index ) self.entity_id = get_ocpp_charger_info_sensor_entity_id( charger_index=charger_index ) - self._name = get_ocpp_charger_info_sensor_name(charger_index) + self._name = get_ocpp_charger_info_sensor_name() self._restored_state: str | None = None @property @@ -559,13 +569,16 @@ def __init__( self._config_entry = config_entry self._charger_index = charger_index + self._hsem_device = ( + HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY + ) self._attr_unique_id = get_ocpp_charger_sessions_sensor_unique_id( config_entry.entry_id, charger_index=charger_index ) self.entity_id = get_ocpp_charger_sessions_sensor_entity_id( charger_index=charger_index ) - self._name = get_ocpp_charger_sessions_sensor_name(charger_index) + self._name = get_ocpp_charger_sessions_sensor_name() self._restored_state: str | None = None @property diff --git a/custom_components/hsem/custom_sensors/prediction_accuracy_sensor.py b/custom_components/hsem/custom_sensors/prediction_accuracy_sensor.py index 1d2d55a7..ce6ef3a2 100644 --- a/custom_components/hsem/custom_sensors/prediction_accuracy_sensor.py +++ b/custom_components/hsem/custom_sensors/prediction_accuracy_sensor.py @@ -27,6 +27,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from custom_components.hsem.coordinator import HSEMDataUpdateCoordinator +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.diagnostics import ( get_prediction_accuracy_sensor_entity_id, @@ -65,6 +66,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.FORECAST self._attr_unique_id = get_prediction_accuracy_sensor_unique_id( config_entry.entry_id ) diff --git a/custom_components/hsem/custom_sensors/pv_curtailment_sensor.py b/custom_components/hsem/custom_sensors/pv_curtailment_sensor.py index d4d94725..16c2ba6a 100644 --- a/custom_components/hsem/custom_sensors/pv_curtailment_sensor.py +++ b/custom_components/hsem/custom_sensors/pv_curtailment_sensor.py @@ -30,6 +30,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.sensornames.diagnostics import ( get_pv_curtailment_sensor_entity_id, @@ -89,6 +90,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.BATTERY_ENERGY self._config_entry = config_entry diff --git a/custom_components/hsem/custom_sensors/savings_sensor.py b/custom_components/hsem/custom_sensors/savings_sensor.py index bb3f7199..6b331a76 100644 --- a/custom_components/hsem/custom_sensors/savings_sensor.py +++ b/custom_components/hsem/custom_sensors/savings_sensor.py @@ -35,6 +35,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from custom_components.hsem.coordinator import HSEMDataUpdateCoordinator +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.models.savings_tracker import SavingsTracker from custom_components.hsem.utils.sensornames.diagnostics import ( @@ -75,6 +76,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.FINANCIAL self._attr_unique_id = get_savings_tracker_sensor_unique_id( config_entry.entry_id ) diff --git a/custom_components/hsem/custom_sensors/solar_confidence_sensor.py b/custom_components/hsem/custom_sensors/solar_confidence_sensor.py index 7736ca33..32a07856 100644 --- a/custom_components/hsem/custom_sensors/solar_confidence_sensor.py +++ b/custom_components/hsem/custom_sensors/solar_confidence_sensor.py @@ -31,6 +31,7 @@ CoordinatorData, HSEMDataUpdateCoordinator, ) +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity from custom_components.hsem.utils.datetime_utils import now as hsem_now from custom_components.hsem.utils.logger import HSEM_LOGGER as _LOGGER @@ -72,6 +73,7 @@ def __init__( """ HSEMCoordinatorEntity.__init__(self, coordinator) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.FORECAST self._attr_unique_id = get_solar_confidence_sensor_unique_id( config_entry.entry_id ) diff --git a/custom_components/hsem/custom_sensors/utility_meter_sensor.py b/custom_components/hsem/custom_sensors/utility_meter_sensor.py index 3e027d8a..840ad6bb 100644 --- a/custom_components/hsem/custom_sensors/utility_meter_sensor.py +++ b/custom_components/hsem/custom_sensors/utility_meter_sensor.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.entity import HSEMEntity @@ -57,6 +58,7 @@ def __init__( "config_entry is required for HSEMUtilityMeterSensor" ) HSEMEntity.__init__(self, config_entry) + self._hsem_device = HSEMDevice.HOURLY_CONSUMPTION self._attr_unique_id = id self.entity_id = e_id diff --git a/custom_components/hsem/custom_switches/description.py b/custom_components/hsem/custom_switches/description.py index 36de5581..e43e9128 100644 --- a/custom_components/hsem/custom_switches/description.py +++ b/custom_components/hsem/custom_switches/description.py @@ -5,10 +5,11 @@ (unique_id, entity_id) tuples. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from homeassistant.components.switch import SwitchEntityDescription +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.utils.sensornames.controls import ( get_batteries_schedule_1_switch_entity_id, get_batteries_schedule_1_switch_key, @@ -144,6 +145,10 @@ class HSEMSwitchEntityDescription(SwitchEntityDescription): description: Short human-readable description of the switch's purpose, exposed as an entity attribute for dashboard display. + hsem_device: + The HSEM device (issue #875) this switch's ``device_info`` resolves + to. Defaults to ``CONTROLLER``. """ description: str = "" + hsem_device: HSEMDevice = field(default=HSEMDevice.CONTROLLER) diff --git a/custom_components/hsem/custom_switches/switch.py b/custom_components/hsem/custom_switches/switch.py index 42df0fab..b19b0dd2 100644 --- a/custom_components/hsem/custom_switches/switch.py +++ b/custom_components/hsem/custom_switches/switch.py @@ -51,6 +51,7 @@ def __init__( Entity description carrying ``key``, ``name``, and ``description``. """ super().__init__(config_entry) + self._hsem_device = description.hsem_device self.hass = hass self._config_entry = config_entry diff --git a/custom_components/hsem/custom_times/description.py b/custom_components/hsem/custom_times/description.py index 4221a176..60038835 100644 --- a/custom_components/hsem/custom_times/description.py +++ b/custom_components/hsem/custom_times/description.py @@ -5,10 +5,11 @@ (unique_id, entity_id) tuples. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from homeassistant.components.time import TimeEntityDescription +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.utils.sensornames.controls import ( get_schedule_1_end_time_entity_id, get_schedule_1_end_time_key, @@ -95,7 +96,11 @@ class HSEMTimeEntityDescription(TimeEntityDescription): as an entity attribute for dashboard display. default_value: Initial time value as an ``"HH:MM:SS"`` string. + hsem_device: + The HSEM device (issue #875) this time entity's ``device_info`` + resolves to. Defaults to ``CONTROLLER``. """ description: str = "" default_value: str = "00:00:00" + hsem_device: HSEMDevice = field(default=HSEMDevice.CONTROLLER) diff --git a/custom_components/hsem/custom_times/time.py b/custom_components/hsem/custom_times/time.py index 2abbbc4d..39e6a886 100644 --- a/custom_components/hsem/custom_times/time.py +++ b/custom_components/hsem/custom_times/time.py @@ -53,6 +53,7 @@ def __init__( and ``default_value``. """ super().__init__(config_entry) + self._hsem_device = description.hsem_device self.hass = hass self._config_entry = config_entry diff --git a/custom_components/hsem/device_migration.py b/custom_components/hsem/device_migration.py new file mode 100644 index 00000000..5a1642c6 --- /dev/null +++ b/custom_components/hsem/device_migration.py @@ -0,0 +1,209 @@ +"""One-time entity-registry migration for the HSEM device split (issue #875). + +Before this migration, every HSEM entity lived on a single Home Assistant +device (``(DOMAIN, entry_id)``). :mod:`custom_components.hsem.devices` now +splits entities across 7 devices, and :class:`HSEMEntity.device_info` +(`entity.py`) already resolves every *newly added* entity to its correct +device. Home Assistant's own entity-platform setup +(``EntityPlatform._async_add_entity`` -> ``entity_registry.async_get_or_create``) +already re-attaches an existing entity (matched by ``unique_id``) to a +changed ``device_info`` on every setup, so this migration mostly exists to: + +- Make the device reassignment explicit, deterministic, and independently + testable rather than relying on an HA-internal implementation detail. +- Provide the ``entity_registry.async_update_entity(..., new_entity_id=...)`` + primitive the issue calls for, so any *future* entity_id change in this + area is guaranteed to move long-term statistics with it (HA's recorder + listens to the entity-registry rename event and renames the matching + ``statistic_id`` in lockstep). No entity in this migration actually needs + an entity_id rename yet -- ``unique_id`` and ``entity_id`` getters were + deliberately kept frozen; only entity *names* and device attachment + changed -- but the rename path is implemented and tested so it is ready + the moment it is needed. + +The migration NEVER touches ``unique_id`` -- that is Home Assistant's true +identity key -- and is gated by a migration-version flag stored in +``entry.data`` so it runs exactly once per config entry. +""" + +from __future__ import annotations + +import logging + +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.entity_registry as er +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from custom_components.hsem.devices import HSEMDevice, get_device_info + +_LOGGER = logging.getLogger(__name__) + +#: Bumped whenever the device-split migration logic changes in a way that +#: needs to re-run for existing installs. +DEVICE_MIGRATION_VERSION = 1 + +#: Key stored in ``entry.data`` recording the migration version already +#: applied to this config entry. Distinct from ``entry.version`` / +#: ``entry.minor_version``, which gate config-schema migrations +#: (``async_migrate_entry``), not entity-registry bookkeeping. +DEVICE_MIGRATION_DATA_KEY = "hsem_device_migration_version" + +#: Optional explicit entity_id renames, keyed by the entity's ``unique_id``. +#: Empty today -- see the module docstring -- but kept as a real, exercised +#: code path (see tests) for any future entity that needs one. +_ENTITY_ID_RENAMES: dict[str, str] = {} + + +def classify_entity_device(unique_id: str) -> HSEMDevice: + """Return the target :class:`HSEMDevice` for a stored ``unique_id``. + + Pure, offline classification based only on the frozen ``unique_id`` + string (never re-instantiates entities), so it can run against + registry entries alone during migration. Mirrors the live + ``_hsem_device`` assignment in every entity class 1:1 -- see + ``custom_components/hsem/devices.py`` and the per-class + ``self._hsem_device = ...`` assignments for the authoritative mapping. + + Args: + unique_id: The entity's stable, never-changing unique ID. + + Returns: + The device the entity should be attached to. Defaults to + ``CONTROLLER`` -- the pre-split single device -- for anything that + does not match a more specific subsystem. + """ + uid = unique_id.lower() + + # EV Secondary — anything explicitly tagged "second" for EV/OCPP. + if "ev_second_" in uid or ("ocpp_charger" in uid and uid.endswith("_second")): + return HSEMDevice.EV_SECONDARY + + # EV Primary — remaining EV/OCPP entities. + if "ev_" in uid or "ocpp_charger" in uid: + return HSEMDevice.EV_PRIMARY + + # Hourly Consumption Profile — the 168 per-hour-block entities. + if "house_consumption_" in uid: + return HSEMDevice.HOURLY_CONSUMPTION + + # Financial. + if any( + marker in uid + for marker in ( + "export_income_sensor", + "import_cost_sensor", + "net_grid_balance_sensor", + "savings_tracker_sensor", + ) + ): + return HSEMDevice.FINANCIAL + + # Forecast. + if any( + marker in uid + for marker in ( + "forecast_accuracy_sensor", + "solar_confidence_sensor", + "prediction_accuracy_sensor", + "solcast_likelihood", + ) + ): + return HSEMDevice.FORECAST + + # Battery & Energy. + if any( + marker in uid + for marker in ( + "battery_soc_sensor", + "effective_discharge_floor_sensor", + "net_consumption_sensor", + "pv_curtailment_sensor", + "battery_charge_efficiency", + "battery_discharge_efficiency", + "batteries_schedule", + "dynamic_discharge_floor", + ) + ): + return HSEMDevice.BATTERY_ENERGY + + # Everything else (working mode, degraded mode, read-only, hardware + # writes, missing entities, force mode, last/next updated, update + # interval, applier status, plan explanation, daily plan vs actual, + # recommendation interval, and generic switches) keeps its pre-split + # Controller identity — no migration needed for these. + return HSEMDevice.CONTROLLER + + +async def async_migrate_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Run the one-time device-split migration for ``entry``. + + Idempotent and gated by :data:`DEVICE_MIGRATION_DATA_KEY`: a second call + for the same (already-migrated) config entry is a no-op that does not + touch the entity or device registries at all. + + Args: + hass: The Home Assistant instance. + entry: The HSEM config entry to migrate. + """ + if entry.data.get(DEVICE_MIGRATION_DATA_KEY, 0) >= DEVICE_MIGRATION_VERSION: + return + + entity_reg = er.async_get(hass) + device_reg = dr.async_get(hass) + + migrated = 0 + for entity_entry in er.async_entries_for_config_entry(entity_reg, entry.entry_id): + if entity_entry.unique_id is None: # pragma: no cover — HA always sets this + continue + + target_device = classify_entity_device(entity_entry.unique_id) + device_info = get_device_info(entry.entry_id, target_device) + device = device_reg.async_get_or_create( + config_entry_id=entry.entry_id, **device_info + ) + + device_id_update = device.id if entity_entry.device_id != device.id else None + + new_entity_id = _ENTITY_ID_RENAMES.get(entity_entry.unique_id) + if new_entity_id == entity_entry.entity_id: + new_entity_id = None + + if device_id_update is None and new_entity_id is None: + continue + + _LOGGER.debug( + "Migrating HSEM entity %s to device %s (device_id=%s, new_entity_id=%s)", + entity_entry.entity_id, + target_device.value, + device_id_update, + new_entity_id, + ) + # entity_registry.async_update_entity() keyword-arg overloads don't + # accept a splatted dict (mypy: incompatible with the Mapping/bool/int/ + # ... union of every parameter type), so branch explicitly instead. + if device_id_update is not None and new_entity_id is not None: + entity_reg.async_update_entity( + entity_entry.entity_id, + device_id=device_id_update, + new_entity_id=new_entity_id, + ) + elif device_id_update is not None: + entity_reg.async_update_entity( + entity_entry.entity_id, device_id=device_id_update + ) + elif new_entity_id is not None: + entity_reg.async_update_entity( + entity_entry.entity_id, new_entity_id=new_entity_id + ) + migrated += 1 + + hass.config_entries.async_update_entry( + entry, + data={**entry.data, DEVICE_MIGRATION_DATA_KEY: DEVICE_MIGRATION_VERSION}, + ) + _LOGGER.info( + "HSEM device-split migration complete for entry %s (%d entities moved)", + entry.entry_id, + migrated, + ) diff --git a/custom_components/hsem/devices.py b/custom_components/hsem/devices.py new file mode 100644 index 00000000..edce3fca --- /dev/null +++ b/custom_components/hsem/devices.py @@ -0,0 +1,70 @@ +"""Per-subsystem device grouping for the HSEM integration (issue #875). + +Historically every HSEM entity was attached to a single Home Assistant +device (``DeviceInfo(identifiers={(DOMAIN, entry_id)}, ...)``). With ~220+ +entities that made the device page unwieldy and impossible to scope +automations/dashboards or HA Areas around one subsystem. + +This module defines the 7 devices entities can now be attached to, plus a +:func:`get_device_info` dispatcher used by :class:`HSEMEntity.device_info`. +``HSEMDevice.CONTROLLER`` keeps the original ``(DOMAIN, entry_id)`` +identifier so entities that stay on it need no migration; every other +device gets a new ``(DOMAIN, f"{entry_id}_{device}")`` identifier and +requires the one-time entity-registry migration in +:mod:`custom_components.hsem.device_migration`. +""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.helpers.device_registry import DeviceInfo + +from custom_components.hsem.const import DOMAIN, NAME + + +class HSEMDevice(StrEnum): + """Identifies which of the 7 HSEM devices an entity is attached to.""" + + CONTROLLER = "controller" + BATTERY_ENERGY = "battery_energy" + HOURLY_CONSUMPTION = "hourly_consumption" + FINANCIAL = "financial" + FORECAST = "forecast" + EV_PRIMARY = "ev_primary" + EV_SECONDARY = "ev_secondary" + + +#: Display name for each device. ``CONTROLLER`` keeps the bare integration +#: name — it is the pre-split single device and needs no visual change. +_DEVICE_NAMES: dict[HSEMDevice, str] = { + HSEMDevice.CONTROLLER: NAME, + HSEMDevice.BATTERY_ENERGY: f"{NAME} Battery & Energy", + HSEMDevice.HOURLY_CONSUMPTION: f"{NAME} Hourly Consumption Profile", + HSEMDevice.FINANCIAL: f"{NAME} Financial", + HSEMDevice.FORECAST: f"{NAME} Forecast", + HSEMDevice.EV_PRIMARY: f"{NAME} EV Primary", + HSEMDevice.EV_SECONDARY: f"{NAME} EV Secondary", +} + + +def get_device_identifier(entry_id: str, device: HSEMDevice) -> str: + """Return the device-registry identifier suffix for ``device``. + + ``CONTROLLER`` reuses the bare ``entry_id`` identifier that predates + the device split so its entities never need a migration. Every other + device gets a stable ``f"{entry_id}_{device.value}"`` identifier. + """ + if device is HSEMDevice.CONTROLLER: + return entry_id + return f"{entry_id}_{device.value}" + + +def get_device_info(entry_id: str, device: HSEMDevice) -> DeviceInfo: + """Return the :class:`DeviceInfo` for one of the 7 HSEM devices.""" + return DeviceInfo( + identifiers={(DOMAIN, get_device_identifier(entry_id, device))}, + name=_DEVICE_NAMES[device], + manufacturer=DOMAIN.upper(), + model="Custom Integration", + ) diff --git a/custom_components/hsem/entity.py b/custom_components/hsem/entity.py index 839b8b6a..f4022156 100644 --- a/custom_components/hsem/entity.py +++ b/custom_components/hsem/entity.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from custom_components.hsem.const import DOMAIN, NAME +from custom_components.hsem.devices import HSEMDevice, get_device_info from custom_components.hsem.utils.logger import HSEM_LOGGER as _LOGGER if TYPE_CHECKING: @@ -28,6 +28,13 @@ class HSEMEntity(Entity): _attr_icon = "mdi:flash" _attr_has_entity_name = True + #: Which of the 7 HSEM devices (issue #875) this entity attaches to. + #: Defaults to ``CONTROLLER`` (the original single-device identifier); + #: subclasses that belong elsewhere set this in ``__init__`` (either as + #: a fixed value, or dynamically for entities that come in + #: primary/secondary EV pairs). + _hsem_device: HSEMDevice = HSEMDevice.CONTROLLER + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize the HSEM entity.""" super().__init__() @@ -36,13 +43,8 @@ def __init__(self, config_entry: ConfigEntry) -> None: @property @override def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._config.entry_id)}, - name=NAME, - manufacturer=DOMAIN.upper(), - model="Custom Integration", - ) + """Return the device information for this entity's target device.""" + return get_device_info(self._config.entry_id, self._hsem_device) @override async def async_will_remove_from_hass(self) -> None: diff --git a/custom_components/hsem/number.py b/custom_components/hsem/number.py index e0c17bfe..c26b1793 100644 --- a/custom_components/hsem/number.py +++ b/custom_components/hsem/number.py @@ -121,6 +121,7 @@ async def async_setup_entry( # NOSONAR -- HA platform callback, must be async config_key=config_keys[description.key], unique_id=_id_map[description.key][0], entity_id=_id_map[description.key][1], + is_second=description.key == get_ev_second_target_soc_number_key(), ) ) else: diff --git a/custom_components/hsem/switch.py b/custom_components/hsem/switch.py index 463583e6..048bf79e 100644 --- a/custom_components/hsem/switch.py +++ b/custom_components/hsem/switch.py @@ -13,6 +13,7 @@ HSEMSwitchEntityDescription, ) from custom_components.hsem.custom_switches.switch import HSEMSwitch +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.utils.misc import get_config_value from custom_components.hsem.utils.sensornames.controls import ( get_batteries_schedule_1_switch_key, @@ -62,46 +63,55 @@ key=get_batteries_schedule_1_switch_key(), icon=_ICON_TOGGLE, translation_key="batteries_schedule_1", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMSwitchEntityDescription( key=get_batteries_schedule_2_switch_key(), icon=_ICON_TOGGLE, translation_key="batteries_schedule_2", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMSwitchEntityDescription( key=get_batteries_schedule_3_switch_key(), icon=_ICON_TOGGLE, translation_key="batteries_schedule_3", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMSwitchEntityDescription( key=get_ev_force_discharge_switch_key(), icon=_ICON_TOGGLE, translation_key="ev_force_discharge", + hsem_device=HSEMDevice.EV_PRIMARY, ), HSEMSwitchEntityDescription( key=get_ev_smart_charging_switch_key(), icon=_ICON_EV, translation_key="ev_smart_charging", + hsem_device=HSEMDevice.EV_PRIMARY, ), HSEMSwitchEntityDescription( key=get_ev_force_charge_now_switch_key(), icon=_ICON_EV, translation_key="ev_force_charge_now", + hsem_device=HSEMDevice.EV_PRIMARY, ), HSEMSwitchEntityDescription( key=get_ev_auto_full_negative_price_switch_key(), icon=_ICON_EV, translation_key="ev_auto_full_negative_price", + hsem_device=HSEMDevice.EV_PRIMARY, ), HSEMSwitchEntityDescription( key=get_ev_second_smart_charging_switch_key(), icon=_ICON_EV, translation_key="ev_second_smart_charging", + hsem_device=HSEMDevice.EV_SECONDARY, ), HSEMSwitchEntityDescription( key=get_ev_second_force_charge_now_switch_key(), icon=_ICON_EV, translation_key="ev_second_force_charge_now", + hsem_device=HSEMDevice.EV_SECONDARY, ), HSEMSwitchEntityDescription( key=get_ml_consumption_switch_key(), @@ -117,6 +127,7 @@ key=get_dynamic_discharge_floor_switch_key(), icon=_ICON_TOGGLE, translation_key="dynamic_discharge_floor", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), ) diff --git a/custom_components/hsem/time.py b/custom_components/hsem/time.py index 5d3dc5da..1abc477b 100644 --- a/custom_components/hsem/time.py +++ b/custom_components/hsem/time.py @@ -10,6 +10,7 @@ from custom_components.hsem.custom_times.description import HSEMTimeEntityDescription from custom_components.hsem.custom_times.time import HSEMTimeEntity +from custom_components.hsem.devices import HSEMDevice from custom_components.hsem.utils.misc import get_config_value from custom_components.hsem.utils.sensornames.controls import ( get_schedule_1_end_time_key, @@ -34,41 +35,49 @@ key=get_schedule_1_start_time_key(), icon=_ICON_CLOCK, translation_key="schedule_1_start", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMTimeEntityDescription( key=get_schedule_1_end_time_key(), icon=_ICON_CLOCK, translation_key="schedule_1_end", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMTimeEntityDescription( key=get_schedule_2_start_time_key(), icon=_ICON_CLOCK, translation_key="schedule_2_start", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMTimeEntityDescription( key=get_schedule_2_end_time_key(), icon=_ICON_CLOCK, translation_key="schedule_2_end", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMTimeEntityDescription( key=get_schedule_3_start_time_key(), icon=_ICON_CLOCK, translation_key="schedule_3_start", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMTimeEntityDescription( key=get_schedule_3_end_time_key(), icon=_ICON_CLOCK, translation_key="schedule_3_end", + hsem_device=HSEMDevice.BATTERY_ENERGY, ), HSEMTimeEntityDescription( key=get_ev_deadline_time_key(), icon=_ICON_CLOCK, translation_key="ev_deadline", + hsem_device=HSEMDevice.EV_PRIMARY, ), HSEMTimeEntityDescription( key=get_ev_second_deadline_time_key(), icon=_ICON_CLOCK, translation_key="ev_second_deadline", + hsem_device=HSEMDevice.EV_SECONDARY, ), ) @@ -106,6 +115,7 @@ async def async_setup_entry( # NOSONAR -- HA platform callback, must be async icon=description.icon, translation_key=description.translation_key, default_value=str(get_config_value(config_entry, description.key)), + hsem_device=description.hsem_device, ), ) for description in descriptions diff --git a/custom_components/hsem/translations/en.json b/custom_components/hsem/translations/en.json index c12ae314..45e824e5 100644 --- a/custom_components/hsem/translations/en.json +++ b/custom_components/hsem/translations/en.json @@ -1105,13 +1105,13 @@ } }, "ev_second_charger_calculated_power": { - "name": "EV 2 Charger Calculated Power" + "name": "EV Charger Calculated Power" }, "ev_second_charger_current_limit": { - "name": "EV 2 Charger Current Limit" + "name": "EV Charger Current Limit" }, "ev_second_optimal_charging_plan": { - "name": "EV 2 Optimal Charging Plan", + "name": "EV Optimal Charging Plan", "state": { "charging": "Charging", "fully_charged": "Fully Charged", @@ -1190,16 +1190,16 @@ "name": "Effective Discharge Floor" }, "ocpp_charger_status": { - "name": "OCPP Charger Status" + "name": "Charger Status" }, "ocpp_charger_power": { - "name": "OCPP Charger Power" + "name": "Charger Power" }, "ocpp_charger_info": { - "name": "OCPP Charger Info" + "name": "Charger Info" }, "ocpp_charger_sessions": { - "name": "OCPP Charger Sessions" + "name": "Charger Sessions" } }, "switch": { @@ -1231,10 +1231,10 @@ "name": "EV Force Charge Now" }, "ev_second_smart_charging": { - "name": "EV 2 Smart Charging" + "name": "EV Smart Charging" }, "ev_second_force_charge_now": { - "name": "EV 2 Force Charge Now" + "name": "EV Force Charge Now" }, "ml_consumption": { "name": "ML consumption prediction" @@ -1268,7 +1268,7 @@ "name": "EV Target SoC" }, "ev_second_target_soc": { - "name": "EV 2 Target SoC" + "name": "EV Target SoC" } }, "time": { @@ -1294,7 +1294,7 @@ "name": "EV Charge Deadline" }, "ev_second_deadline": { - "name": "EV 2 Charge Deadline" + "name": "EV Charge Deadline" } } }, diff --git a/custom_components/hsem/utils/sensornames/ev.py b/custom_components/hsem/utils/sensornames/ev.py index 583bc4ad..8560b7a3 100644 --- a/custom_components/hsem/utils/sensornames/ev.py +++ b/custom_components/hsem/utils/sensornames/ev.py @@ -54,8 +54,13 @@ def get_ev_optimal_charging_plan_sensor_entity_id() -> str: # EV Second Optimal Charging Plan Sensor def get_ev_second_optimal_charging_plan_sensor_name() -> str: - """Return the display name for the second EV optimal charging plan sensor.""" - return "EV 2 Optimal Charging Plan" + """Return the display name for the second EV optimal charging plan sensor. + + Identical to the primary sensor's name — the EV Primary / EV Secondary + device (issue #875) disambiguates them, so the entity name carries no + redundant "Second"/"2" marker. + """ + return "EV Optimal Charging Plan" def get_ev_second_optimal_charging_plan_sensor_unique_id(entry_id: str) -> str: @@ -114,8 +119,13 @@ def get_ev_charger_current_limit_sensor_entity_id() -> str: # EV Second Charger Current Limit Sensor def get_ev_second_charger_current_limit_sensor_name() -> str: - """Return the display name for the second EV charger current limit sensor.""" - return "EV 2 Charger Current Limit" + """Return the display name for the second EV charger current limit sensor. + + Identical to the primary sensor's name — the EV Primary / EV Secondary + device (issue #875) disambiguates them, so the entity name carries no + redundant "Second"/"2" marker. + """ + return "EV Charger Current Limit" def get_ev_second_charger_current_limit_sensor_unique_id(entry_id: str) -> str: @@ -134,8 +144,13 @@ def get_ev_second_charger_current_limit_sensor_entity_id() -> str: # EV Second Charger Calculated Power Sensor def get_ev_second_charger_calculated_power_sensor_name() -> str: - """Return the display name for the second EV charger calculated power sensor.""" - return "EV 2 Charger Calculated Power" + """Return the display name for the second EV charger calculated power sensor. + + Identical to the primary sensor's name — the EV Primary / EV Secondary + device (issue #875) disambiguates them, so the entity name carries no + redundant "Second"/"2" marker. + """ + return "EV Charger Calculated Power" def get_ev_second_charger_calculated_power_sensor_unique_id(entry_id: str) -> str: diff --git a/custom_components/hsem/utils/sensornames/ocpp.py b/custom_components/hsem/utils/sensornames/ocpp.py index f6fa5bdb..8e1875b8 100644 --- a/custom_components/hsem/utils/sensornames/ocpp.py +++ b/custom_components/hsem/utils/sensornames/ocpp.py @@ -20,10 +20,16 @@ def _suffix(charger_index: int) -> str: return "" if charger_index == 1 else "_second" -def get_ocpp_charger_status_sensor_name(charger_index: int = 1) -> str: - """Return the display name for the OCPP charger status sensor.""" - suffix = " Second" if charger_index == 2 else "" - return f"OCPP Charger{suffix} Status" +def get_ocpp_charger_status_sensor_name() -> str: + """Return the display name for the OCPP charger status sensor. + + Identical for both chargers — the EV Primary / EV Secondary device + (issue #875) disambiguates them, so the entity name carries neither + the redundant "OCPP" integration prefix nor a "Second"/"2" marker + (e.g. rendered as "EV Secondary Charger Status" via + ``_attr_has_entity_name``). + """ + return "Charger Status" def get_ocpp_charger_status_sensor_unique_id( @@ -48,10 +54,14 @@ def get_ocpp_charger_status_sensor_entity_id(charger_index: int = 1) -> str: # --------------------------------------------------------------------------- -def get_ocpp_charger_power_sensor_name(charger_index: int = 1) -> str: - """Return the display name for the OCPP charger power sensor.""" - suffix = " Second" if charger_index == 2 else "" - return f"OCPP Charger{suffix} Power" +def get_ocpp_charger_power_sensor_name() -> str: + """Return the display name for the OCPP charger power sensor. + + Identical for both chargers — the EV Primary / EV Secondary device + (issue #875) disambiguates them, so the entity name carries neither + the redundant "OCPP" integration prefix nor a "Second"/"2" marker. + """ + return "Charger Power" def get_ocpp_charger_power_sensor_unique_id( @@ -76,10 +86,14 @@ def get_ocpp_charger_power_sensor_entity_id(charger_index: int = 1) -> str: # --------------------------------------------------------------------------- -def get_ocpp_charger_info_sensor_name(charger_index: int = 1) -> str: - """Return the display name for the OCPP charger info sensor.""" - suffix = " Second" if charger_index == 2 else "" - return f"OCPP Charger{suffix} Info" +def get_ocpp_charger_info_sensor_name() -> str: + """Return the display name for the OCPP charger info sensor. + + Identical for both chargers — the EV Primary / EV Secondary device + (issue #875) disambiguates them, so the entity name carries neither + the redundant "OCPP" integration prefix nor a "Second"/"2" marker. + """ + return "Charger Info" def get_ocpp_charger_info_sensor_unique_id( @@ -104,10 +118,14 @@ def get_ocpp_charger_info_sensor_entity_id(charger_index: int = 1) -> str: # --------------------------------------------------------------------------- -def get_ocpp_charger_sessions_sensor_name(charger_index: int = 1) -> str: - """Return the display name for the OCPP charger sessions sensor.""" - suffix = " Second" if charger_index == 2 else "" - return f"OCPP Charger{suffix} Sessions" +def get_ocpp_charger_sessions_sensor_name() -> str: + """Return the display name for the OCPP charger sessions sensor. + + Identical for both chargers — the EV Primary / EV Secondary device + (issue #875) disambiguates them, so the entity name carries neither + the redundant "OCPP" integration prefix nor a "Second"/"2" marker. + """ + return "Charger Sessions" def get_ocpp_charger_sessions_sensor_unique_id( diff --git a/docs/home.md b/docs/home.md index a5c93524..c672c323 100644 --- a/docs/home.md +++ b/docs/home.md @@ -27,6 +27,23 @@ HSEM is a modular, secure, and highly configurable Home Assistant integration th --- +## Devices + +HSEM's entities are split across 7 Home Assistant devices — **Controller**, +**Battery & Energy**, **Hourly Consumption Profile**, **Financial**, +**Forecast**, **EV Primary**, and **EV Secondary** — instead of one, so each +subsystem can be scoped independently in dashboards, automations, and Areas. +See [Devices](sensors-reference.md#devices-issue-875) in the sensors +reference for the full entity-to-device mapping. + +> **Upgrading from an older HSEM version?** A one-time migration moves +> existing entities to their new device on first startup; dashboards or +> automations that reference the old single device_id will need to be +> re-pointed to the new devices. `unique_id`s and entity history/statistics +> are preserved automatically. + +--- + ## Features - **Dynamic Grid Export/Import Management** — avoids export at negative prices, forces charging at negative import prices. diff --git a/docs/sensors-reference.md b/docs/sensors-reference.md index c79c0902..8e1ed58b 100644 --- a/docs/sensors-reference.md +++ b/docs/sensors-reference.md @@ -19,6 +19,41 @@ HSEM exposes these entity types: --- +## Devices (issue #875) + +Entities are split across 7 Home Assistant devices instead of one, so each +subsystem gets its own device page and can be scoped independently in +dashboards, automations, and Areas. + +| Device | Identifier suffix | Entities | +| ------------------------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Controller** | _(none — legacy)_ | Working mode, degraded mode, read-only, hardware-writes, missing-entities, force-mode, last/next-updated, update-interval, applier-status, plan-explanation, daily-plan-vs-actual, recommendation-interval, force-working-mode selector, extended-attributes/verbose-logging/ML switches | +| **Battery & Energy** | `_battery_energy` | Battery SoC, effective discharge floor, net consumption, PV curtailment, charge/discharge efficiency numbers, battery schedule 1/2/3 switches and start/end times, dynamic discharge floor switch | +| **Hourly Consumption Profile** | `_hourly_consumption` | All 168 per-hour-block entities (`HSEMHouseConsumptionPowerSensor` + integral + utility-meter + 1/3/7/14-day averages × 24 hour blocks) | +| **Financial** | `_financial` | Export income, import cost, net grid balance, savings | +| **Forecast** | `_forecast` | Forecast accuracy, prediction accuracy, solar confidence, Solcast likelihood selector | +| **EV Primary** | `_ev_primary` | EV charging/plan sensors, calculated power, current limit, target SoC number, deadline time, smart-charging/force-charge-now/force-discharge/auto-full switches, OCPP charger sensors for `charger_index=1` | +| **EV Secondary** | `_ev_secondary` | The same set as EV Primary for `charger_index=2` / the second EV (only present when the second EV is configured) | + +The Controller device keeps the original `(DOMAIN, entry_id)` identifier that +predates the split — no migration is needed for its entities. Every other +device is identified as `(DOMAIN, f"{entry_id}_")`. + +**Naming:** EV Secondary and OCPP entity _names_ no longer carry a redundant +`"Second"`/`"2"` marker (e.g. `"Charger Status"`, not `"OCPP Charger Second +Status"`) — the device name (e.g. "HSEM EV Secondary") already disambiguates +via `_attr_has_entity_name = True`. `unique_id` and `entity_id` are +unchanged. + +**Migration:** on first startup after upgrading, a one-time migration +(`custom_components/hsem/device_migration.py`) reassigns `device_id` for +every pre-existing entity to its new device, gated by a migration-version +flag stored on the config entry so it runs exactly once. `unique_id` is +never touched. This is a **breaking change** for dashboards/automations that +reference the old single `device_id`. + +--- + ## Working mode sensor The primary HSEM sensor. Exposes the active battery recommendation and carries diff --git a/tests/custom_sensors/test_ocpp_per_ev.py b/tests/custom_sensors/test_ocpp_per_ev.py index fffb67c2..02f288e8 100644 --- a/tests/custom_sensors/test_ocpp_per_ev.py +++ b/tests/custom_sensors/test_ocpp_per_ev.py @@ -45,14 +45,19 @@ def test_primary_sensor_names_unchanged(): def test_second_sensor_names_are_distinct(): - """charger_index=2 produces distinct, slugified second-server entities.""" + """charger_index=2 produces distinct, slugified second-server entities. + + The display name itself is identical to the primary sensor's — the EV + Primary / EV Secondary device (issue #875) disambiguates them instead of + a "Second"/"2" name marker. + """ entity_id = get_ocpp_charger_power_sensor_entity_id(charger_index=2) unique_id = get_ocpp_charger_power_sensor_unique_id("entry", charger_index=2) - name = get_ocpp_charger_power_sensor_name(2) + name = get_ocpp_charger_power_sensor_name() assert entity_id == "sensor.hsem_ocpp_charger_power_sensor_second" assert unique_id == "hsem_entry_ocpp_charger_power_sensor_second" - assert name == "OCPP Charger Second Power" + assert name == "Charger Power" assert entity_id != get_ocpp_charger_power_sensor_entity_id() diff --git a/tests/test_device_split.py b/tests/test_device_split.py new file mode 100644 index 00000000..cac9ca80 --- /dev/null +++ b/tests/test_device_split.py @@ -0,0 +1,716 @@ +"""Tests for the HSEM device split (issue #875). + +Covers: +- ``devices.py`` — ``DeviceInfo`` construction for all 7 devices. +- ``HSEMEntity.device_info`` dispatch, including per-instance dynamic + dispatch (EV primary/secondary, OCPP charger_index, switch/time/number + description-driven devices). +- ``device_migration.py`` — offline ``unique_id`` classification and the + one-time, idempotent entity-registry migration. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.number import NumberEntityDescription +from homeassistant.components.select import SelectEntityDescription +from homeassistant.helpers.device_registry import DeviceInfo + +from custom_components.hsem.const import DOMAIN, NAME +from custom_components.hsem.custom_numbers.battery_efficiency import ( + HSEMBatteryEfficiencyNumber, +) +from custom_components.hsem.custom_numbers.ev_target_soc import HSEMEVTargetSocNumber +from custom_components.hsem.custom_selectors.solcast_likelihood import ( + HSEMSolcastLikelihoodSelector, +) +from custom_components.hsem.custom_selectors.working_mode import HSEMWorkingModeSelector +from custom_components.hsem.custom_sensors.avg_sensor import HSEMAvgSensor +from custom_components.hsem.custom_sensors.battery_soc_sensor import ( + HSEMBatterySoCSensor, +) +from custom_components.hsem.custom_sensors.degraded_mode_sensor import ( + HSEMDegradedModeSensor, +) +from custom_components.hsem.custom_sensors.ev_charger_calculated_power_sensor import ( + HSEMEVChargerCalculatedPowerSensor, + HSEMEVSecondChargerCalculatedPowerSensor, +) +from custom_components.hsem.custom_sensors.financial_sensors import ( + HSEMExportIncomeSensor, +) +from custom_components.hsem.custom_sensors.forecast_accuracy_sensor import ( + HSEMForecastAccuracySensor, +) +from custom_components.hsem.custom_sensors.house_consumption_power_sensor import ( + HSEMHouseConsumptionPowerSensor, +) +from custom_components.hsem.custom_sensors.ocpp_sensors import ( + HSEMOCPPChargerStatusSensor, +) +from custom_components.hsem.custom_switches.description import ( + HSEMSwitchEntityDescription, +) +from custom_components.hsem.custom_switches.switch import HSEMSwitch +from custom_components.hsem.custom_times.description import HSEMTimeEntityDescription +from custom_components.hsem.custom_times.time import HSEMTimeEntity +from custom_components.hsem.device_migration import ( + _ENTITY_ID_RENAMES, + DEVICE_MIGRATION_DATA_KEY, + DEVICE_MIGRATION_VERSION, + async_migrate_devices, + classify_entity_device, +) +from custom_components.hsem.devices import ( + HSEMDevice, + get_device_identifier, + get_device_info, +) +from custom_components.hsem.entity import HSEMEntity + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ENTRY_ID = "test_entry_id" + + +def _mock_config_entry(**option_overrides: object) -> MagicMock: + """Return a minimal mock ConfigEntry with real ``options``/``data`` dicts.""" + entry = MagicMock() + entry.entry_id = _ENTRY_ID + entry.options = dict(option_overrides) + entry.data = {} + return entry + + +def _mock_hass() -> MagicMock: + """Return a minimal Home Assistant mock.""" + hass = MagicMock() + hass.config_entries = MagicMock() + hass.config_entries.async_update_entry = MagicMock() + return hass + + +def _mock_coordinator() -> MagicMock: + """Return a minimal mock HSEMDataUpdateCoordinator.""" + coordinator = MagicMock() + coordinator.data = None + coordinator.last_update_success = True + return coordinator + + +# --------------------------------------------------------------------------- +# devices.py +# --------------------------------------------------------------------------- + + +class TestGetDeviceIdentifier: + """Controller keeps the pre-split identifier; every other device is new.""" + + def test_controller_keeps_bare_entry_id(self) -> None: + assert get_device_identifier(_ENTRY_ID, HSEMDevice.CONTROLLER) == _ENTRY_ID + + @pytest.mark.parametrize( + "device", + [ + HSEMDevice.BATTERY_ENERGY, + HSEMDevice.HOURLY_CONSUMPTION, + HSEMDevice.FINANCIAL, + HSEMDevice.FORECAST, + HSEMDevice.EV_PRIMARY, + HSEMDevice.EV_SECONDARY, + ], + ) + def test_non_controller_devices_get_suffixed_identifier( + self, device: HSEMDevice + ) -> None: + identifier = get_device_identifier(_ENTRY_ID, device) + assert identifier == f"{_ENTRY_ID}_{device.value}" + assert identifier != _ENTRY_ID + + +class TestGetDeviceInfo: + """DeviceInfo construction for all 7 devices.""" + + def test_all_devices_produce_distinct_device_info(self) -> None: + infos = [get_device_info(_ENTRY_ID, device) for device in HSEMDevice] + identifiers = [info["identifiers"] for info in infos] + assert len(identifiers) == len(set(frozenset(i) for i in identifiers)), ( + "Every device must have a unique DeviceInfo identifier" + ) + + def test_controller_device_info_matches_legacy_shape(self) -> None: + """Controller DeviceInfo is byte-for-byte the pre-split DeviceInfo.""" + info = get_device_info(_ENTRY_ID, HSEMDevice.CONTROLLER) + assert info == DeviceInfo( + identifiers={(DOMAIN, _ENTRY_ID)}, + name=NAME, + manufacturer=DOMAIN.upper(), + model="Custom Integration", + ) + + def test_manufacturer_and_model_consistent_across_devices(self) -> None: + for device in HSEMDevice: + info = get_device_info(_ENTRY_ID, device) + assert info["manufacturer"] == DOMAIN.upper() + assert info["model"] == "Custom Integration" + + +# --------------------------------------------------------------------------- +# HSEMEntity.device_info dispatch +# --------------------------------------------------------------------------- + + +class TestHSEMEntityDefault: + """The base HSEMEntity defaults to the CONTROLLER device.""" + + def test_default_device_is_controller(self) -> None: + entity = HSEMEntity(_mock_config_entry()) + assert entity.device_info["identifiers"] == {(DOMAIN, _ENTRY_ID)} + + +class TestPerEntityDeviceInfo: + """Each concrete entity class resolves device_info to its target device.""" + + def _identifiers(self, entity: HSEMEntity) -> set[tuple[str, str]]: + info = entity.device_info + assert info is not None + return set(info["identifiers"]) + + def test_controller_entity(self) -> None: + sensor = HSEMDegradedModeSensor(_mock_config_entry(), _mock_coordinator()) + assert self._identifiers(sensor) == {(DOMAIN, _ENTRY_ID)} + + def test_battery_energy_entity(self) -> None: + sensor = HSEMBatterySoCSensor(_mock_config_entry(), _mock_coordinator()) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.BATTERY_ENERGY.value}") + } + + def test_hourly_consumption_house_power_sensor(self) -> None: + sensor = HSEMHouseConsumptionPowerSensor( + _mock_config_entry(), 13, 14, MagicMock() + ) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.HOURLY_CONSUMPTION.value}") + } + + def test_hourly_consumption_avg_sensor(self) -> None: + sensor = HSEMAvgSensor( + config_entry=_mock_config_entry(), + hour_start=13, + hour_end=14, + avg=7, + tracked_entity="sensor.hsem_house_consumption_energy_13_14_utility_meter", + name="13:00-14:00 7-Day Average", + unique_id="hsem_test_entry_id_house_consumption_energy_avg_13_14_7d", + entity_id="sensor.hsem_house_consumption_energy_avg_13_14_7d", + ) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.HOURLY_CONSUMPTION.value}") + } + + def test_financial_entity(self) -> None: + sensor = HSEMExportIncomeSensor(_mock_config_entry(), _mock_coordinator()) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.FINANCIAL.value}") + } + + def test_forecast_sensor_entity(self) -> None: + sensor = HSEMForecastAccuracySensor(_mock_config_entry(), _mock_coordinator()) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.FORECAST.value}") + } + + def test_forecast_select_entity(self) -> None: + description = SelectEntityDescription(key="hsem_solcast_likelihood") + selector = HSEMSolcastLikelihoodSelector( + _mock_hass(), _mock_config_entry(), description + ) + assert self._identifiers(selector) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.FORECAST.value}") + } + + def test_ev_primary_calculated_power_sensor(self) -> None: + sensor = HSEMEVChargerCalculatedPowerSensor( + _mock_config_entry(), _mock_coordinator() + ) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.EV_PRIMARY.value}") + } + + def test_ev_secondary_calculated_power_sensor(self) -> None: + sensor = HSEMEVSecondChargerCalculatedPowerSensor( + _mock_config_entry(), _mock_coordinator() + ) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.EV_SECONDARY.value}") + } + + def test_ocpp_primary_charger_sensor(self) -> None: + sensor = HSEMOCPPChargerStatusSensor( + _mock_config_entry(), _mock_coordinator(), charger_index=1 + ) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.EV_PRIMARY.value}") + } + + def test_ocpp_secondary_charger_sensor(self) -> None: + sensor = HSEMOCPPChargerStatusSensor( + _mock_config_entry(), _mock_coordinator(), charger_index=2 + ) + assert self._identifiers(sensor) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.EV_SECONDARY.value}") + } + + def test_ev_target_soc_number_primary(self) -> None: + description = NumberEntityDescription(key="hsem_ev_target_soc") + number = HSEMEVTargetSocNumber( + _mock_hass(), + _mock_config_entry(), + description, + config_key="hsem_ev_target_soc", + is_second=False, + ) + assert self._identifiers(number) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.EV_PRIMARY.value}") + } + + def test_ev_target_soc_number_secondary(self) -> None: + description = NumberEntityDescription(key="hsem_ev_second_target_soc") + number = HSEMEVTargetSocNumber( + _mock_hass(), + _mock_config_entry(), + description, + config_key="hsem_ev_second_target_soc", + is_second=True, + ) + assert self._identifiers(number) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.EV_SECONDARY.value}") + } + + def test_battery_efficiency_number(self) -> None: + description = NumberEntityDescription(key="hsem_charge_efficiency") + number = HSEMBatteryEfficiencyNumber( + _mock_hass(), + _mock_config_entry(), + description, + config_key="hsem_batteries_charge_efficiency", + ) + assert self._identifiers(number) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.BATTERY_ENERGY.value}") + } + + def test_working_mode_selector_stays_on_controller(self) -> None: + description = SelectEntityDescription( + key="hsem_force_working_mode", options=["auto"] + ) + selector = HSEMWorkingModeSelector( + _mock_hass(), _mock_config_entry(), description, "auto" + ) + assert self._identifiers(selector) == {(DOMAIN, _ENTRY_ID)} + + def test_switch_uses_description_hsem_device(self) -> None: + description = HSEMSwitchEntityDescription( + key="hsem_batteries_enable_batteries_schedule_1", + hsem_device=HSEMDevice.BATTERY_ENERGY, + ) + switch = HSEMSwitch(_mock_hass(), _mock_config_entry(), description) + assert self._identifiers(switch) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.BATTERY_ENERGY.value}") + } + + def test_switch_default_description_device_is_controller(self) -> None: + description = HSEMSwitchEntityDescription(key="hsem_read_only") + switch = HSEMSwitch(_mock_hass(), _mock_config_entry(), description) + assert self._identifiers(switch) == {(DOMAIN, _ENTRY_ID)} + + def test_time_uses_description_hsem_device(self) -> None: + description = HSEMTimeEntityDescription( + key="hsem_ev_second_deadline_time", + default_value="07:00:00", + hsem_device=HSEMDevice.EV_SECONDARY, + ) + time_entity = HSEMTimeEntity(_mock_hass(), _mock_config_entry(), description) + assert self._identifiers(time_entity) == { + (DOMAIN, f"{_ENTRY_ID}_{HSEMDevice.EV_SECONDARY.value}") + } + + +# --------------------------------------------------------------------------- +# device_migration.py — classification +# --------------------------------------------------------------------------- + + +class TestClassifyEntityDevice: + """Offline unique_id -> HSEMDevice classification used by the migration.""" + + @pytest.mark.parametrize( + ("unique_id", "expected"), + [ + # Controller (default / unchanged entities). + (f"{DOMAIN}_{_ENTRY_ID}_workingmode_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_degraded_mode_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_read_only_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_hardware_writes_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_missing_entities_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_force_mode_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_last_updated_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_next_update_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_update_interval_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_applier_status_sensor", HSEMDevice.CONTROLLER), + (f"{DOMAIN}_{_ENTRY_ID}_plan_explanation_sensor", HSEMDevice.CONTROLLER), + ( + f"{DOMAIN}_{_ENTRY_ID}_{DOMAIN}_daily_plan_vs_actual_sensor", + HSEMDevice.CONTROLLER, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_recommendation_interval_sensor", + HSEMDevice.CONTROLLER, + ), + # Battery & Energy. + (f"{DOMAIN}_{_ENTRY_ID}_battery_soc_sensor", HSEMDevice.BATTERY_ENERGY), + ( + f"{DOMAIN}_{_ENTRY_ID}_effective_discharge_floor_sensor", + HSEMDevice.BATTERY_ENERGY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_net_consumption_sensor", + HSEMDevice.BATTERY_ENERGY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_pv_curtailment_sensor", + HSEMDevice.BATTERY_ENERGY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_battery_charge_efficiency", + HSEMDevice.BATTERY_ENERGY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_battery_discharge_efficiency", + HSEMDevice.BATTERY_ENERGY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_{DOMAIN}_batteries_enable_batteries_schedule_1_switch", + HSEMDevice.BATTERY_ENERGY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_{DOMAIN}_batteries_enable_batteries_schedule_2_start_time", + HSEMDevice.BATTERY_ENERGY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_{DOMAIN}_dynamic_discharge_floor_switch", + HSEMDevice.BATTERY_ENERGY, + ), + # Hourly Consumption Profile. + ( + f"{DOMAIN}_{_ENTRY_ID}_house_consumption_power_13_14", + HSEMDevice.HOURLY_CONSUMPTION, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_house_consumption_energy_integral_13_14", + HSEMDevice.HOURLY_CONSUMPTION, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_house_consumption_energy_avg_13_14_7d", + HSEMDevice.HOURLY_CONSUMPTION, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_house_consumption_energy_13_14_utility_meter", + HSEMDevice.HOURLY_CONSUMPTION, + ), + # Financial. + (f"{DOMAIN}_{_ENTRY_ID}_export_income_sensor", HSEMDevice.FINANCIAL), + (f"{DOMAIN}_{_ENTRY_ID}_import_cost_sensor", HSEMDevice.FINANCIAL), + (f"{DOMAIN}_{_ENTRY_ID}_net_grid_balance_sensor", HSEMDevice.FINANCIAL), + (f"{DOMAIN}_{_ENTRY_ID}_savings_tracker_sensor", HSEMDevice.FINANCIAL), + # Forecast. + (f"{DOMAIN}_{_ENTRY_ID}_forecast_accuracy_sensor", HSEMDevice.FORECAST), + (f"{DOMAIN}_{_ENTRY_ID}_solar_confidence_sensor", HSEMDevice.FORECAST), + (f"{DOMAIN}_{_ENTRY_ID}_prediction_accuracy_sensor", HSEMDevice.FORECAST), + (f"{DOMAIN}_solcast_likelihood_{_ENTRY_ID}", HSEMDevice.FORECAST), + # EV Primary. + (f"{DOMAIN}_{_ENTRY_ID}_ev_charging_sensor", HSEMDevice.EV_PRIMARY), + ( + f"{DOMAIN}_{_ENTRY_ID}_ev_optimal_charging_plan", + HSEMDevice.EV_PRIMARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ev_charger_calculated_power", + HSEMDevice.EV_PRIMARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ev_charger_current_limit", + HSEMDevice.EV_PRIMARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_{DOMAIN}_ev_target_soc_number", + HSEMDevice.EV_PRIMARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ocpp_charger_status_sensor", + HSEMDevice.EV_PRIMARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ocpp_charger_sessions_sensor", + HSEMDevice.EV_PRIMARY, + ), + # EV Secondary. + ( + f"{DOMAIN}_{_ENTRY_ID}_ev_second_optimal_charging_plan", + HSEMDevice.EV_SECONDARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ev_second_charger_current_limit", + HSEMDevice.EV_SECONDARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ev_second_charger_calculated_power", + HSEMDevice.EV_SECONDARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_{DOMAIN}_ev_second_target_soc_number", + HSEMDevice.EV_SECONDARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_{DOMAIN}_ev_second_deadline_time_time", + HSEMDevice.EV_SECONDARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ocpp_charger_status_sensor_second", + HSEMDevice.EV_SECONDARY, + ), + ( + f"{DOMAIN}_{_ENTRY_ID}_ocpp_charger_sessions_sensor_second", + HSEMDevice.EV_SECONDARY, + ), + ], + ) + def test_classification(self, unique_id: str, expected: HSEMDevice) -> None: + assert classify_entity_device(unique_id) is expected + + +# --------------------------------------------------------------------------- +# device_migration.py — async_migrate_devices +# --------------------------------------------------------------------------- + + +def _make_entity_entry( + entity_id: str, unique_id: str, device_id: str | None = None +) -> MagicMock: + entry = MagicMock() + entry.entity_id = entity_id + entry.unique_id = unique_id + entry.device_id = device_id + return entry + + +class TestAsyncMigrateDevices: + """One-time, idempotent entity-registry migration.""" + + @pytest.mark.asyncio + async def test_migrates_device_and_marks_entry(self) -> None: + hass = _mock_hass() + config_entry = _mock_config_entry() + + entity_entries = [ + _make_entity_entry( + "sensor.hsem_battery_soc_sensor", + f"{DOMAIN}_{_ENTRY_ID}_battery_soc_sensor", + device_id="old-controller-device", + ), + ] + + entity_reg = MagicMock() + device_reg = MagicMock() + new_device = MagicMock() + new_device.id = "device-battery-energy" + device_reg.async_get_or_create.return_value = new_device + + with ( + patch( + "custom_components.hsem.device_migration.er.async_get", + return_value=entity_reg, + ), + patch( + "custom_components.hsem.device_migration.dr.async_get", + return_value=device_reg, + ), + patch( + "custom_components.hsem.device_migration.er.async_entries_for_config_entry", + return_value=entity_entries, + ), + ): + await async_migrate_devices(hass, config_entry) + + entity_reg.async_update_entity.assert_called_once_with( + "sensor.hsem_battery_soc_sensor", device_id="device-battery-energy" + ) + hass.config_entries.async_update_entry.assert_called_once() + _, kwargs = hass.config_entries.async_update_entry.call_args + assert kwargs["data"][DEVICE_MIGRATION_DATA_KEY] == DEVICE_MIGRATION_VERSION + # unique_id must never be touched by the migration. + assert "new_unique_id" not in entity_reg.async_update_entity.call_args.kwargs + + @pytest.mark.asyncio + async def test_entity_already_on_correct_device_is_untouched(self) -> None: + hass = _mock_hass() + config_entry = _mock_config_entry() + + entity_entries = [ + _make_entity_entry( + "sensor.hsem_battery_soc_sensor", + f"{DOMAIN}_{_ENTRY_ID}_battery_soc_sensor", + device_id="device-battery-energy", + ), + ] + + entity_reg = MagicMock() + device_reg = MagicMock() + existing_device = MagicMock() + existing_device.id = "device-battery-energy" + device_reg.async_get_or_create.return_value = existing_device + + with ( + patch( + "custom_components.hsem.device_migration.er.async_get", + return_value=entity_reg, + ), + patch( + "custom_components.hsem.device_migration.dr.async_get", + return_value=device_reg, + ), + patch( + "custom_components.hsem.device_migration.er.async_entries_for_config_entry", + return_value=entity_entries, + ), + ): + await async_migrate_devices(hass, config_entry) + + entity_reg.async_update_entity.assert_not_called() + # The migration-version flag is still recorded even when no entity + # needed a device move, so a second run is still a no-op. + hass.config_entries.async_update_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_second_run_is_a_noop(self) -> None: + hass = _mock_hass() + config_entry = _mock_config_entry() + config_entry.data = {DEVICE_MIGRATION_DATA_KEY: DEVICE_MIGRATION_VERSION} + + entity_reg = MagicMock() + device_reg = MagicMock() + + with ( + patch( + "custom_components.hsem.device_migration.er.async_get", + return_value=entity_reg, + ), + patch( + "custom_components.hsem.device_migration.dr.async_get", + return_value=device_reg, + ), + patch( + "custom_components.hsem.device_migration.er.async_entries_for_config_entry" + ) as mock_entries, + ): + await async_migrate_devices(hass, config_entry) + + mock_entries.assert_not_called() + entity_reg.async_update_entity.assert_not_called() + hass.config_entries.async_update_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_entity_id_rename_uses_registry_primitive(self) -> None: + """The rename primitive is exercised so statistic-following is guaranteed. + + No production entity needs a rename today (unique_id and entity_id + getters were kept frozen — see the module docstring), but the + mechanism is real and tested: renaming via + ``entity_registry.async_update_entity(..., new_entity_id=...)`` is + the exact call HA's recorder listens to when following long-term + statistics to a renamed ``statistic_id``. + """ + hass = _mock_hass() + config_entry = _mock_config_entry() + + old_unique_id = f"{DOMAIN}_{_ENTRY_ID}_export_income_sensor" + old_entity_id = "sensor.hsem_export_income_old" + new_entity_id = "sensor.hsem_export_income" + + entity_entries = [ + _make_entity_entry( + old_entity_id, old_unique_id, device_id="device-financial" + ), + ] + + entity_reg = MagicMock() + device_reg = MagicMock() + device = MagicMock() + device.id = "device-financial" + device_reg.async_get_or_create.return_value = device + + with ( + patch( + "custom_components.hsem.device_migration.er.async_get", + return_value=entity_reg, + ), + patch( + "custom_components.hsem.device_migration.dr.async_get", + return_value=device_reg, + ), + patch( + "custom_components.hsem.device_migration.er.async_entries_for_config_entry", + return_value=entity_entries, + ), + patch.dict(_ENTITY_ID_RENAMES, {old_unique_id: new_entity_id}), + ): + await async_migrate_devices(hass, config_entry) + + entity_reg.async_update_entity.assert_called_once_with( + old_entity_id, new_entity_id=new_entity_id + ) + + @pytest.mark.asyncio + async def test_unique_id_is_never_included_in_migration_updates(self) -> None: + """Defensive regression guard: the migration must never rewrite unique_id.""" + hass = _mock_hass() + config_entry = _mock_config_entry() + + entity_entries = [ + _make_entity_entry( + "sensor.hsem_export_income", + f"{DOMAIN}_{_ENTRY_ID}_export_income_sensor", + device_id="old-device", + ), + ] + + entity_reg = MagicMock() + device_reg = MagicMock() + device = MagicMock() + device.id = "device-financial" + device_reg.async_get_or_create.return_value = device + + with ( + patch( + "custom_components.hsem.device_migration.er.async_get", + return_value=entity_reg, + ), + patch( + "custom_components.hsem.device_migration.dr.async_get", + return_value=device_reg, + ), + patch( + "custom_components.hsem.device_migration.er.async_entries_for_config_entry", + return_value=entity_entries, + ), + ): + await async_migrate_devices(hass, config_entry) + + for call in entity_reg.async_update_entity.call_args_list: + assert "new_unique_id" not in call.kwargs + assert "unique_id" not in call.kwargs