From 531c7f6a9d26b5e4f0fffad278b5989e69839d3f Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 16 Jul 2026 21:36:22 +0200 Subject: [PATCH 1/5] Keep last seen sensor available after first value --- custom_components/opendisplay/sensor.py | 19 ++++++++++++++-- tests/test_last_seen.py | 29 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 8107ed0..d5ab26a 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -141,6 +141,20 @@ def native_value(self) -> float | int | str | datetime | None: class OpenDisplayLastSeenSensor(OpenDisplaySensorEntity): """last_seen sourced from the bluetooth stack, not the gated callback.""" + def __init__( + self, + coordinator, + description: OpenDisplaySensorEntityDescription, + ) -> None: + """Initialize the last_seen sensor.""" + super().__init__(coordinator, description) + self._last_seen: datetime | None = None + + @property + def available(self) -> bool: + """Stay available once a last_seen value has been observed.""" + return self._last_seen is not None or super().available + @property def native_value(self) -> datetime | None: # connectable=False matches the Bluetooth advertisement monitor's @@ -150,8 +164,9 @@ def native_value(self) -> datetime | None: self.hass, self.coordinator.address, connectable=False ) if info is None: - return None + return self._last_seen # 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_seen = datetime.fromtimestamp(wall, tz=timezone.utc) + return self._last_seen 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 From 967eab8721f9f096ead37a49564e2a20fb10e125 Mon Sep 17 00:00:00 2001 From: Keith Lamprecht <1894492+Nixon506E@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:20:18 -0400 Subject: [PATCH 2/5] Add Sticky Sensor Class --- custom_components/opendisplay/sensor.py | 88 +++++++++++++++++-------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 954e318..63dd780 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -37,7 +37,6 @@ @dataclass(frozen=True, kw_only=True) class OpenDisplaySensorEntityDescription(SensorEntityDescription): """Describes an OpenDisplay sensor entity.""" - value_fn: Callable[[OpenDisplayUpdate], float | int | str | datetime | None] @@ -60,28 +59,28 @@ def _sht40_descriptions( sensor: SensorData, ) -> list[OpenDisplaySensorEntityDescription]: """Build ambient temperature and humidity entities for one SHT40. - + The reading rides in the advertisement, so these need no connection. Its offset within the dynamic block is per-board and cannot be assumed -- reTerminal E1001/E1002/E1004 use 1 while the firmware default is 7 -- so it comes from the device's own config and is captured once per entity here. - + Unlike the chip temperature these are primary entities: not diagnostic, and enabled by default. """ start_byte = sensor.sht40_msd_start_byte - + def _reading(upd: OpenDisplayUpdate) -> Sht40Reading | None: return upd.advertisement.sht40_reading(start_byte) - + def _temperature(upd: OpenDisplayUpdate) -> float | None: reading = _reading(upd) return None if reading is None else reading.temperature_c - + def _humidity(upd: OpenDisplayUpdate) -> float | None: reading = _reading(upd) return None if reading is None else reading.humidity_percent - + return [ OpenDisplaySensorEntityDescription( key=f"sht40_{sensor.instance_number}_temperature", @@ -131,12 +130,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"}: + return OpenDisplayStickySensorEntity(coordinator, description) + return OpenDisplaySensorEntity(coordinator, description) + + async def async_setup_entry( hass: HomeAssistant, entry: OpenDisplayConfigEntry, @@ -151,11 +166,11 @@ async def async_setup_entry( _RSSI_DESCRIPTION, _LAST_SEEN_DESCRIPTION, ] - + for sensor in device_config.sensors: if sensor.sensor_type_enum is SensorType.SHT40: descriptions += _sht40_descriptions(sensor) - + if power_config.power_mode_enum in _BATTERY_POWER_MODES: capacity_estimator = power_config.capacity_estimator or CapacityEstimator.LI_ION descriptions += [ @@ -171,22 +186,18 @@ 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 ) class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): """A sensor entity for an OpenDisplay device.""" - + entity_description: OpenDisplaySensorEntityDescription - + @property def native_value(self) -> float | int | str | datetime | None: """Return the sensor value.""" @@ -195,23 +206,42 @@ def native_value(self) -> float | int | str | datetime | None: return self.entity_description.value_fn(self.coordinator.data) -class OpenDisplayLastSeenSensor(OpenDisplaySensorEntity): - """last_seen sourced from the bluetooth stack, not the gated callback.""" - +class OpenDisplayStickySensorEntity(OpenDisplaySensorEntity): + """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 last_seen sensor.""" + """Initialize the sticky sensor.""" super().__init__(coordinator, description) - self._last_seen: datetime | None = None - + self._last_value: float | int | str | datetime | None = None + @property def available(self) -> bool: - """Stay available once a last_seen value has been observed.""" - return self._last_seen is not None or super().available + """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 + +class OpenDisplayLastSeenSensorEntity(OpenDisplayStickySensorEntity): + """last_seen sourced from the bluetooth stack, not the gated callback.""" + @property def native_value(self) -> datetime | None: # connectable=False matches the Bluetooth advertisement monitor's @@ -221,9 +251,9 @@ def native_value(self) -> datetime | None: self.hass, self.coordinator.address, connectable=False ) if info is None: - return self._last_seen + 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()) - self._last_seen = datetime.fromtimestamp(wall, tz=timezone.utc) - return self._last_seen + self._last_value = datetime.fromtimestamp(wall, tz=timezone.utc) + return self._last_value From ae98d15af50264c36d456ee1356cf0dcdeb50816 Mon Sep 17 00:00:00 2001 From: Keith Lamprecht <1894492+Nixon506E@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:40:36 -0400 Subject: [PATCH 3/5] Keep version available between bluetooth updates Updated availability logic to maintain entity status during firmware updates and clarified docstring. --- custom_components/opendisplay/update.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) 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: From 09cff6d9ce27e87fd3668f988c061be534e1c0d2 Mon Sep 17 00:00:00 2001 From: Keith Lamprecht <1894492+Nixon506E@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:50:06 -0400 Subject: [PATCH 4/5] Remove whitespace --- custom_components/opendisplay/sensor.py | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 63dd780..1afc29b 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -59,28 +59,28 @@ def _sht40_descriptions( sensor: SensorData, ) -> list[OpenDisplaySensorEntityDescription]: """Build ambient temperature and humidity entities for one SHT40. - + The reading rides in the advertisement, so these need no connection. Its offset within the dynamic block is per-board and cannot be assumed -- reTerminal E1001/E1002/E1004 use 1 while the firmware default is 7 -- so it comes from the device's own config and is captured once per entity here. - + Unlike the chip temperature these are primary entities: not diagnostic, and enabled by default. """ start_byte = sensor.sht40_msd_start_byte - + def _reading(upd: OpenDisplayUpdate) -> Sht40Reading | None: return upd.advertisement.sht40_reading(start_byte) - + def _temperature(upd: OpenDisplayUpdate) -> float | None: reading = _reading(upd) return None if reading is None else reading.temperature_c - + def _humidity(upd: OpenDisplayUpdate) -> float | None: reading = _reading(upd) return None if reading is None else reading.humidity_percent - + return [ OpenDisplaySensorEntityDescription( key=f"sht40_{sensor.instance_number}_temperature", @@ -166,11 +166,11 @@ async def async_setup_entry( _RSSI_DESCRIPTION, _LAST_SEEN_DESCRIPTION, ] - + for sensor in device_config.sensors: if sensor.sensor_type_enum is SensorType.SHT40: descriptions += _sht40_descriptions(sensor) - + if power_config.power_mode_enum in _BATTERY_POWER_MODES: capacity_estimator = power_config.capacity_estimator or CapacityEstimator.LI_ION descriptions += [ @@ -186,7 +186,7 @@ async def async_setup_entry( ), ), ] - + async_add_entities( _entity_for_description(coordinator, description) for description in descriptions @@ -195,9 +195,9 @@ async def async_setup_entry( class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): """A sensor entity for an OpenDisplay device.""" - + entity_description: OpenDisplaySensorEntityDescription - + @property def native_value(self) -> float | int | str | datetime | None: """Return the sensor value.""" @@ -208,13 +208,13 @@ def native_value(self) -> float | int | str | datetime | None: class OpenDisplayStickySensorEntity(OpenDisplaySensorEntity): """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, @@ -223,12 +223,12 @@ def __init__( """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.""" @@ -241,7 +241,7 @@ def native_value(self) -> float | int | str | datetime | None: class OpenDisplayLastSeenSensorEntity(OpenDisplayStickySensorEntity): """last_seen sourced from the bluetooth stack, not the gated callback.""" - + @property def native_value(self) -> datetime | None: # connectable=False matches the Bluetooth advertisement monitor's From c88328201e746c882709dae08baab515c9473400 Mon Sep 17 00:00:00 2001 From: Keith Lamprecht <1894492+Nixon506E@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:55:15 -0400 Subject: [PATCH 5/5] Implement RestoreEntity in OpenDisplayStickySensorEntity Added support for restoring last known values for sensors. --- custom_components/opendisplay/sensor.py | 29 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 1afc29b..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 @@ -37,6 +41,7 @@ @dataclass(frozen=True, kw_only=True) class OpenDisplaySensorEntityDescription(SensorEntityDescription): """Describes an OpenDisplay sensor entity.""" + value_fn: Callable[[OpenDisplayUpdate], float | int | str | datetime | None] @@ -147,7 +152,7 @@ def _entity_for_description( # 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"}: + if description.key in {"battery", "battery_voltage", "rssi", "temperature"}: return OpenDisplayStickySensorEntity(coordinator, description) return OpenDisplaySensorEntity(coordinator, description) @@ -206,15 +211,15 @@ def native_value(self) -> float | int | str | datetime | None: return self.entity_description.value_fn(self.coordinator.data) -class OpenDisplayStickySensorEntity(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, @@ -223,12 +228,12 @@ def __init__( """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.""" @@ -238,6 +243,18 @@ def native_value(self) -> float | int | str | datetime | 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."""