Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 71 additions & 9 deletions custom_components/opendisplay/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)

Expand All @@ -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
Expand All @@ -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
24 changes: 15 additions & 9 deletions custom_components/opendisplay/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions tests/test_last_seen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading