From 3f7e5dc27d56e4971ecb01eedafc2d6806713db9 Mon Sep 17 00:00:00 2001 From: technorat2 Date: Sat, 11 Jul 2026 22:18:33 -0400 Subject: [PATCH 1/2] Add friendly entity names and numeric signal/battery sensors Ported from pjordanandrsn/ha-generac#4 by @technorat2 (commit 7e3b067 "Improve authentication and entity reporting"), entity and telemetry half, applied unchanged to current upstream: - Friendly display names derived from each entity's technical name, while unique_id keeps using the technical name. - Signal strength reported as a numeric percentage (measurement), plus a device battery percentage sensor and an exercise duration sensor when Mobile Link reports them. - _safe_float() accepts "NN%" strings. - Entity availability also follows coordinator.last_update_success. - Diagnostics: tolerate an unloaded entry, redact e-mail addresses, and detect IPv6 with IPv6Network. The authentication half of #4 is carried separately. Refs binarydev/ha-generac#284 Co-authored-by: Jordan Anderson Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K2RdXuPegztHA1uMG2fbtk --- custom_components/generac/binary_sensor.py | 18 +-- custom_components/generac/diagnostics.py | 32 +++-- custom_components/generac/entity.py | 68 +++++++++- custom_components/generac/image.py | 3 +- custom_components/generac/sensor.py | 140 ++++++++++++++++----- custom_components/generac/weather.py | 3 +- 6 files changed, 196 insertions(+), 68 deletions(-) diff --git a/custom_components/generac/binary_sensor.py b/custom_components/generac/binary_sensor.py index a01b062..e10ed5e 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,13 @@ 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") + return self._friendly_name() @property def device_class(self): @@ -65,12 +60,7 @@ class GeneracConnectingSensor(GeneracEntity, BinarySensorEntity): @property def name(self): """Return the name of the binary_sensor.""" - return sensor_name(self, "is_connecting") - - @property - def device_class(self): - """Return the class of this binary_sensor.""" - return BinarySensorDeviceClass.CONNECTIVITY + return self._friendly_name() @property def is_on(self): @@ -84,7 +74,7 @@ class GeneracMaintenanceAlertSensor(GeneracEntity, BinarySensorEntity): @property def name(self): """Return the name of the binary_sensor.""" - return sensor_name(self, "has_maintenance_alert") + return self._friendly_name() @property def device_class(self): @@ -103,7 +93,7 @@ class GeneracWarningSensor(GeneracEntity, BinarySensorEntity): @property def name(self): """Return the name of the binary_sensor.""" - return sensor_name(self, "show_warning") + return self._friendly_name() @property def device_class(self): diff --git a/custom_components/generac/diagnostics.py b/custom_components/generac/diagnostics.py index c80805c..f1e2168 100644 --- a/custom_components/generac/diagnostics.py +++ b/custom_components/generac/diagnostics.py @@ -17,11 +17,20 @@ 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", "data": redact( - {gen_id: asdict(item) for gen_id, item in coordinator.data.items()}, False + {gen_id: asdict(item) for gen_id, item in (coordinator.data or {}).items()}, + False, ), } @@ -31,16 +40,17 @@ 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" + return "REDACTED_IPV6" if is_ipv6(data): return "REDACTED_IPV4" if redact_all: @@ -48,19 +58,19 @@ def redact(data: Any, redact_all: bool): 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 diff --git a/custom_components/generac/entity.py b/custom_components/generac/entity.py index 0bd7bdf..4adcae5 100644 --- a/custom_components/generac/entity.py +++ b/custom_components/generac/entity.py @@ -1,5 +1,7 @@ -"""GeneracEntity class""" +"""Generac entity base classes and friendly-name helpers.""" import logging +import re +from typing import ClassVar from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback @@ -7,6 +9,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 +19,48 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) +_ENTITY_WORDS = { + "api": "API", + "id": "ID", + "ssid": "SSID", + "vpp": "VPP", +} +_ENTITY_KEY_OVERRIDES = { + "GeneracConnectedSensor": "is_connected", + "GeneracConnectingSensor": "is_connecting", + "GeneracMaintenanceAlertSensor": "has_maintenance_alert", + "GeneracWarningSensor": "show_warning", +} + + +def _camel_to_snake(value: str) -> str: + """Convert a class name fragment into an entity suffix.""" + value = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", value) + value = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", value) + return value.lower() + + +def parse_entity_name(entity_name: str) -> str: + """Convert an internal entity name into a readable label.""" + words = re.split(r"[_\s-]+", entity_name.strip()) + domain_words = DEFAULT_NAME.split("_") + if words[: len(domain_words)] == domain_words: + words = words[len(domain_words) :] + if words and words[0].isdigit(): + words = words[1:] + return " ".join( + _ENTITY_WORDS.get(word.lower(), word.capitalize()) for word in words + ) + + _EMPTY_ITEM = Item(apparatus=Apparatus(), apparatusDetail=ApparatusDetail(), empty=True) class GeneracEntity(CoordinatorEntity[GeneracDataUpdateCoordinator]): + """Base entity with stable registry IDs and readable display names.""" + + _entity_key: ClassVar[str | None] = None + def __init__( self, coordinator: GeneracDataUpdateCoordinator, @@ -35,7 +76,20 @@ 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}" + return f"{self.config_entry.entry_id}_{self.device_id}_{self._technical_name}" + + @property + def _technical_name(self) -> str: + """Return the stable technical name used by the entity registry.""" + entity_key = self._entity_key or _ENTITY_KEY_OVERRIDES.get( + type(self).__name__, + _camel_to_snake(type(self).__name__.removesuffix("Sensor")), + ) + return f"{DEFAULT_NAME}_{self.device_id}_{entity_key}" + + def _friendly_name(self) -> str: + """Return the readable display label for this entity.""" + return parse_entity_name(self._technical_name) @property def device_info(self): @@ -58,7 +112,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 +133,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.unique_id) self.async_write_ha_state() diff --git a/custom_components/generac/image.py b/custom_components/generac/image.py index 1bb73ce..eee26b1 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 @@ -46,7 +45,7 @@ def __init__( @property def name(self): """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{self.device_id}_hero_image" + return self._friendly_name() @property def image_url(self): diff --git a/custom_components/generac/sensor.py b/custom_components/generac/sensor.py index 4fbc779..13c4e24 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,7 +61,6 @@ def sensors(item: Item) -> list[Type[GeneracEntity]]: ModelNumberSensor, DeviceSsidSensor, PanelIDSensor, - SignalStrengthSensor, ] elif item.apparatus.type == DEVICE_TYPE_PROPANE_MONITOR: lst = [ @@ -83,6 +82,12 @@ def sensors(item: Item) -> list[Type[GeneracEntity]]: and item.apparatusDetail.weather.temperature.value is not None ): lst.append(OutdoorTemperatureSensor) + if 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 +111,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 +149,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: @@ -149,7 +180,7 @@ class StatusSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "status") + return self._friendly_name() @property def native_value(self): @@ -183,7 +214,7 @@ class DeviceTypeSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "device_type") + return self._friendly_name() @property def native_value(self): @@ -212,7 +243,7 @@ class RunTimeSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "run_time") + return self._friendly_name() @property def native_value(self): @@ -230,7 +261,7 @@ class ProtectionTimeSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "protection_time") + return self._friendly_name() @property def native_value(self): @@ -247,7 +278,7 @@ class ActivationDateSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "activation_date") + return self._friendly_name() @property def native_value(self): @@ -266,7 +297,7 @@ class LastSeenSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "last_seen") + return self._friendly_name() @property def native_value(self): @@ -285,7 +316,7 @@ class ConnectionTimeSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "connection_time") + return self._friendly_name() @property def native_value(self): @@ -306,7 +337,7 @@ class BatteryVoltageSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "battery_voltage") + return self._friendly_name() @property def native_value(self): @@ -315,6 +346,23 @@ def native_value(self): return _safe_float(val, "battery_voltage") +class ExerciseMinutesSensor(GeneracEntity, SensorEntity): + """Exercise duration reported by Mobile Link.""" + + device_class = SensorDeviceClass.DURATION + native_unit_of_measurement = "min" + + @property + def name(self): + """Return the name of the sensor.""" + return self._friendly_name() + + @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.""" @@ -323,7 +371,7 @@ class OutdoorTemperatureSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "outdoor_temperature") + return self._friendly_name() @property def native_unit_of_measurement(self): @@ -353,7 +401,7 @@ class SerialNumberSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "serial_number") + return self._friendly_name() @property def native_value(self): @@ -365,7 +413,7 @@ class ModelNumberSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "model_number") + return self._friendly_name() @property def native_value(self): @@ -377,7 +425,7 @@ class DeviceSsidSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "device_ssid") + return self._friendly_name() @property def native_value(self): @@ -389,7 +437,7 @@ class StatusLabelSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "status_label") + return self._friendly_name() @property def native_value(self): @@ -401,7 +449,7 @@ class StatusTextSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "status_text") + return self._friendly_name() @property def native_value(self): @@ -413,7 +461,7 @@ class AddressSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "address") + return self._friendly_name() @property def native_value(self): @@ -425,7 +473,7 @@ class DealerNameSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "dealer_name") + return self._friendly_name() @property def native_value(self): @@ -437,7 +485,7 @@ class DealerEmailSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "dealer_email") + return self._friendly_name() @property def native_value(self): @@ -449,7 +497,7 @@ class DealerPhoneSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "dealer_phone") + return self._friendly_name() @property def native_value(self): @@ -461,7 +509,7 @@ class PanelIDSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "panel_id") + return self._friendly_name() @property def native_value(self): @@ -476,7 +524,7 @@ class CapacitySensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "capacity") + return self._friendly_name() @property def native_value(self): @@ -490,7 +538,7 @@ class FuelTypeSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "fuel_type") + return self._friendly_name() @property def native_value(self): @@ -504,7 +552,7 @@ class OrientationSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "orientation") + return self._friendly_name() @property def native_value(self): @@ -518,7 +566,7 @@ class BatteryLevelSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "battery_level") + return self._friendly_name() @property def native_value(self): @@ -534,7 +582,7 @@ class LastReadingDateSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "last_reading") + return self._friendly_name() @property def native_value(self): @@ -554,7 +602,7 @@ class FuelLevelSensor(GeneracEntity, SensorEntity): @property def name(self): """Return the name of the sensor.""" - return sensor_name(self, "fuel_level") + return self._friendly_name() @property def native_value(self): @@ -565,15 +613,39 @@ def native_value(self): class SignalStrengthSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" + _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") + return self._friendly_name() @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, "signalStrength"), + "signal_strength", + ) + + +class DeviceBatteryLevelSensor(GeneracEntity, SensorEntity): + """Battery level reported by the Mobile Link device.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + + @property + def name(self): + """Return the name of the sensor.""" + return self._friendly_name() + + @property + def native_value(self): + """Return the state of the sensor.""" + 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..02bced6 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 @@ -40,7 +39,7 @@ class WeatherSensor(GeneracEntity, WeatherEntity): @property def name(self): """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{self.device_id}_weather" + return self._friendly_name() @property def condition(self): From 71aa0f67854e2d497ea53b9832440f8aa714797e Mon Sep 17 00:00:00 2001 From: Jordan Anderson Date: Wed, 28 Oct 2026 19:24:53 +0000 Subject: [PATCH 2/2] Keep entity registry IDs stable; use has_entity_name for friendly names Follow-up to the ported friendly-name change. Registry compatibility: - The port derived each unique_id suffix from the class name. That matched history for every class except LastReadingDateSensor, whose historical suffix is "last_reading". Upgrading would have orphaned the existing propane "last reading" entity and created a new one. Every entity class now declares an explicit entity_key; none is inferred, and unique_ids stay byte-for-byte what they were. - SignalStrengthSensor stays unconditional for generators, so existing entities are never dropped when a device omits the value. It is also added for other device types (propane monitors) when reported. Naming: - _attr_has_entity_name = True, so Home Assistant shows "