Files
localthings/custom_components/localthings/sensor.py
T
Marc Billow 81dbc6fa03 Key devices on the OCF device ID instead of the serial number
Two Samsung air purifiers of the same model report the identical, well
formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's
unique_id, the device registry identifiers and every entity unique_id
were all minted from that string, the second unit was refused as already
configured, and would have collided entity-for-entity even if it hadn't
been.

This is the third firmware family to ship an unusable serialNum, after
`Nothing(SVC)` (#83) and the flash-unset sentinel (#189), and the first
one no heuristic can catch: the value is well formed, it's just shared.
`is_placeholder_serial` was a dead end.

So identity moves onto /oic/d's `di`, falling back to /oic/p's `pi`, then
the serial, then the host. `di` is what the protocol already uses to
address the endpoint -- if it were wrong or shared, OCF discovery and the
DTLS association wouldn't work at all -- and it's device-scoped, where
`pi` is platform-scoped and would be shared by a board hosting several
logical devices. Both units in #381 report a distinct `di`. A board that
answers neither resource lands exactly where it did before, so no
existing hardware regresses.

The re-key can't happen in async_migrate_entry: the UUID is only readable
from the device, and an entry can load entirely from its snapshot while
the appliance is off (#295). So v3 -> v4 only records the legacy key, and
the coordinator adopts the UUID on the first live poll, rewriting the
entity registry, the device registry (including subdevice identifiers)
and the entry's unique_id together. Rewriting rather than recreating is
what lets a user keep entity_ids, names, areas, statistics and every
automation that references them.

Three rules keep that adoption from misfiring:

- A poll that reads no UUID never demotes a UUID-keyed entry back onto
  its serial, so one failed reconnect doesn't re-key every entity.
- A changed UUID is followed only when the serial still corroborates it
  (a factory reset may regenerate `di`) or when the entry was keyed on
  its IP, which was never an identity to defend.
- When the identity is rejected as a different appliance, the serial
  isn't adopted either -- otherwise the intruder would gain exactly the
  corroboration needed to win the next poll.

Also stop redacting `di`/`pi` from diagnostics. They're randomly assigned
per-unit UUIDs, not account data, and blanking them is what made the
first #381 diagnostics download unable to answer the only question it was
requested to answer. The owner-set device name stays redacted.

Fixes #381
2026-08-17 05:24:22 +00:00

199 lines
8.7 KiB
Python

"""Sensor platform for Local Things."""
from __future__ import annotations
import time
from datetime import timedelta
from typing import cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN,
)
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SensorEntity] = [
LocalThingsSensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
]
entities.append(LocalThingsConnectionModeSensor(coordinator))
async_add_entities(entities)
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc = cast(SensorDesc, bound.desc)
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = (
SensorDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
# Always set, so the `options` property below can read it unguarded.
self._attr_options = list(desc.options) if desc.options else None
self._hysteresis_value = None
self._sticky_value = None
self._sticky_until: float | None = None
self._sticky_spent = False
@property
def native_unit_of_measurement(self):
desc = cast(SensorDesc, self._bound.desc)
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
@property
def options(self):
"""Declared options, plus whatever this device is actually reporting.
HA raises for an enum state outside `options`, which would turn any
device value we don't have a translation for into a broken entity --
the opposite of this registry's rule that an unrecognized value
renders raw. Admitting the live value keeps it displayable; it just
shows untranslated (PR #341 review).
"""
if self._attr_options is None:
return None
value = self.native_value
if not isinstance(value, str) or value in self._attr_options:
return self._attr_options
return [*self._attr_options, value]
@property
def native_value(self):
raw = (self.coordinator.data or {}).get(self._state_key)
desc = cast(SensorDesc, self._bound.desc)
if desc.sticky_fn is not None:
raw = self._apply_sticky(raw, desc)
if not desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_sticky(self, raw, desc: SensorDesc):
"""Freeze this entity at a value for up to `desc.sticky_seconds`
after `desc.sticky_fn` next stops matching this href's live rep
(issue #345 -- see operational.py's `_just_finished` for the
motivating case). Entity-instance state only, exactly like
`_hysteresis_value` above -- never written back to the coordinator
cache, so write_fn, diagnostics, and the observe-mode sweep
comparison keep seeing real device data throughout.
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
than the already-computed `raw`, so they can key on fields rep_fn
has collapsed away -- but they never compute a value. `raw` and
the frozen `sticky_value` are the only things returned here, so a
held entity and a free-running one agree on what "live" means; a
hook that broke that rule caused issue #358.
At most one window per `sticky_bypass_fn` cycle: arming marks the
hold spent, and only the bypass clears that. So a `sticky_fn` that
keeps matching (firmware leaving the field stuck -- the quirk
`_completion_minutes` works around) can't extend the window, and
one flapping in and out can't restart it either, before or after
expiry. Expiry alone doesn't re-open the door: without something
the calibre of "a new cycle is actually running" in between, a
second Finish is the same Finish, and re-arming on it would strobe
the entity between held and live once per window -- exactly the
repeated announcements #345 and #358 are about.
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
sticky right now" is ambiguous between "went idle, honor the hold"
and "genuinely moved on to new data". It is both the early release
and the only re-arm, so it should demand positive evidence of that
move; when unsure, letting the window run out is the cheaper
mistake.
"""
assert desc.sticky_fn is not None # native_value only calls this when set
rep = self.coordinator.resource(self._bound.href)
now = time.monotonic()
if desc.sticky_fn(rep):
if not self._sticky_spent:
self._sticky_value = (
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
)
self._sticky_until = now + desc.sticky_seconds
self._sticky_spent = True
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
self._sticky_spent = False
return raw
holding = self._sticky_until is not None and now < self._sticky_until
return self._sticky_value if holding else raw
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
that difference has been building up (this is a deadband, not a
time-based debounce).
Values like finish_time are `now() + remaining`, recomputed from
scratch every poll -- both wall-clock drift between the device's own
remaining-time updates and the device revising its own estimate mid-
cycle produce a stream of small, real changes that are individually
meaningless but each trigger a recorder/logbook entry. A cycle
ending (raw is None) or starting (cache empty) always passes through
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
assert self.coordinator.config_entry is not None
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
if (
threshold_min
and raw is not None
and self._hysteresis_value is not None
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
):
return self._hysteresis_value
self._hysteresis_value = raw
return raw
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
"""Diagnostic sensor exposing whether this device is currently
receiving push notifications (observe mode) or being polled only.
Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True
_attr_translation_key = "connection_mode"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = [MODE_OBSERVE, MODE_POLL] # noqa: RUF012 -- HA `_attr_*` convention
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_connection_mode"
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info
@property
def native_value(self) -> str:
return self.coordinator.observe_mode