diff --git a/custom_components/generac/binary_sensor.py b/custom_components/generac/binary_sensor.py index a01b062..40b6a60 100644 --- a/custom_components/generac/binary_sensor.py +++ b/custom_components/generac/binary_sensor.py @@ -7,7 +7,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEFAULT_NAME from .const import DOMAIN from .coordinator import GeneracDataUpdateCoordinator from .entity import GeneracEntity @@ -36,17 +35,11 @@ def sensors() -> Type[GeneracEntity]: ] -def sensor_name(self, name_label): - return f"{DEFAULT_NAME}_{self.device_id}_{name_label}" - - class GeneracConnectedSensor(GeneracEntity, BinarySensorEntity): """generac binary_sensor class.""" - @property - def name(self): - """Return the name of the binary_sensor.""" - return sensor_name(self, "is_connected") + entity_key = "is_connected" + entity_label = "Connected" @property def device_class(self): @@ -62,10 +55,8 @@ def is_on(self): class GeneracConnectingSensor(GeneracEntity, BinarySensorEntity): """generac binary_sensor class.""" - @property - def name(self): - """Return the name of the binary_sensor.""" - return sensor_name(self, "is_connecting") + entity_key = "is_connecting" + entity_label = "Connecting" @property def device_class(self): @@ -81,10 +72,8 @@ def is_on(self): class GeneracMaintenanceAlertSensor(GeneracEntity, BinarySensorEntity): """generac binary_sensor class.""" - @property - def name(self): - """Return the name of the binary_sensor.""" - return sensor_name(self, "has_maintenance_alert") + entity_key = "has_maintenance_alert" + entity_label = "Maintenance Alert" @property def device_class(self): @@ -100,10 +89,8 @@ def is_on(self): class GeneracWarningSensor(GeneracEntity, BinarySensorEntity): """generac binary_sensor class.""" - @property - def name(self): - """Return the name of the binary_sensor.""" - return sensor_name(self, "show_warning") + entity_key = "show_warning" + entity_label = "Warning" @property def device_class(self): diff --git a/custom_components/generac/diagnostics.py b/custom_components/generac/diagnostics.py index c80805c..269f040 100644 --- a/custom_components/generac/diagnostics.py +++ b/custom_components/generac/diagnostics.py @@ -17,11 +17,25 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: GeneracDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: GeneracDataUpdateCoordinator | None = hass.data.get(DOMAIN, {}).get( + entry.entry_id + ) + if coordinator is None: + return { + "status": "not_loaded", + "data": {}, + } diagnostics_data = { + "status": "loaded", + # Keyed by position: the coordinator keys devices by apparatusId, + # which is itself redacted below. "data": redact( - {gen_id: asdict(item) for gen_id, item in coordinator.data.items()}, False + { + f"device_{index}": asdict(item) + for index, item in enumerate((coordinator.data or {}).values()) + }, + False, ), } @@ -31,36 +45,37 @@ async def async_get_config_entry_diagnostics( DataDict = dict[str, "str | DataDict"] -def redact(data: Any, redact_all: bool): +def redact(data: Any, redact_all: bool) -> Any: if isinstance(data, dict): return redact_dict(data, redact_all) if isinstance(data, list): return redact_array(data, redact_all) if isinstance(data, str): - if parseaddr(data) == ("", ""): - return "REDACTED_VALID_EMAIL" + _, address = parseaddr(data) + if "@" in address: + return "REDACTED_EMAIL" if is_ipv4(data): return "REDACTED_IPV4" if is_ipv6(data): - return "REDACTED_IPV4" + return "REDACTED_IPV6" if redact_all: return "REDACTED" return data -def is_ipv4(s: str): +def is_ipv4(s: str) -> bool: try: ipaddress.IPv4Network(s) return True - except ValueError: + except (TypeError, ValueError): return False -def is_ipv6(s: str): +def is_ipv6(s: str) -> bool: try: - ipaddress.IPv4Network(s) + ipaddress.IPv6Network(s) return True - except ValueError: + except (TypeError, ValueError): return False @@ -68,6 +83,9 @@ def is_ipv6(s: str): "deviceSsid", "serialNumber", "apparatusId", + "panelId", + "shortDeviceId", + "deviceId", "address", "localizedAddress", } diff --git a/custom_components/generac/entity.py b/custom_components/generac/entity.py index 0bd7bdf..67b3b25 100644 --- a/custom_components/generac/entity.py +++ b/custom_components/generac/entity.py @@ -1,5 +1,6 @@ -"""GeneracEntity class""" +"""Generac entity base classes and friendly-name helpers.""" import logging +from typing import ClassVar from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback @@ -7,6 +8,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION +from .const import DEFAULT_NAME from .const import DOMAIN from .coordinator import GeneracDataUpdateCoordinator from .models import Apparatus @@ -16,10 +18,34 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) +_ENTITY_WORDS = { + "id": "ID", + "ssid": "SSID", +} + + +def parse_entity_name(entity_key: str) -> str: + """Convert a stable entity key into a readable label.""" + return " ".join( + _ENTITY_WORDS.get(word, word.capitalize()) for word in entity_key.split("_") + ) + + _EMPTY_ITEM = Item(apparatus=Apparatus(), apparatusDetail=ApparatusDetail(), empty=True) class GeneracEntity(CoordinatorEntity[GeneracDataUpdateCoordinator]): + """Base entity with stable registry IDs and readable display names.""" + + _attr_has_entity_name = True + + # Stable suffix of the entity registry unique_id. Set explicitly on every + # entity class and never derive it from the class name: changing it + # orphans the user's existing entity and creates a duplicate. + entity_key: ClassVar[str] + # Display label when the one derived from entity_key reads poorly. + entity_label: ClassVar[str | None] = None + def __init__( self, coordinator: GeneracDataUpdateCoordinator, @@ -35,7 +61,17 @@ def __init__( @property def unique_id(self): """Return a unique ID to use for this entity.""" - return f"{self.config_entry.entry_id}_{self.device_id}_{self.name}" + # Byte-for-byte the historical format, which embedded the old + # technical name "generac__". + return ( + f"{self.config_entry.entry_id}_{self.device_id}_" + f"{DEFAULT_NAME}_{self.device_id}_{self.entity_key}" + ) + + @property + def name(self): + """Return the entity label; Home Assistant prefixes the device name.""" + return self.entity_label or parse_entity_name(self.entity_key) @property def device_info(self): @@ -58,7 +94,11 @@ def extra_state_attributes(self): @property def available(self): """Return True if entity is available.""" - return self.coordinator.is_online and not self.item.empty + return ( + self.coordinator.last_update_success + and self.coordinator.is_online + and not self.item.empty + ) async def async_added_to_hass(self) -> None: """Connect to dispatcher listening for entity data notifications.""" @@ -75,6 +115,6 @@ def aparatus_detail(self) -> ApparatusDetail: @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self.item = self.coordinator.data.get(self.device_id, _EMPTY_ITEM) - _LOGGER.debug(f"Updated data for {self.unique_id}: {self.item}") + self.item = (self.coordinator.data or {}).get(self.device_id, _EMPTY_ITEM) + _LOGGER.debug("Updated Generac entity %s", self.entity_id) self.async_write_ha_state() diff --git a/custom_components/generac/image.py b/custom_components/generac/image.py index 1bb73ce..a73e515 100644 --- a/custom_components/generac/image.py +++ b/custom_components/generac/image.py @@ -7,7 +7,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEFAULT_NAME from .const import DOMAIN from .coordinator import GeneracDataUpdateCoordinator from .entity import GeneracEntity @@ -31,6 +30,8 @@ async def async_setup_entry( class HeroImageSensor(GeneracEntity, ImageEntity): """generac Image class.""" + entity_key = "hero_image" + def __init__( self, coordinator: GeneracDataUpdateCoordinator, @@ -43,11 +44,6 @@ def __init__( super().__init__(coordinator, config_entry, device_id, item) ImageEntity.__init__(self, hass) - @property - def name(self): - """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{self.device_id}_hero_image" - @property def image_url(self): return self.aparatus_detail.heroImageUrl diff --git a/custom_components/generac/sensor.py b/custom_components/generac/sensor.py index 4fbc779..a5bb6ae 100644 --- a/custom_components/generac/sensor.py +++ b/custom_components/generac/sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor.const import SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.const import UnitOfElectricPotential @@ -12,7 +13,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEFAULT_NAME from .const import DEVICE_TYPE_GENERATOR from .const import DEVICE_TYPE_PROPANE_MONITOR from .const import DOMAIN @@ -61,6 +61,8 @@ def sensors(item: Item) -> list[Type[GeneracEntity]]: ModelNumberSensor, DeviceSsidSensor, PanelIDSensor, + # Always created for generators so existing registry entries are + # never orphaned; it reports unknown when the value is missing. SignalStrengthSensor, ] elif item.apparatus.type == DEVICE_TYPE_PROPANE_MONITOR: @@ -83,6 +85,15 @@ def sensors(item: Item) -> list[Type[GeneracEntity]]: and item.apparatusDetail.weather.temperature.value is not None ): lst.append(OutdoorTemperatureSensor) + if ( + SignalStrengthSensor not in lst + and get_apparatus_property_value(item, "signalStrength") is not None + ): + lst.append(SignalStrengthSensor) + if get_apparatus_property_value(item, "batteryLevel") is not None: + lst.append(DeviceBatteryLevelSensor) + if get_detail_property_value(item, 95) is not None: + lst.append(ExerciseMinutesSensor) return lst @@ -106,8 +117,32 @@ def get_prop_value(props, type_num: int, default_val): return val -def sensor_name(self, name_label): - return f"{DEFAULT_NAME}_{self.device_id}_{name_label}" +def get_detail_property_value(item: Item, property_type: int): + """Return a detail property value by its Mobile Link type code.""" + if item.apparatusDetail.properties is None: + return None + return next( + ( + prop.value + for prop in item.apparatusDetail.properties + if prop.type == property_type + ), + None, + ) + + +def get_apparatus_property_value(item: Item, field: str): + """Return a scalar field from the apparatus property payload.""" + if item.apparatus.properties is None: + return None + for prop in item.apparatus.properties: + value = prop.value + if isinstance(value, list) or value is None: + continue + attr_value = getattr(value, field, None) + if attr_value is not None: + return attr_value + return None _LOGGER = logging.getLogger(__name__) @@ -120,6 +155,8 @@ def _safe_float(val, label: str = ""): return None if isinstance(val, (int, float)): return float(val) + if isinstance(val, str) and val.strip().endswith("%"): + val = val.strip()[:-1].strip() try: return float(val) except (TypeError, ValueError) as ex: @@ -132,6 +169,8 @@ def _safe_float(val, label: str = ""): class StatusSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + entity_key = "status" + options = [ "Ready", "Running", @@ -146,11 +185,6 @@ class StatusSensor(GeneracEntity, SensorEntity): device_class = SensorDeviceClass.ENUM icon = "mdi:power" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "status") - @property def native_value(self): """Return the state of the sensor.""" @@ -171,6 +205,8 @@ def native_value(self): class DeviceTypeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + entity_key = "device_type" + options = [ "Wifi", "Ethernet", @@ -180,11 +216,6 @@ class DeviceTypeSensor(GeneracEntity, SensorEntity): ] device_class = SensorDeviceClass.ENUM - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "device_type") - @property def native_value(self): """Return the state of the sensor.""" @@ -206,14 +237,11 @@ def native_value(self): class RunTimeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + entity_key = "run_time" + device_class = SensorDeviceClass.DURATION native_unit_of_measurement = "h" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "run_time") - @property def native_value(self): """Return the state of the sensor.""" @@ -224,14 +252,11 @@ def native_value(self): class ProtectionTimeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + entity_key = "protection_time" + device_class = SensorDeviceClass.DURATION native_unit_of_measurement = "h" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "protection_time") - @property def native_value(self): """Return the state of the sensor.""" @@ -242,12 +267,9 @@ def native_value(self): class ActivationDateSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TIMESTAMP + entity_key = "activation_date" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "activation_date") + device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): @@ -261,12 +283,9 @@ def native_value(self): class LastSeenSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TIMESTAMP + entity_key = "last_seen" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "last_seen") + device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): @@ -280,12 +299,9 @@ def native_value(self): class ConnectionTimeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TIMESTAMP + entity_key = "connection_time" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "connection_time") + device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): @@ -299,15 +315,12 @@ def native_value(self): class BatteryVoltageSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + entity_key = "battery_voltage" + device_class = SensorDeviceClass.VOLTAGE native_unit_of_measurement = UnitOfElectricPotential.VOLT suggested_display_precision = 1 - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "battery_voltage") - @property def native_value(self): """Return the state of the sensor.""" @@ -315,15 +328,27 @@ def native_value(self): return _safe_float(val, "battery_voltage") +class ExerciseMinutesSensor(GeneracEntity, SensorEntity): + """Exercise duration reported by Mobile Link.""" + + entity_key = "exercise_minutes" + entity_label = "Exercise Duration" + + device_class = SensorDeviceClass.DURATION + native_unit_of_measurement = "min" + + @property + def native_value(self): + """Return the state of the sensor.""" + return _safe_float(get_detail_property_value(self.item, 95), "exercise_minutes") + + class OutdoorTemperatureSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TEMPERATURE + entity_key = "outdoor_temperature" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "outdoor_temperature") + device_class = SensorDeviceClass.TEMPERATURE @property def native_unit_of_measurement(self): @@ -350,10 +375,7 @@ def native_value(self): class SerialNumberSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "serial_number") + entity_key = "serial_number" @property def native_value(self): @@ -362,10 +384,7 @@ def native_value(self): class ModelNumberSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "model_number") + entity_key = "model_number" @property def native_value(self): @@ -374,10 +393,7 @@ def native_value(self): class DeviceSsidSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "device_ssid") + entity_key = "device_ssid" @property def native_value(self): @@ -386,10 +402,7 @@ def native_value(self): class StatusLabelSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "status_label") + entity_key = "status_label" @property def native_value(self): @@ -398,10 +411,7 @@ def native_value(self): class StatusTextSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "status_text") + entity_key = "status_text" @property def native_value(self): @@ -410,10 +420,7 @@ def native_value(self): class AddressSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "address") + entity_key = "address" @property def native_value(self): @@ -422,10 +429,7 @@ def native_value(self): class DealerNameSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "dealer_name") + entity_key = "dealer_name" @property def native_value(self): @@ -434,10 +438,7 @@ def native_value(self): class DealerEmailSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "dealer_email") + entity_key = "dealer_email" @property def native_value(self): @@ -446,10 +447,7 @@ def native_value(self): class DealerPhoneSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "dealer_phone") + entity_key = "dealer_phone" @property def native_value(self): @@ -458,10 +456,7 @@ def native_value(self): class PanelIDSensor(GeneracEntity, SensorEntity): - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "panel_id") + entity_key = "panel_id" @property def native_value(self): @@ -473,10 +468,7 @@ def native_value(self): class CapacitySensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "capacity") + entity_key = "capacity" @property def native_value(self): @@ -487,10 +479,7 @@ def native_value(self): class FuelTypeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "fuel_type") + entity_key = "fuel_type" @property def native_value(self): @@ -501,10 +490,7 @@ def native_value(self): class OrientationSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "orientation") + entity_key = "orientation" @property def native_value(self): @@ -515,10 +501,7 @@ def native_value(self): class BatteryLevelSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "battery_level") + entity_key = "battery_level" @property def native_value(self): @@ -529,12 +512,9 @@ def native_value(self): class LastReadingDateSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TIMESTAMP + entity_key = "last_reading" - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "last_reading") + device_class = SensorDeviceClass.TIMESTAMP @property def native_value(self): @@ -548,32 +528,48 @@ def native_value(self): class FuelLevelSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + entity_key = "fuel_level" + device_class = SensorDeviceClass.BATTERY native_unit_of_measurement = PERCENTAGE - @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "fuel_level") - @property def native_value(self): """Return the state of the sensor.""" - return get_prop_value(self.aparatus_detail.tuProperties, 9, None) + val = get_prop_value(self.aparatus_detail.tuProperties, 9, None) + return _safe_float(val, "fuel_level") class SignalStrengthSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + entity_key = "signal_strength" + + _attr_icon = "mdi:signal-cellular-2" + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + @property - def name(self): - """Return the name of the sensor.""" - return sensor_name(self, "signal_strength") + def native_value(self): + """Return the state of the sensor.""" + return _safe_float( + get_apparatus_property_value(self.item, "signalStrength"), + "signal_strength", + ) + + +class DeviceBatteryLevelSensor(GeneracEntity, SensorEntity): + """Battery level reported by the Mobile Link device.""" + + entity_key = "device_battery_level" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE @property def native_value(self): """Return the state of the sensor.""" - wifi_signal_data = get_prop_value(self.aparatus.properties, 3, None) - if wifi_signal_data is None: - return "0%" - return wifi_signal_data.signalStrength + return _safe_float( + get_apparatus_property_value(self.item, "batteryLevel"), + "device_battery_level", + ) diff --git a/custom_components/generac/weather.py b/custom_components/generac/weather.py index b1cad99..44c7600 100644 --- a/custom_components/generac/weather.py +++ b/custom_components/generac/weather.py @@ -7,7 +7,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEFAULT_NAME from .const import DOMAIN from .coordinator import GeneracDataUpdateCoordinator from .entity import GeneracEntity @@ -37,10 +36,7 @@ def sensors(item: Item) -> list[Type[GeneracEntity]]: class WeatherSensor(GeneracEntity, WeatherEntity): """generac Weather class.""" - @property - def name(self): - """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{self.device_id}_weather" + entity_key = "weather" @property def condition(self): diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index f4dcd5c..acec67f 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -37,7 +37,7 @@ async def test_connected_sensor(hass): item = get_mock_item(True, False, False, False) sensor = GeneracConnectedSensor(coordinator, entry, "12345", item) assert sensor.is_on is True - assert sensor.name == "generac_12345_is_connected" + assert sensor.name == "Connected" assert sensor.device_class == "connectivity" # Test when not connected @@ -55,7 +55,7 @@ async def test_connecting_sensor(hass): item = get_mock_item(False, True, False, False) sensor = GeneracConnectingSensor(coordinator, entry, "12345", item) assert sensor.is_on is True - assert sensor.name == "generac_12345_is_connecting" + assert sensor.name == "Connecting" assert sensor.device_class == "connectivity" # Test when not connecting @@ -73,7 +73,7 @@ async def test_maintenance_alert_sensor(hass): item = get_mock_item(False, False, True, False) sensor = GeneracMaintenanceAlertSensor(coordinator, entry, "12345", item) assert sensor.is_on is True - assert sensor.name == "generac_12345_has_maintenance_alert" + assert sensor.name == "Maintenance Alert" assert sensor.device_class == "safety" # Test when maintenance alert is not active @@ -91,7 +91,7 @@ async def test_warning_sensor(hass): item = get_mock_item(False, False, False, True) sensor = GeneracWarningSensor(coordinator, entry, "12345", item) assert sensor.is_on is True - assert sensor.name == "generac_12345_show_warning" + assert sensor.name == "Warning" assert sensor.device_class == "safety" # Test when warning is not active diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 9893e44..45bf1ca 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -28,7 +28,62 @@ async def test_diagnostics(hass): diagnostics = await async_get_config_entry_diagnostics(hass, entry) - assert diagnostics["data"]["12345"]["apparatus"]["serialNumber"] == "REDACTED" - assert diagnostics["data"]["12345"]["apparatus"]["apparatusId"] == "REDACTED" - assert diagnostics["data"]["12345"]["apparatus"]["localizedAddress"] == "REDACTED" - assert diagnostics["data"]["12345"]["apparatusDetail"]["deviceSsid"] == "REDACTED" + assert diagnostics["data"]["device_0"]["apparatus"]["serialNumber"] == "REDACTED" + assert diagnostics["data"]["device_0"]["apparatus"]["apparatusId"] == "REDACTED" + assert ( + diagnostics["data"]["device_0"]["apparatus"]["localizedAddress"] == "REDACTED" + ) + assert ( + diagnostics["data"]["device_0"]["apparatusDetail"]["deviceSsid"] == "REDACTED" + ) + + +async def test_diagnostics_redacts_identifiers_emails_and_ips(hass): + """Device IDs never leak via dict keys; emails and IPs get correct labels.""" + coordinator = MagicMock() + coordinator.data = { + "98765": Item( + apparatus=Apparatus( + apparatusId=98765, + panelId="PANEL-1", + preferredDealerEmail="dealer@example.com", + preferredDealerName="Dealer ", + properties=[ + Apparatus.Property(name="ip", value="192.0.2.10", type=1), + Apparatus.Property(name="ip6", value="2001:db8::1", type=2), + ], + ), + apparatusDetail=ApparatusDetail(shortDeviceId="SHORT-1"), + ) + } + entry = MagicMock() + entry.entry_id = "test_entry_id" + hass.data = {"generac": {"test_entry_id": coordinator}} + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + + assert diagnostics["status"] == "loaded" + assert list(diagnostics["data"]) == ["device_0"] + assert "98765" not in str(diagnostics) + apparatus = diagnostics["data"]["device_0"]["apparatus"] + assert apparatus["panelId"] == "REDACTED" + assert apparatus["preferredDealerEmail"] == "REDACTED_EMAIL" + assert apparatus["preferredDealerName"] == "REDACTED_EMAIL" + assert apparatus["properties"][0]["value"] == "REDACTED_IPV4" + assert apparatus["properties"][1]["value"] == "REDACTED_IPV6" + assert diagnostics["data"]["device_0"]["apparatusDetail"]["shortDeviceId"] == ( + "REDACTED" + ) + for secret in ("PANEL-1", "SHORT-1", "dealer@example.com", "192.0.2.10"): + assert secret not in str(diagnostics) + + +async def test_diagnostics_before_entry_is_loaded(hass): + """Diagnostics for an entry that never loaded don't raise KeyError.""" + entry = MagicMock() + entry.entry_id = "missing" + hass.data = {} + assert await async_get_config_entry_diagnostics(hass, entry) == { + "status": "not_loaded", + "data": {}, + } diff --git a/tests/test_entity.py b/tests/test_entity.py index c63b0be..0cf7636 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -76,3 +76,27 @@ async def test_entity_missing_device_falls_back_to_empty_item(hass): assert entity.item is _EMPTY_ITEM assert entity.available is False assert entity.async_write_ha_state.called + + +async def test_entity_unavailable_after_failed_poll(hass): + """A failed coordinator refresh marks entities unavailable.""" + coordinator = MagicMock() + coordinator.is_online = True + coordinator.last_update_success = False + entry = MagicMock() + entity = GeneracEntity(coordinator, entry, "12345", get_mock_item()) + assert entity.available is False + + +async def test_entity_update_tolerates_missing_coordinator_data(hass): + """coordinator.data of None leaves the entity alive but unavailable.""" + from custom_components.generac.entity import _EMPTY_ITEM + + coordinator = MagicMock() + coordinator.data = None + entity = GeneracEntity(coordinator, MagicMock(), "12345", get_mock_item()) + entity.hass = hass + entity.async_write_ha_state = MagicMock() + entity._handle_coordinator_update() + assert entity.item is _EMPTY_ITEM + assert entity.available is False diff --git a/tests/test_entity_registry.py b/tests/test_entity_registry.py new file mode 100644 index 0000000..dd64550 --- /dev/null +++ b/tests/test_entity_registry.py @@ -0,0 +1,319 @@ +"""Upgrade compatibility: existing entity registry entries must be reused. + +The unique_id suffixes below are copied from the integration as released +before friendly names (each entity's old technical name was +``generac__``). An upgrade must match every one of them, +so that no entity is orphaned and no ``_2`` duplicate is created. +""" +from unittest.mock import patch + +from custom_components.generac.auth import DPoPKey +from custom_components.generac.const import CONF_DPOP_PEM +from custom_components.generac.const import CONF_REFRESH_TOKEN +from custom_components.generac.const import CONF_USERNAME +from custom_components.generac.const import DOMAIN +from custom_components.generac.models import Apparatus +from custom_components.generac.models import ApparatusDetail +from custom_components.generac.models import Item +from dacite import from_dict +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +ENTRY_ID = "entry1" +GENERATOR_ID = "12345" +PROPANE_ID = "67890" + +HISTORICAL_SUFFIXES = { + GENERATOR_ID: { + "sensor": [ + "status", + "run_time", + "protection_time", + "activation_date", + "last_seen", + "connection_time", + "battery_voltage", + "device_type", + "dealer_email", + "dealer_name", + "dealer_phone", + "address", + "status_text", + "status_label", + "serial_number", + "model_number", + "device_ssid", + "panel_id", + "signal_strength", + "outdoor_temperature", + ], + "binary_sensor": [ + "is_connected", + "is_connecting", + "has_maintenance_alert", + "show_warning", + ], + "weather": ["weather"], + "image": ["hero_image"], + }, + PROPANE_ID: { + "sensor": [ + "status", + "capacity", + "fuel_level", + "fuel_type", + "orientation", + "last_reading", + "battery_level", + "address", + "device_type", + ], + "binary_sensor": [ + "is_connected", + "is_connecting", + "has_maintenance_alert", + "show_warning", + ], + }, +} + +# Entities this change adds for the fixture payloads below. +NEW_SUFFIXES = { + GENERATOR_ID: {"sensor": ["exercise_minutes"]}, + PROPANE_ID: {"sensor": ["signal_strength", "device_battery_level"]}, +} + + +def _weather() -> dict: + return { + "temperature": {"value": 71.0, "unit": "F", "unitType": 1}, + "iconCode": 1, + } + + +def _generator() -> Item: + return Item( + from_dict( + Apparatus, + { + "apparatusId": int(GENERATOR_ID), + "name": "Generator", + "type": 0, + "serialNumber": "SN-1", + "modelNumber": "G-1", + "panelId": "P-1", + "localizedAddress": "1 Main St", + "weather": _weather(), + "properties": [ + { + "name": "Wi-Fi", + "type": 3, + "value": {"type": 3, "signalStrength": "75%"}, + } + ], + }, + ), + from_dict( + ApparatusDetail, + { + "apparatusId": int(GENERATOR_ID), + "name": "Generator", + "apparatusStatus": 1, + "deviceType": "wifi", + "heroImageUrl": "https://example.invalid/hero.png", + "weather": _weather(), + "isConnected": True, + "isConnecting": False, + "hasMaintenanceAlert": False, + "showWarning": False, + "properties": [ + {"name": "Battery Voltage", "value": "13.4", "type": 70}, + {"name": "Run Hours", "value": "120.5", "type": 71}, + {"name": "Protection Time", "value": "4000", "type": 32}, + {"name": "Exercise Duration", "value": "12", "type": 95}, + ], + }, + ), + ) + + +def _propane() -> Item: + return Item( + from_dict( + Apparatus, + { + "apparatusId": int(PROPANE_ID), + "name": "Propane Tank", + "type": 2, + "localizedAddress": "1 Main St", + "properties": [ + { + "name": "Tank Monitor", + "type": 3, + "value": { + "status": "Online", + "signalStrength": "60", + "batteryLevel": "80%", + }, + } + ], + }, + ), + from_dict( + ApparatusDetail, + { + "apparatusId": int(PROPANE_ID), + "name": "Propane Tank", + "deviceType": "lte-tankutility-v2", + "isConnected": True, + "tuProperties": [ + {"name": "Fuel Type", "value": "Propane", "type": 0}, + {"name": "Capacity", "value": 500, "type": 1}, + {"name": "Orientation", "value": "Horizontal", "type": 2}, + {"name": "Fuel Level", "value": "45%", "type": 9}, + { + "name": "Last Reading", + "value": "2026-07-01T12:00:00Z", + "type": 11, + }, + {"name": "Battery", "value": "Good", "type": 17}, + ], + }, + ), + ) + + +def _unique_id(device_id: str, suffix: str) -> str: + return f"{ENTRY_ID}_{device_id}_generac_{device_id}_{suffix}" + + +async def test_upgrade_reuses_every_existing_registry_entry(hass: HomeAssistant): + """Entities registered by the old naming scheme keep their IDs on upgrade.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id=ENTRY_ID, + unique_id="user@example.com", + data={ + CONF_USERNAME: "user@example.com", + CONF_REFRESH_TOKEN: "rt", + CONF_DPOP_PEM: DPoPKey.generate().to_pem_str(), + }, + ) + entry.add_to_hass(hass) + + registry = er.async_get(hass) + historical: dict[str, str] = {} + for device_id, platforms in HISTORICAL_SUFFIXES.items(): + for platform, suffixes in platforms.items(): + for suffix in suffixes: + reg_entry = registry.async_get_or_create( + platform, + DOMAIN, + _unique_id(device_id, suffix), + config_entry=entry, + suggested_object_id=f"generac_{device_id}_{suffix}", + ) + historical[reg_entry.unique_id] = reg_entry.entity_id + + data = {GENERATOR_ID: _generator(), PROPANE_ID: _propane()} + with patch( + "custom_components.generac.api.GeneracApiClient.async_get_data", + return_value=data, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entries = er.async_entries_for_config_entry(registry, ENTRY_ID) + by_unique_id = {e.unique_id: e for e in entries} + + expected_new = { + _unique_id(device_id, suffix) + for device_id, platforms in NEW_SUFFIXES.items() + for suffixes in platforms.values() + for suffix in suffixes + } + # Nothing duplicated, nothing unexpected: only the genuinely new sensors. + assert set(by_unique_id) == set(historical) | expected_new + assert not [e.entity_id for e in entries if e.entity_id.endswith("_2")] + + for unique_id, entity_id in historical.items(): + # Same registry entry, same entity_id, and actually provided again + # (not a restored orphan). + assert by_unique_id[unique_id].entity_id == entity_id + state = hass.states.get(entity_id) + assert state is not None, entity_id + assert not state.attributes.get("restored"), entity_id + + # Display names now read "