diff --git a/custom_components/andersen_ev/__init__.py b/custom_components/andersen_ev/__init__.py index 66a7508..fff955b 100644 --- a/custom_components/andersen_ev/__init__.py +++ b/custom_components/andersen_ev/__init__.py @@ -9,6 +9,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -30,6 +31,27 @@ _LOGGER = logging.getLogger(__name__) +def _make_stale_device_listener(hass: HomeAssistant, coordinator: "AndersenEvCoordinator"): + """Build a coordinator listener that removes devices no longer returned by the API. + + Compares the device_id set on each coordinator refresh against the previous one; any + device_id that drops out gets its device-registry entry removed. Runs once (not per + platform) since it acts on the registry, not entities. + """ + device_registry = dr.async_get(hass) + known_device_ids = {device.device_id for device in coordinator.data} + + def _handle_stale_devices() -> None: + current_device_ids = {device.device_id for device in coordinator.data} + for device_id in known_device_ids - current_device_ids: + if device_entry := device_registry.async_get_device(identifiers={(DOMAIN, device_id)}): + device_registry.async_remove_device(device_entry.id) + known_device_ids.clear() + known_device_ids.update(current_device_ids) + + return _handle_stale_devices + + async def async_setup_entry(hass: HomeAssistant, entry: AndersenEvConfigEntry) -> bool: """Set up Andersen EV from a config entry.""" email = entry.data["email"] @@ -44,6 +66,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AndersenEvConfigEntry) - entry.runtime_data = coordinator + entry.async_on_unload(coordinator.async_add_listener(_make_stale_device_listener(hass, coordinator))) + # Register services async def disable_all_schedules(call: ServiceCall) -> None: """Disable all schedules for a device.""" diff --git a/custom_components/andersen_ev/entity.py b/custom_components/andersen_ev/entity.py new file mode 100644 index 0000000..6d18795 --- /dev/null +++ b/custom_components/andersen_ev/entity.py @@ -0,0 +1,27 @@ +"""Shared entity helpers for Andersen EV.""" + +from __future__ import annotations + + +class AndersenEvDeviceInfoMixin: + """Mixin providing shared device-info update logic for Andersen EV entities. + + Entities using this mixin must set ``self._device`` (a ``KonnectDevice``) and + ``self._attr_device_info`` (a ``DeviceInfo``) before calling + ``_update_model_from_device_status``. + """ + + def _update_model_from_device_status(self) -> None: + """Update model information from device status if available.""" + # First try to use the model name from the API if available + if hasattr(self._device, "model_name") and self._device.model_name: + self._attr_device_info["model"] = self._device.model_name + # Fall back to the information from device status + elif self._device.last_status: + status = self._device.last_status + if "sysProductName" in status: + self._attr_device_info["model"] = status["sysProductName"] + elif "sysProductId" in status: + self._attr_device_info["model"] = status["sysProductId"] + elif "sysHwVersion" in status: + self._attr_device_info["model"] = f"A2 (HW: {status['sysHwVersion']})" diff --git a/custom_components/andersen_ev/lock.py b/custom_components/andersen_ev/lock.py index 458881b..785e9c0 100644 --- a/custom_components/andersen_ev/lock.py +++ b/custom_components/andersen_ev/lock.py @@ -14,6 +14,7 @@ from . import AndersenEvConfigEntry, AndersenEvCoordinator from .const import DOMAIN +from .entity import AndersenEvDeviceInfoMixin PARALLEL_UPDATES = 1 @@ -25,15 +26,26 @@ async def async_setup_entry( ) -> None: """Set up the Andersen EV lock platform.""" coordinator = entry.runtime_data + known_device_ids: set[str] = set() - entities = [] - for device in coordinator.data: - entities.append(AndersenEvLock(coordinator, device)) + def _entities_for_new_devices() -> list[AndersenEvLock]: + """Build lock entities for any device not seen before.""" + new_devices = [device for device in coordinator.data if device.device_id not in known_device_ids] + entities = [] + for device in new_devices: + known_device_ids.add(device.device_id) + entities.append(AndersenEvLock(coordinator, device)) + return entities - async_add_entities(entities) + def _handle_coordinator_update() -> None: + if new_entities := _entities_for_new_devices(): + async_add_entities(new_entities) + async_add_entities(_entities_for_new_devices()) + entry.async_on_unload(coordinator.async_add_listener(_handle_coordinator_update)) -class AndersenEvLock(CoordinatorEntity, LockEntity): # pylint: disable=abstract-method + +class AndersenEvLock(AndersenEvDeviceInfoMixin, CoordinatorEntity, LockEntity): # pylint: disable=abstract-method """Representation of an Andersen EV charging lock.""" _attr_has_entity_name = True @@ -54,21 +66,6 @@ def __init__(self, coordinator: AndersenEvCoordinator, device) -> None: # Update model if device status is already available self._update_model_from_device_status() - def _update_model_from_device_status(self): - """Update model information from device status if available.""" - # First try to use the model name from the API if available - if hasattr(self._device, "model_name") and self._device.model_name: - self._attr_device_info["model"] = self._device.model_name - # Fall back to the information from device status - elif self._device.last_status: - status = self._device.last_status - if "sysProductName" in status: - self._attr_device_info["model"] = status["sysProductName"] - elif "sysProductId" in status: - self._attr_device_info["model"] = status["sysProductId"] - elif "sysHwVersion" in status: - self._attr_device_info["model"] = f"A2 (HW: {status['sysHwVersion']})" - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/custom_components/andersen_ev/quality_scale.yaml b/custom_components/andersen_ev/quality_scale.yaml index 5d8ac66..227dd2f 100644 --- a/custom_components/andersen_ev/quality_scale.yaml +++ b/custom_components/andersen_ev/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: todo entity-device-class: todo entity-disabled-by-default: todo @@ -62,7 +62,7 @@ rules: icon-translations: todo reconfiguration-flow: todo repair-issues: todo - stale-devices: todo + stale-devices: done # Platinum async-dependency: todo diff --git a/custom_components/andersen_ev/sensor.py b/custom_components/andersen_ev/sensor.py index 901300a..405ab05 100644 --- a/custom_components/andersen_ev/sensor.py +++ b/custom_components/andersen_ev/sensor.py @@ -18,11 +18,13 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AndersenEvConfigEntry, AndersenEvCoordinator from .const import DOMAIN +from .entity import AndersenEvDeviceInfoMixin PARALLEL_UPDATES = 0 @@ -36,277 +38,296 @@ async def async_setup_entry( ) -> None: """Set up the Andersen EV sensor platform.""" coordinator = entry.runtime_data - - entities = [] - for device in coordinator.data: - # Energy sensors from historical data - entities.append( - AndersenEvEnergySensor( - coordinator, - device, - "energy", - "Total Energy", - "chargeEnergyTotal", - "mdi:lightning-bolt-circle", - ) + known_device_ids: set[str] = set() + + def _entities_for_new_devices() -> list[SensorEntity]: + """Build sensor entities for any device not seen before.""" + new_devices = [device for device in coordinator.data if device.device_id not in known_device_ids] + entities: list[SensorEntity] = [] + for device in new_devices: + known_device_ids.add(device.device_id) + entities.extend(_build_entities_for_device(coordinator, device)) + return entities + + def _handle_coordinator_update() -> None: + if new_entities := _entities_for_new_devices(): + async_add_entities(new_entities) + + async_add_entities(_entities_for_new_devices()) + entry.async_on_unload(coordinator.async_add_listener(_handle_coordinator_update)) + + +def _build_entities_for_device(coordinator: AndersenEvCoordinator, device) -> list[SensorEntity]: + """Build all sensor entities for a single device.""" + entities: list[SensorEntity] = [] + # Energy sensors from historical data + entities.append( + AndersenEvEnergySensor( + coordinator, + device, + "energy", + "Total Energy", + "chargeEnergyTotal", + "mdi:lightning-bolt-circle", ) - entities.append( - AndersenEvEnergySensor( - coordinator, - device, - "grid_energy", - "Grid Energy", - "gridEnergyTotal", - "mdi:transmission-tower", - ) + ) + entities.append( + AndersenEvEnergySensor( + coordinator, + device, + "grid_energy", + "Grid Energy", + "gridEnergyTotal", + "mdi:transmission-tower", ) - entities.append( - AndersenEvEnergySensor( - coordinator, - device, - "solar_energy", - "Solar Energy", - "solarEnergyTotal", - "mdi:solar-power", - ) + ) + entities.append( + AndersenEvEnergySensor( + coordinator, + device, + "solar_energy", + "Solar Energy", + "solarEnergyTotal", + "mdi:solar-power", ) - entities.append( - AndersenEvEnergySensor( - coordinator, - device, - "surplus_energy", - "Surplus Energy", - "surplusUsedEnergyTotal", - "mdi:battery-plus", - ) + ) + entities.append( + AndersenEvEnergySensor( + coordinator, + device, + "surplus_energy", + "Surplus Energy", + "surplusUsedEnergyTotal", + "mdi:battery-plus", ) - - # Live status sensors - entities.append( - AndersenEvLiveSensor( - coordinator, - device, - "sys_grid_power", - "System Grid Power", - "sysGridPower", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - UnitOfPower.KILO_WATT, - "mdi:transmission-tower", - ) + ) + + # Live status sensors + entities.append( + AndersenEvLiveSensor( + coordinator, + device, + "sys_grid_power", + "System Grid Power", + "sysGridPower", + SensorDeviceClass.POWER, + SensorStateClass.MEASUREMENT, + UnitOfPower.KILO_WATT, + "mdi:transmission-tower", ) - entities.append( - AndersenEvLiveSensor( - coordinator, - device, - "sys_temperature", - "System Temperature", - "sysTemperature", - SensorDeviceClass.TEMPERATURE, - SensorStateClass.MEASUREMENT, - UnitOfTemperature.CELSIUS, - "mdi:temperature-celsius", - ) + ) + entities.append( + AndersenEvLiveSensor( + coordinator, + device, + "sys_temperature", + "System Temperature", + "sysTemperature", + SensorDeviceClass.TEMPERATURE, + SensorStateClass.MEASUREMENT, + UnitOfTemperature.CELSIUS, + "mdi:temperature-celsius", ) - entities.append( - AndersenEvLiveSensor( - coordinator, - device, - "sys_voltage", - "System Voltage", - "sysVoltageC", - SensorDeviceClass.VOLTAGE, - SensorStateClass.MEASUREMENT, - UnitOfElectricPotential.VOLT, - "mdi:transmission-tower", - ) + ) + entities.append( + AndersenEvLiveSensor( + coordinator, + device, + "sys_voltage", + "System Voltage", + "sysVoltageC", + SensorDeviceClass.VOLTAGE, + SensorStateClass.MEASUREMENT, + UnitOfElectricPotential.VOLT, + "mdi:transmission-tower", ) - entities.append( - AndersenEvLiveSensor( - coordinator, - device, - "sys_fault_code", - "Fault Code", - "sysFaultCode", - None, - None, - None, - "mdi:exclamation", - ) + ) + entities.append( + AndersenEvLiveSensor( + coordinator, + device, + "sys_fault_code", + "Fault Code", + "sysFaultCode", + None, + None, + None, + "mdi:exclamation", ) - entities.append( - AndersenEvLiveSensor( - coordinator, - device, - "sys_grid_energy_delta", - "System Grid Energy Delta", - "sysGridEnergyDelta", - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL, - UnitOfEnergy.KILO_WATT_HOUR, - "mdi:transmission-tower", - ) + ) + entities.append( + AndersenEvLiveSensor( + coordinator, + device, + "sys_grid_energy_delta", + "System Grid Energy Delta", + "sysGridEnergyDelta", + SensorDeviceClass.ENERGY, + SensorStateClass.TOTAL, + UnitOfEnergy.KILO_WATT_HOUR, + "mdi:transmission-tower", ) - - # Cost sensors from historical data - entities.append( - AndersenEvCostSensor( - coordinator, - device, - "cost", - "Total Cost", - "chargeCostTotal", - "mdi:currency-gbp", - ) + ) + + # Cost sensors from historical data + entities.append( + AndersenEvCostSensor( + coordinator, + device, + "cost", + "Total Cost", + "chargeCostTotal", + "mdi:currency-gbp", ) - entities.append( - AndersenEvCostSensor( - coordinator, - device, - "grid_cost", - "Grid Cost", - "gridCostTotal", - "mdi:cash-multiple", - ) + ) + entities.append( + AndersenEvCostSensor( + coordinator, + device, + "grid_cost", + "Grid Cost", + "gridCostTotal", + "mdi:cash-multiple", ) - entities.append( - AndersenEvCostSensor( - coordinator, - device, - "solar_cost", - "Solar Cost", - "solarCostTotal", - "mdi:solar-power-variant", - ) + ) + entities.append( + AndersenEvCostSensor( + coordinator, + device, + "solar_cost", + "Solar Cost", + "solarCostTotal", + "mdi:solar-power-variant", ) - entities.append( - AndersenEvCostSensor( - coordinator, - device, - "surplus_cost", - "Surplus Cost", - "surplusUsedCostTotal", - "mdi:cash-plus", - ) + ) + entities.append( + AndersenEvCostSensor( + coordinator, + device, + "surplus_cost", + "Surplus Cost", + "surplusUsedCostTotal", + "mdi:cash-plus", ) - - # Connector state sensor - entities.append(AndersenEvConnectorSensor(coordinator, device)) - - # Realtime charge status sensors - power - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "charge_power", - "Charge Power", - "chargePower", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - UnitOfPower.WATT, - "mdi:ev-station", - ) + ) + + # Connector state sensor + entities.append(AndersenEvConnectorSensor(coordinator, device)) + + # Realtime charge status sensors - power + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "charge_power", + "Charge Power", + "chargePower", + SensorDeviceClass.POWER, + SensorStateClass.MEASUREMENT, + UnitOfPower.WATT, + "mdi:ev-station", ) - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "charge_power_max", - "Max Charge Power", - "chargePowerMax", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - UnitOfPower.KILO_WATT, - "mdi:speedometer", - ) + ) + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "charge_power_max", + "Max Charge Power", + "chargePowerMax", + SensorDeviceClass.POWER, + SensorStateClass.MEASUREMENT, + UnitOfPower.KILO_WATT, + "mdi:speedometer", ) - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "solar_power", - "Solar Power", - "solarPower", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - UnitOfPower.WATT, - "mdi:solar-power", - ) + ) + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "solar_power", + "Solar Power", + "solarPower", + SensorDeviceClass.POWER, + SensorStateClass.MEASUREMENT, + UnitOfPower.WATT, + "mdi:solar-power", ) - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "grid_power", - "Grid Power", - "gridPower", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - UnitOfPower.WATT, - "mdi:transmission-tower", - ) + ) + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "grid_power", + "Grid Power", + "gridPower", + SensorDeviceClass.POWER, + SensorStateClass.MEASUREMENT, + UnitOfPower.WATT, + "mdi:transmission-tower", ) - - # Realtime charge status sensors - energy - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "current_charge_energy", - "Current Session Energy", - "chargeEnergyTotal", - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL, - UnitOfEnergy.KILO_WATT_HOUR, - "mdi:car-electric", - ) + ) + + # Realtime charge status sensors - energy + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "current_charge_energy", + "Current Session Energy", + "chargeEnergyTotal", + SensorDeviceClass.ENERGY, + SensorStateClass.TOTAL, + UnitOfEnergy.KILO_WATT_HOUR, + "mdi:car-electric", ) - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "current_solar_energy", - "Current Session Solar Energy", - "solarEnergyTotal", - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL, - UnitOfEnergy.KILO_WATT_HOUR, - "mdi:solar-power-variant", - ) + ) + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "current_solar_energy", + "Current Session Solar Energy", + "solarEnergyTotal", + SensorDeviceClass.ENERGY, + SensorStateClass.TOTAL, + UnitOfEnergy.KILO_WATT_HOUR, + "mdi:solar-power-variant", ) - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "current_grid_energy", - "Current Session Grid Energy", - "gridEnergyTotal", - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL, - UnitOfEnergy.KILO_WATT_HOUR, - "mdi:power-plug", - ) + ) + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "current_grid_energy", + "Current Session Grid Energy", + "gridEnergyTotal", + SensorDeviceClass.ENERGY, + SensorStateClass.TOTAL, + UnitOfEnergy.KILO_WATT_HOUR, + "mdi:power-plug", ) - - # Start time sensor - entities.append( - AndersenEvChargeStatusSensor( - coordinator, - device, - "session_start", - "Session Start Time", - "start", - SensorDeviceClass.TIMESTAMP, - None, - None, - "mdi:clock-start", - ) + ) + + # Start time sensor + entities.append( + AndersenEvChargeStatusSensor( + coordinator, + device, + "session_start", + "Session Start Time", + "start", + SensorDeviceClass.TIMESTAMP, + None, + None, + "mdi:clock-start", ) + ) - async_add_entities(entities) + return entities -class AndersenEvBaseSensor(CoordinatorEntity, SensorEntity): +class AndersenEvBaseSensor(AndersenEvDeviceInfoMixin, CoordinatorEntity, SensorEntity): """Base class for Andersen EV sensors.""" _attr_has_entity_name = True @@ -319,12 +340,13 @@ def __init__(self, coordinator: AndersenEvCoordinator, device, sensor_type, name self._data_key = data_key self._attr_name = name_suffix self._attr_unique_id = f"{device.device_id}_{sensor_type}" - self._attr_device_info = { - "identifiers": {(DOMAIN, device.device_id)}, - "name": f"{device.friendly_name} ({device.device_id})", - "manufacturer": "Andersen EV", - "model": "A2", - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=f"{device.friendly_name} ({device.device_id})", + manufacturer="Andersen EV", + model="A2", + serial_number=f"{device.device_id}", + ) self._last_charge = None self._update_model_from_device_status() @@ -334,21 +356,6 @@ async def async_added_to_hass(self) -> None: # Update last charge data await self._update_last_charge() - def _update_model_from_device_status(self): - """Update model information from device status if available.""" - # First try to use the model name from the API if available - if hasattr(self._device, "model_name") and self._device.model_name: - self._attr_device_info["model"] = self._device.model_name - # Fall back to the information from device status - elif self._device.last_status: - status = self._device.last_status - if "sysProductName" in status: - self._attr_device_info["model"] = status["sysProductName"] - elif "sysProductId" in status: - self._attr_device_info["model"] = status["sysProductId"] - elif "sysHwVersion" in status: - self._attr_device_info["model"] = f"A2 (HW: {status['sysHwVersion']})" - async def _update_last_charge(self): """Get the last charge data for the device.""" self._last_charge = await self._device.get_last_charge() @@ -419,7 +426,7 @@ def native_value(self) -> float | None: return None -class AndersenEvConnectorSensor(CoordinatorEntity, SensorEntity): +class AndersenEvConnectorSensor(AndersenEvDeviceInfoMixin, CoordinatorEntity, SensorEntity): """Sensor for Andersen EV connector state.""" _attr_device_class = SensorDeviceClass.ENUM @@ -441,12 +448,13 @@ def __init__(self, coordinator: AndersenEvCoordinator, device, icon=None) -> Non self._device = device self._attr_name = "Connector" self._attr_unique_id = f"{device.device_id}_connector" - self._attr_device_info = { - "identifiers": {(DOMAIN, device.device_id)}, - "name": f"{device.friendly_name} ({device.device_id})", - "manufacturer": "Andersen EV", - "model": "A2", - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=f"{device.friendly_name} ({device.device_id})", + manufacturer="Andersen EV", + model="A2", + serial_number=f"{device.device_id}", + ) if icon: self._attr_icon = icon else: @@ -455,21 +463,6 @@ def __init__(self, coordinator: AndersenEvCoordinator, device, icon=None) -> Non self._connector_state = "unknown" self._last_evse_state = None - def _update_model_from_device_status(self): - """Update model information from device status if available.""" - # First try to use the model name from the API if available - if hasattr(self._device, "model_name") and self._device.model_name: - self._attr_device_info["model"] = self._device.model_name - # Fall back to the information from device status - elif self._device.last_status: - status = self._device.last_status - if "sysProductName" in status: - self._attr_device_info["model"] = status["sysProductName"] - elif "sysProductId" in status: - self._attr_device_info["model"] = status["sysProductId"] - elif "sysHwVersion" in status: - self._attr_device_info["model"] = f"A2 (HW: {status['sysHwVersion']})" - @property def available(self) -> bool: """Return if the sensor is available.""" @@ -548,7 +541,7 @@ async def async_update(self) -> None: _LOGGER.debug("Error updating connector state: %s", err) -class AndersenEvChargeStatusSensor(CoordinatorEntity, SensorEntity): +class AndersenEvChargeStatusSensor(AndersenEvDeviceInfoMixin, CoordinatorEntity, SensorEntity): """Sensor for Andersen EV charge status values.""" _attr_has_entity_name = True @@ -572,12 +565,13 @@ def __init__( self._data_key = data_key self._attr_name = name_suffix self._attr_unique_id = f"{device.device_id}_{sensor_type}" - self._attr_device_info = { - "identifiers": {(DOMAIN, device.device_id)}, - "name": f"{device.friendly_name} ({device.device_id})", - "manufacturer": "Andersen EV", - "model": "A2", - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=f"{device.friendly_name} ({device.device_id})", + manufacturer="Andersen EV", + model="A2", + serial_number=f"{device.device_id}", + ) if device_class: self._attr_device_class = device_class if state_class: @@ -588,21 +582,6 @@ def __init__( self._attr_icon = icon self._update_model_from_device_status() - def _update_model_from_device_status(self): - """Update model information from device status if available.""" - # First try to use the model name from the API if available - if hasattr(self._device, "model_name") and self._device.model_name: - self._attr_device_info["model"] = self._device.model_name - # Fall back to the information from device status - elif self._device.last_status: - status = self._device.last_status - if "sysProductName" in status: - self._attr_device_info["model"] = status["sysProductName"] - elif "sysProductId" in status: - self._attr_device_info["model"] = status["sysProductId"] - elif "sysHwVersion" in status: - self._attr_device_info["model"] = f"A2 (HW: {status['sysHwVersion']})" - @property def available(self) -> bool: """Return if the sensor is available.""" @@ -656,7 +635,7 @@ async def async_update(self) -> None: _LOGGER.debug("Error updating charge status sensor: %s", err) -class AndersenEvLiveSensor(CoordinatorEntity, SensorEntity): +class AndersenEvLiveSensor(AndersenEvDeviceInfoMixin, CoordinatorEntity, SensorEntity): """Sensor for Andersen EV live status values.""" _attr_has_entity_name = True @@ -680,12 +659,13 @@ def __init__( self._data_key = data_key self._attr_name = name_suffix self._attr_unique_id = f"{device.device_id}_{sensor_type}" - self._attr_device_info = { - "identifiers": {(DOMAIN, device.device_id)}, - "name": f"{device.friendly_name} ({device.device_id})", - "manufacturer": "Andersen EV", - "model": "A2", - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=f"{device.friendly_name} ({device.device_id})", + manufacturer="Andersen EV", + model="A2", + serial_number=f"{device.device_id}", + ) if device_class: self._attr_device_class = device_class if state_class: @@ -696,21 +676,6 @@ def __init__( self._attr_icon = icon self._update_model_from_device_status() - def _update_model_from_device_status(self): - """Update model information from device status if available.""" - # First try to use the model name from the API if available - if hasattr(self._device, "model_name") and self._device.model_name: - self._attr_device_info["model"] = self._device.model_name - # Fall back to the information from device status - elif self._device.last_status: - status = self._device.last_status - if "sysProductName" in status: - self._attr_device_info["model"] = status["sysProductName"] - elif "sysProductId" in status: - self._attr_device_info["model"] = status["sysProductId"] - elif "sysHwVersion" in status: - self._attr_device_info["model"] = f"A2 (HW: {status['sysHwVersion']})" - @property def available(self) -> bool: """Return if the sensor is available.""" diff --git a/custom_components/andersen_ev/switch.py b/custom_components/andersen_ev/switch.py index d383bb4..e773efc 100644 --- a/custom_components/andersen_ev/switch.py +++ b/custom_components/andersen_ev/switch.py @@ -9,11 +9,13 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AndersenEvConfigEntry, AndersenEvCoordinator from .const import DOMAIN +from .entity import AndersenEvDeviceInfoMixin from .konnect import const PARALLEL_UPDATES = 1 @@ -21,6 +23,39 @@ _LOGGER = logging.getLogger(__name__) +async def _async_build_switches_for_device( + coordinator: AndersenEvCoordinator, device +) -> list[AndersenEvScheduleSwitch]: + """Build schedule switches for a single device (fetches device info).""" + # Get device info including schedule names and schedule slots + device_info = await device.get_device_info() + if not device_info or "deviceInfo" not in device_info: + _LOGGER.warning("Could not retrieve device info for %s", device.friendly_name) + return [] + + # Get schedule slots from device_info + if "deviceStatus" not in device_info or "scheduleSlotsArray" not in device_info["deviceStatus"]: + _LOGGER.warning("Could not retrieve schedule slots for %s", device.friendly_name) + return [] + + schedule_slots = device_info["deviceStatus"]["scheduleSlotsArray"] + device_info_data = device_info["deviceInfo"] + + # Create switches for each schedule + entities = [] + for idx, _slot in enumerate(schedule_slots): + # Get the schedule name from deviceInfo if available + schedule_name_key = f"schedule{idx}Name" + if device_info_data.get(schedule_name_key): + schedule_name = device_info_data[schedule_name_key] + else: + schedule_name = f"Schedule {idx + 1}" + + entities.append(AndersenEvScheduleSwitch(coordinator, device, idx, schedule_name)) + + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: AndersenEvConfigEntry, @@ -28,38 +63,33 @@ async def async_setup_entry( ) -> None: """Set up the Andersen EV schedule switches.""" coordinator = entry.runtime_data + known_device_ids: set[str] = set() - entities = [] - for device in coordinator.data: - # Get device info including schedule names and schedule slots - device_info = await device.get_device_info() - if not device_info or "deviceInfo" not in device_info: - _LOGGER.warning("Could not retrieve device info for %s", device.friendly_name) - continue - - # Get schedule slots from device_info - if "deviceStatus" not in device_info or "scheduleSlotsArray" not in device_info["deviceStatus"]: - _LOGGER.warning("Could not retrieve schedule slots for %s", device.friendly_name) - continue - - schedule_slots = device_info["deviceStatus"]["scheduleSlotsArray"] - device_info_data = device_info["deviceInfo"] - - # Create switches for each schedule - for idx, _slot in enumerate(schedule_slots): - # Get the schedule name from deviceInfo if available - schedule_name_key = f"schedule{idx}Name" - if device_info_data.get(schedule_name_key): - schedule_name = device_info_data[schedule_name_key] - else: - schedule_name = f"Schedule {idx + 1}" + async def _async_add_switches_for_devices(devices) -> None: + """Fetch device info and create switches for the given devices.""" + entities = [] + for device in devices: + entities.extend(await _async_build_switches_for_device(coordinator, device)) + async_add_entities(entities) - entities.append(AndersenEvScheduleSwitch(coordinator, device, idx, schedule_name)) + def _handle_coordinator_update() -> None: + """Schedule switch creation for any device not seen before.""" + new_devices = [device for device in coordinator.data if device.device_id not in known_device_ids] + if not new_devices: + return + for device in new_devices: + known_device_ids.add(device.device_id) + hass.async_create_task(_async_add_switches_for_devices(new_devices)) - async_add_entities(entities) + initial_devices = list(coordinator.data) + for device in initial_devices: + known_device_ids.add(device.device_id) + await _async_add_switches_for_devices(initial_devices) + entry.async_on_unload(coordinator.async_add_listener(_handle_coordinator_update)) -class AndersenEvScheduleSwitch(CoordinatorEntity, SwitchEntity): # pylint: disable=abstract-method + +class AndersenEvScheduleSwitch(AndersenEvDeviceInfoMixin, CoordinatorEntity, SwitchEntity): # pylint: disable=abstract-method """Representation of an Andersen EV charging schedule switch.""" _attr_has_entity_name = True @@ -78,12 +108,13 @@ def __init__( self._schedule_name = schedule_name self._attr_name = f"Schedule {index + 1}" self._attr_unique_id = f"{device.device_id}_schedule_{index}" - self._attr_device_info = { - "identifiers": {(DOMAIN, device.device_id)}, - "name": f"{device.friendly_name} ({device.device_id})", - "manufacturer": "Andersen EV", - "model": "A2", - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=f"{device.friendly_name} ({device.device_id})", + manufacturer="Andersen EV", + model="A2", + serial_number=f"{device.device_id}", + ) self._attr_icon = "mdi:calendar-clock" self._update_model_from_device_status() @@ -95,21 +126,6 @@ def extra_state_attributes(self): "schedule_index": self._schedule_index, } - def _update_model_from_device_status(self): - """Update model information from device status if available.""" - # First try to use the model name from the API if available - if hasattr(self._device, "model_name") and self._device.model_name: - self._attr_device_info["model"] = self._device.model_name - # Fall back to the information from device status - elif self._device.last_status: - status = self._device.last_status - if "sysProductName" in status: - self._attr_device_info["model"] = status["sysProductName"] - elif "sysProductId" in status: - self._attr_device_info["model"] = status["sysProductId"] - elif "sysHwVersion" in status: - self._attr_device_info["model"] = f"A2 (HW: {status['sysHwVersion']})" - @property def available(self) -> bool: """Return if the switch is available.""" diff --git a/custom_components/andersen_ev/tests/test_entity.py b/custom_components/andersen_ev/tests/test_entity.py new file mode 100644 index 0000000..610ccd6 --- /dev/null +++ b/custom_components/andersen_ev/tests/test_entity.py @@ -0,0 +1,59 @@ +"""Tests for the shared AndersenEvDeviceInfoMixin.""" + +from unittest.mock import MagicMock + +from andersen_ev.entity import AndersenEvDeviceInfoMixin + + +class _StubEntity(AndersenEvDeviceInfoMixin): + """Minimal stand-in exercising the mixin without a real CoordinatorEntity.""" + + def __init__(self, device) -> None: + self._device = device + self._attr_device_info = {"model": "A2"} + + +def _make_device(model_name=None, last_status=None): + device = MagicMock() + device.model_name = model_name + device.last_status = last_status + return device + + +class TestUpdateModelFromDeviceStatus: + """Tests for AndersenEvDeviceInfoMixin._update_model_from_device_status().""" + + def test_uses_model_name_when_present(self): + entity = _StubEntity(_make_device(model_name="Andersen A3")) + + entity._update_model_from_device_status() + + assert entity._attr_device_info["model"] == "Andersen A3" + + def test_falls_back_to_sys_product_name(self): + entity = _StubEntity(_make_device(last_status={"sysProductName": "Andersen A2 Pro"})) + + entity._update_model_from_device_status() + + assert entity._attr_device_info["model"] == "Andersen A2 Pro" + + def test_falls_back_to_sys_product_id(self): + entity = _StubEntity(_make_device(last_status={"sysProductId": "A2"})) + + entity._update_model_from_device_status() + + assert entity._attr_device_info["model"] == "A2" + + def test_falls_back_to_hw_version(self): + entity = _StubEntity(_make_device(last_status={"sysHwVersion": "1.5"})) + + entity._update_model_from_device_status() + + assert entity._attr_device_info["model"] == "A2 (HW: 1.5)" + + def test_no_status_keeps_default_model(self): + entity = _StubEntity(_make_device(last_status=None)) + + entity._update_model_from_device_status() + + assert entity._attr_device_info["model"] == "A2" diff --git a/custom_components/andersen_ev/tests/test_init.py b/custom_components/andersen_ev/tests/test_init.py index db8df0d..4e4b797 100644 --- a/custom_components/andersen_ev/tests/test_init.py +++ b/custom_components/andersen_ev/tests/test_init.py @@ -10,6 +10,7 @@ from andersen_ev import ( PLATFORMS, AndersenEvCoordinator, + _make_stale_device_listener, async_setup_entry, async_unload_entry, ) @@ -272,9 +273,95 @@ async def test_sleeps_before_delegating_to_parent(self): mock_parent_refresh.assert_awaited_once() +class TestMakeStaleDeviceListener: + """Tests for _make_stale_device_listener() (the stale-devices rule).""" + + def test_removed_device_is_removed_from_registry(self): + device_a = _make_device(device_id="device_1") + device_b = _make_device(device_id="device_2") + coordinator = _make_coordinator() + coordinator.data = [device_a, device_b] + hass = MagicMock() + mock_registry = MagicMock() + mock_registry_entry = MagicMock() + mock_registry_entry.id = "registry_entry_1" + mock_registry.async_get_device.return_value = mock_registry_entry + + with patch("andersen_ev.dr.async_get", return_value=mock_registry): + listener = _make_stale_device_listener(hass, coordinator) + coordinator.data = [device_a] + listener() + + mock_registry.async_get_device.assert_called_once_with(identifiers={(DOMAIN, "device_2")}) + mock_registry.async_remove_device.assert_called_once_with("registry_entry_1") + + def test_no_change_removes_nothing(self): + device_a = _make_device(device_id="device_1") + coordinator = _make_coordinator() + coordinator.data = [device_a] + hass = MagicMock() + mock_registry = MagicMock() + + with patch("andersen_ev.dr.async_get", return_value=mock_registry): + listener = _make_stale_device_listener(hass, coordinator) + listener() + + mock_registry.async_remove_device.assert_not_called() + + def test_device_missing_from_registry_skips_removal(self): + device_a = _make_device(device_id="device_1") + coordinator = _make_coordinator() + coordinator.data = [device_a] + hass = MagicMock() + mock_registry = MagicMock() + mock_registry.async_get_device.return_value = None + + with patch("andersen_ev.dr.async_get", return_value=mock_registry): + listener = _make_stale_device_listener(hass, coordinator) + coordinator.data = [] + listener() + + mock_registry.async_remove_device.assert_not_called() + + def test_new_device_appearing_is_tracked_without_removal(self): + device_a = _make_device(device_id="device_1") + device_b = _make_device(device_id="device_2") + coordinator = _make_coordinator() + coordinator.data = [device_a] + hass = MagicMock() + mock_registry = MagicMock() + + with patch("andersen_ev.dr.async_get", return_value=mock_registry): + listener = _make_stale_device_listener(hass, coordinator) + coordinator.data = [device_a, device_b] + listener() + + # A subsequent refresh with no further changes should still remove nothing. + listener() + + mock_registry.async_remove_device.assert_not_called() + + class TestAsyncSetupEntry: """Tests for async_setup_entry().""" + @pytest.mark.asyncio + async def test_registers_stale_device_cleanup_listener(self): + hass = _make_hass() + entry = _make_entry() + mock_coordinator = MagicMock() + mock_coordinator.async_config_entry_first_refresh = AsyncMock() + mock_coordinator.data = [] + + with ( + patch("andersen_ev.KonnectClient"), + patch("andersen_ev.AndersenEvCoordinator", return_value=mock_coordinator), + ): + await async_setup_entry(hass, entry) + + mock_coordinator.async_add_listener.assert_called_once() + entry.async_on_unload.assert_called_once() + @pytest.mark.asyncio async def test_creates_client_and_coordinator_and_stores_runtime_data(self): hass = _make_hass() diff --git a/custom_components/andersen_ev/tests/test_lock.py b/custom_components/andersen_ev/tests/test_lock.py index cf98019..c145943 100644 --- a/custom_components/andersen_ev/tests/test_lock.py +++ b/custom_components/andersen_ev/tests/test_lock.py @@ -70,6 +70,48 @@ async def test_no_devices_creates_no_entities(self): assert entities == [] +class TestDynamicDevices: + """Tests for the dynamic-devices coordinator listener registered in async_setup_entry().""" + + @pytest.mark.asyncio + async def test_new_device_added_without_reload(self): + device_a = _make_device(device_id="device_1") + coordinator = _make_coordinator([device_a]) + entry = MagicMock() + entry.runtime_data = coordinator + async_add_entities = MagicMock() + + await async_setup_entry(MagicMock(), entry, async_add_entities) + + assert async_add_entities.call_count == 1 + entry.async_on_unload.assert_called_once() + listener = coordinator.async_add_listener.call_args.args[0] + + device_b = _make_device(device_id="device_2") + coordinator.data = [device_a, device_b] + listener() + + assert async_add_entities.call_count == 2 + new_entities = async_add_entities.call_args.args[0] + assert len(new_entities) == 1 + assert new_entities[0]._device.device_id == "device_2" + + @pytest.mark.asyncio + async def test_no_new_devices_does_not_call_add_entities_again(self): + device_a = _make_device(device_id="device_1") + coordinator = _make_coordinator([device_a]) + entry = MagicMock() + entry.runtime_data = coordinator + async_add_entities = MagicMock() + + await async_setup_entry(MagicMock(), entry, async_add_entities) + listener = coordinator.async_add_listener.call_args.args[0] + + listener() + + assert async_add_entities.call_count == 1 + + class TestInit: """Tests for AndersenEvLock.__init__().""" diff --git a/custom_components/andersen_ev/tests/test_sensor.py b/custom_components/andersen_ev/tests/test_sensor.py index 7cdcf7a..8e9f992 100644 --- a/custom_components/andersen_ev/tests/test_sensor.py +++ b/custom_components/andersen_ev/tests/test_sensor.py @@ -79,6 +79,48 @@ async def test_no_devices_creates_no_entities(self): assert async_add_entities.call_args.args[0] == [] +class TestDynamicDevices: + """Tests for the dynamic-devices coordinator listener registered in async_setup_entry().""" + + @pytest.mark.asyncio + async def test_new_device_added_without_reload(self): + device_a = _make_device(device_id="device_1") + coordinator = _make_coordinator([device_a]) + entry = MagicMock() + entry.runtime_data = coordinator + async_add_entities = MagicMock() + + await async_setup_entry(MagicMock(), entry, async_add_entities) + + assert async_add_entities.call_count == 1 + entry.async_on_unload.assert_called_once() + listener = coordinator.async_add_listener.call_args.args[0] + + device_b = _make_device(device_id="device_2") + coordinator.data = [device_a, device_b] + listener() + + assert async_add_entities.call_count == 2 + new_entities = async_add_entities.call_args.args[0] + assert len(new_entities) == 22 + assert all(entity._device.device_id == "device_2" for entity in new_entities) + + @pytest.mark.asyncio + async def test_no_new_devices_does_not_call_add_entities_again(self): + device_a = _make_device(device_id="device_1") + coordinator = _make_coordinator([device_a]) + entry = MagicMock() + entry.runtime_data = coordinator + async_add_entities = MagicMock() + + await async_setup_entry(MagicMock(), entry, async_add_entities) + listener = coordinator.async_add_listener.call_args.args[0] + + listener() + + assert async_add_entities.call_count == 1 + + class TestBaseSensorInit: """Tests for AndersenEvBaseSensor.__init__() via AndersenEvEnergySensor.""" diff --git a/custom_components/andersen_ev/tests/test_switch.py b/custom_components/andersen_ev/tests/test_switch.py index 7f01f72..8b314b8 100644 --- a/custom_components/andersen_ev/tests/test_switch.py +++ b/custom_components/andersen_ev/tests/test_switch.py @@ -117,6 +117,74 @@ async def test_missing_schedule_slots_array_skips_device(self): assert async_add_entities.call_args.args[0] == [] +class TestDynamicDevices: + """Tests for the dynamic-devices coordinator listener registered in async_setup_entry().""" + + @pytest.mark.asyncio + async def test_new_device_scheduled_for_addition(self): + device_a = _make_device(device_id="device_1") + device_a.get_device_info = AsyncMock( + return_value={ + "deviceInfo": {}, + "deviceStatus": {"scheduleSlotsArray": [{"enabled": True}]}, + } + ) + coordinator = _make_coordinator([device_a]) + entry = MagicMock() + entry.runtime_data = coordinator + async_add_entities = MagicMock() + hass = MagicMock() + + await async_setup_entry(hass, entry, async_add_entities) + + assert async_add_entities.call_count == 1 + entry.async_on_unload.assert_called_once() + listener = coordinator.async_add_listener.call_args.args[0] + + device_b = _make_device(device_id="device_2") + device_b.get_device_info = AsyncMock( + return_value={ + "deviceInfo": {}, + "deviceStatus": {"scheduleSlotsArray": [{"enabled": False}, {"enabled": True}]}, + } + ) + coordinator.data = [device_a, device_b] + + listener() + + hass.async_create_task.assert_called_once() + scheduled_coro = hass.async_create_task.call_args.args[0] + await scheduled_coro + + assert async_add_entities.call_count == 2 + new_entities = async_add_entities.call_args.args[0] + assert len(new_entities) == 2 + assert all(entity._device.device_id == "device_2" for entity in new_entities) + + @pytest.mark.asyncio + async def test_no_new_devices_does_not_schedule_a_task(self): + device_a = _make_device(device_id="device_1") + device_a.get_device_info = AsyncMock( + return_value={ + "deviceInfo": {}, + "deviceStatus": {"scheduleSlotsArray": [{"enabled": True}]}, + } + ) + coordinator = _make_coordinator([device_a]) + entry = MagicMock() + entry.runtime_data = coordinator + async_add_entities = MagicMock() + hass = MagicMock() + + await async_setup_entry(hass, entry, async_add_entities) + listener = coordinator.async_add_listener.call_args.args[0] + + listener() + + hass.async_create_task.assert_not_called() + assert async_add_entities.call_count == 1 + + class TestInit: """Tests for AndersenEvScheduleSwitch.__init__()."""