diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 29ff665..f4f32e1 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -21,11 +21,15 @@ PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfElectricPotential, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +import homeassistant.util.dt as dt_util from . import OpenDisplayConfigEntry from .coordinator import OpenDisplayUpdate @@ -131,12 +135,28 @@ def _humidity(upd: OpenDisplayUpdate) -> float | None: device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - # native_value is overridden by OpenDisplayLastSeenSensor, so this value_fn + # native_value is overridden by OpenDisplayLastSeenSensorEntity, so this value_fn # is dead code; value_fn is a required field, hence the no-op. value_fn=lambda _upd: None, ) +def _entity_for_description( + coordinator, description: OpenDisplaySensorEntityDescription +) -> OpenDisplaySensorEntity: + """Pick the entity class for a sensor description.""" + + if description.key == "last_seen": + return OpenDisplayLastSeenSensorEntity(coordinator, description) + # Battery readings only ride in the advertisement, so they update far less + # often than the device's own wake cadence. Keep showing the last known + # value (and stay available) across Bluetooth gaps instead of flashing + # unavailable. + if description.key in {"battery", "battery_voltage", "rssi", "temperature"}: + return OpenDisplayStickySensorEntity(coordinator, description) + return OpenDisplaySensorEntity(coordinator, description) + + async def async_setup_entry( hass: HomeAssistant, entry: OpenDisplayConfigEntry, @@ -173,11 +193,7 @@ async def async_setup_entry( ] async_add_entities( - ( - OpenDisplayLastSeenSensor(coordinator, description) - if description.key == "last_seen" - else OpenDisplaySensorEntity(coordinator, description) - ) + _entity_for_description(coordinator, description) for description in descriptions ) @@ -195,7 +211,52 @@ def native_value(self) -> float | int | str | datetime | None: return self.entity_description.value_fn(self.coordinator.data) -class OpenDisplayLastSeenSensor(OpenDisplaySensorEntity): +class OpenDisplayStickySensorEntity(OpenDisplaySensorEntity, RestoreEntity): + """A sensor that keeps its last known value once observed. + + Some readings (e.g. battery) only ride in the advertisement and can go + a long time between updates. Rather than flashing "unavailable" whenever + Bluetooth briefly loses the device, cache the last non-None value and + stay available. + """ + + def __init__( + self, + coordinator, + description: OpenDisplaySensorEntityDescription, + ) -> None: + """Initialize the sticky sensor.""" + super().__init__(coordinator, description) + self._last_value: float | int | str | datetime | None = None + + @property + def available(self) -> bool: + """Stay available once a value has been observed.""" + return self._last_value is not None or super().available + + @property + def native_value(self) -> float | int | str | datetime | None: + """Return the last known value, updating it if a fresh one is present.""" + if self.coordinator.data is not None: + value = self.entity_description.value_fn(self.coordinator.data) + if value is not None: + self._last_value = value + return self._last_value + + async def async_added_to_hass(self) -> None: + """Restore the previous value until Bluetooth has one.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is None: + return + if last_state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}: + return + if self.device_class == SensorDeviceClass.TIMESTAMP: + self._last_value = dt_util.parse_datetime(last_state.state) + else: + self._last_value = last_state.state + + +class OpenDisplayLastSeenSensorEntity(OpenDisplayStickySensorEntity): """last_seen sourced from the bluetooth stack, not the gated callback.""" @property @@ -207,8 +268,9 @@ def native_value(self) -> datetime | None: self.hass, self.coordinator.address, connectable=False ) if info is None: - return None + return self._last_value # info.time is a monotonic clock (monotonic_time_coarse); convert to # wall time with the same offset the advertisement monitor uses. wall = info.time + (time.time() - time.monotonic()) - return datetime.fromtimestamp(wall, tz=timezone.utc) + self._last_value = datetime.fromtimestamp(wall, tz=timezone.utc) + return self._last_value diff --git a/custom_components/opendisplay/update.py b/custom_components/opendisplay/update.py index ac5fdb9..ce30394 100644 --- a/custom_components/opendisplay/update.py +++ b/custom_components/opendisplay/update.py @@ -131,16 +131,22 @@ def __init__(self, coordinator, entry: OpenDisplayConfigEntry) -> None: @property def available(self) -> bool: - """Stay available while a firmware update is installing. - - During an update the device leaves app mode for the AppLoader, so the - passive-BLE availability tracker would otherwise mark this entity - unavailable mid-install and hide the progress, making a working update - look like a silent failure. The install runs on its own BLE connection - and is unaffected by the app-mode advertisement stopping, so keep the - entity available until it finishes. + """Stay available once the installed version is known. + + installed_version is captured once at setup from stored device config, + so it never goes stale between BLE adverts the way a live reading + would. Gating availability on the passive-BLE tracker would otherwise + flash this entity unavailable on every brief Bluetooth gap -- and, + during an update, hide install progress entirely, since the device + leaves app mode for the AppLoader and stops advertising in a form the + tracker recognizes. The install itself runs on its own BLE connection + and is unaffected by that gap regardless of transient BLE state. """ - return self._installing or super().available + return ( + self._installing + or self._attr_installed_version is not None + or super().available + ) @property def release_url(self) -> str | None: diff --git a/tests/test_last_seen.py b/tests/test_last_seen.py index 94af071..c9682e3 100644 --- a/tests/test_last_seen.py +++ b/tests/test_last_seen.py @@ -59,6 +59,35 @@ def test_native_value_none_when_no_service_info(): assert entity.native_value is None +def test_last_seen_available_after_first_value_when_coordinator_unavailable(): + entity = _make_sensor() + entity.coordinator.available = False + mono = time.monotonic() + + with patch.object( + sensor_mod, + "async_last_service_info", + return_value=SimpleNamespace(time=mono), + ): + value = entity.native_value + + assert value is not None + assert entity.available is True + + with patch.object(sensor_mod, "async_last_service_info", return_value=None): + assert entity.native_value == value + assert entity.available is True + + +def test_last_seen_unavailable_before_first_value_when_coordinator_unavailable(): + entity = _make_sensor() + entity.coordinator.available = False + + with patch.object(sensor_mod, "async_last_service_info", return_value=None): + assert entity.native_value is None + assert entity.available is False + + if __name__ == "__main__": import pytest