Compare commits

...
Author SHA1 Message Date
Marc Billow bc21f5f8f5 Bump version to 0.22.0 2026-08-15 02:29:22 +00:00
Marc Billow ab035a94af Merge pull request #371 from mbillow/claude/pr-341-review
Normalize appliance enums for HA translations
2026-08-14 21:19:33 -05:00
Marc Billow f6fbfc1f7f Keep the drum-clean unit in code rather than the catalog
Home Assistant resolves a catalog `unit_of_measurement` against the default
language, not the user's (entity_platform re-fetches 'en' for exactly this
key), because a unit is part of the state's identity -- the recorder writes
it into statistics metadata and compares it across restarts. Localizing it
would make switching Home Assistant's language look like a unit change and
suppress the sensor's statistics.

So the six non-English entries were never read, and the English one only
restated what `unit="cycles"` already said. Same displayed unit either way;
this drops seven catalog keys that looked translatable but weren't.
2026-08-15 02:07:30 +00:00
Lukas Knoeller 75466761a1 Normalize appliance enums for HA translations
Translates status values the integration previously surfaced as raw
Samsung strings: cycle progress ('Rinse' -> "Rinsing"), diagnosis state,
buzzer volume options, and the drum-clean counter's unit. Progress and
diagnosis become enum sensors so Home Assistant looks their state up in
the catalog; progress keys come from lowercasing the device's own value
rather than a hardcoded map, so adding a language is a catalog-only
change (PR #341 review).

Rebased onto main, which has since gained the issue #345 sticky hold, and
fixed up for two problems that combination exposes:

Home Assistant refuses an enum state that isn't in the sensor's options,
which takes the entity out rather than degrading it. The sticky hold froze
progress at the device's raw 'Finish' while rep_fn had been normalized to
'finish', so every completed cycle -- the exact path #345 exists to serve
-- would have produced a rejected state.

Separately, options built from the catalog can only ever list values we
have a translation for, while this registry's rule is that an unrecognized
device value renders raw. Every progress token the shipped fixtures
advertise is covered today, but Samsung ships more devices than we have
dumps for, so the sensor platform now admits the live value into its own
options: known values translate, unknown ones display untranslated instead
of breaking the entity.

The drum-clean unit moves from a native unit to the catalog because Home
Assistant rejects an entity declaring both. Note it resolves against the
default language, so the localized unit strings are inert -- kept only
because every catalog must mirror English key for key.

Also moves the diagnosis normalizer to common.py, so dryer.py doesn't
import a private symbol from dishwasher.py to get it.
2026-08-15 01:40:56 +00:00
Marc Billow 02d009380d Merge pull request #370 from mbillow/claude/pr-365-review-4o0f94
washer: 0A/B0 cycle labels; air quality: PM device classes + statistics migration
2026-08-14 20:25:39 -05:00
Marc Billow 9f1bcad3ec Harden the statistics relabel against older Home Assistant and lost boots
Review follow-up on the v2 -> v3 migration.

`new_unit_class` only exists from HA 2025.11, but hacs.json still declares
2025.1 as the minimum. On anything in between, naming that keyword is a
TypeError raised out of async_migrate_entry, which fails the config entry
outright -- the integration would not load at all for those users. The
keyword is now feature-detected, and the relabel is wrapped so that no
recorder-side surprise can cost anyone the integration: it is a
convenience, and without it they simply get Home Assistant's own
units_changed repair, which is where they were before this existed.

The version bump also no longer happens when the recorder wasn't loaded.
That case isn't distinguishable from an install without the recorder, but
burning the one-shot migration on a boot where it merely failed to come up
would leave the statistics suppressed permanently, so the entry stays on
v2 and the next start retries.

The instance-suffix regex was wrong: discovery.instance_suffix yields
`_<n>`, so keys are `dust_1`, not `dust1`. Unreachable today because
AIR_QUALITY binds an exact href, but the comment claimed a guarantee the
pattern didn't provide and the test pinned a form that can't occur.

Also drops a stale claim in airconditioner.py that air_monitor rejects the
pm10/pm25/pm1 mapping, which is no longer true as of this branch.

Tests cover both signatures, the deferral and its retry, and a relabel
that raises. The older-HA guard is mutation-checked: removing the feature
detection fails it.
2026-08-15 01:23:33 +00:00
Marc Billow eeece4906c Type air_monitor's particulates and migrate the recorded statistics
Adding a unit to a sensor that recorded long-term statistics without one
is not cosmetic: Home Assistant raises a units_changed repair and then
*suppresses statistics generation* for that entity until a human resolves
it (sensor/recorder.py's compile path hits `continue`). Shipping the PM
device classes on their own would therefore have silently frozen the very
history the labels were meant to describe.

A v2 -> v3 config-entry migration relabels the statistics metadata first.
It rewrites only the metadata row, never the recorded values -- these
readings were always µg/m³ and only the label was missing, so nothing
needs converting, which is why this uses async_update_statistics_metadata
and not change_statistics_unit. It reads entity_ids back off the entity
registry rather than rebuilding them from descriptor keys, since a renamed
entity's statistic_id no longer follows from its key, and it is scoped by
device family: range_hood and airconditioner still declare no unit for
their identically-named sensors, so relabelling theirs would create the
exact mismatch this exists to prevent.

With the migration in place there is no longer a reason to hold the labels
back on air_monitor, so it takes them too. That board is one of the two
whose fixtures pin the grade bands the mapping rests on -- typing the
purifier and not the monitor was an inconsistency, not caution. It still
declines the shared state_class column, unchanged.

Recorder coupling is guarded: after_dependencies pulls it in when
configured, the import is local to the migration, and a setup without it
is a no-op. Freshly created entries mint v3 directly, having no history to
relabel.

Tested at both levels. The unit-level tests patch the recorder and assert
which entities are relabelled with which arguments, covering the renamed
entity, subdevice-prefixed and instanced keys, the near-miss keys
(dustbag_/dustbin_), the skipped families and the recorder-absent path.
Because a mock can only prove the call is made, not that it does what the
migration needs, a second suite drives a real in-memory recorder end to
end: statistics seeded unitless, migration run, metadata confirmed to read
µg/m³ / concentration, recorded means confirmed byte-identical, and the
units_changed issue confirmed present before and absent after.
2026-08-15 01:01:49 +00:00
Marc Billow 21af5708cb Fix PM unit codepoint and document the /sensors/vs/0 grade column
Review follow-up to PR #365, which landed the washer 0A/B0 labels and the
air-purifier PM device classes.

The three particulate units were spelled with U+00B5 MICRO SIGN. Home
Assistant's DEVICE_CLASS_UNITS holds only the U+03BC GREEK SMALL LETTER MU
spelling, so every purifier logged a per-entity "not a valid unit for the
device class" warning asking the user to file a bug against us. The two
characters render identically, and the PR's own test hardcoded the wrong
one, so the test agreed with the bug. That test now takes the expected
unit from HA's own constant, and a new registry-wide guard
(test_sensor_device_class_units.py, mirroring the SwitchDesc guard from
issue #349) checks every SensorDesc unit against HA -- these were the only
three invalid pairs among 29.

Getting this in before release matters more than usual: the recorder
writes unit_of_measurement into long-term statistics, so correcting it
afterwards would raise a "units changed" repair for anyone who had run the
released version.

Also settles what the second element of a dust reading's value[] is, which
was the open question behind issue #325's request for another dump. It is
the device's own graded air-quality level: it appears only on the fields
carrying a magnitude (Dust/FineDust/SuperFineDust/CO2) and not on
Odor/CleanLevel, which are grades already; it reads 0-2 against index 0's
0-31; and CleanLevel equals the highest per-field grade on 9 of the 11
fixtures reporting the resource. It stays unbound -- ARTIK051_TVTL grades
good air as 0 while every other family uses 1, so a shared descriptor
would need a per-family offset -- but it is what confirms the PM mapping
without relying on field names: 18 grades one step above the floor as
SuperFineDust yet sits at the floor as Dust, on two families that both
floor at 1, so the firmware itself treats the three fields as different
scales ordered coarse-to-fine. Each field's floor boundary also brackets
the Korean CAI band for its tier (PM10 at 30/31, PM2.5 at 15/16). Pinned
against the shipped fixtures in test_air_quality_grade_column.py.

air_monitor keeps its untyped sensors, but the docstring now gives the
real reason: the evidence carries over, and what is deliberately deferred
is the statistics migration for entities shipped unitless since issue #210.

Smaller fixes: en.json's "Mixed load" -> "Mixed Load" to match the
catalog's title casing and issue #363's own wording; de/ko gave B0 the
same string as the existing "34" Mixed, so a machine exposing both showed
two identical options; washer.py's shared-label list still said "'24'
Towels", which went stale when issue #343 found 24/33 transposed; and
0A/B0 now have a locale-wide translation guard like every other confirmed
code batch.
2026-08-15 00:23:53 +00:00
JayChickenK 627b761462 washer: add 0A/B0 cycle labels; purifier: PM device classes
Table_02 codes 0A (Towels) and B0 (Mixed load) were reported for a
WW90DG5G34ABLE (issue #363). Air-purifier Dust/FineDust/SuperFineDust
map to PM10/PM2.5/PM1 in µg/m³ from a same-moment SmartThings
correlation (issue #325).
2026-08-15 00:17:09 +00:00
NicolasandNicolas 3735d8b806 vacuum_station: bind VS9700 stick battery via /status/stick/vs/0 (#369)
* vacuum_station: bind VS9700 stick battery via /status/stick/vs/0

* vacuum_station: translate stick labels; drop diagnostic category

Address review: localize stick entity names in non-English files, and
keep wand status/BLE as primary entities rather than diagnostic.

---------

Co-authored-by: Nicolas <11050206+WiestDaessle@users.noreply.github.com>
2026-08-14 14:44:52 -05:00
Marc Billow 65fa80c71c Merge pull request #368 from mbillow/claude/smartthings-local-upgrade-07r0u0
Upgrade smartthings-local to 0.1.6 and adopt its typed-error interface
2026-08-14 13:58:44 -05:00
Marc Billow 1abaad7f40 Fix issues found by an Opus review of the smartthings-local upgrade
An independent review of the last two commits' diff turned up five real
problems and one CI-breaking one. Fixed all of them:

- tests/test_coordinator_error_handling.py assigned directly onto
  coordinator instance attributes (coordinator._poll_once = dict), which
  `ty check custom_components tests` -- what CI actually runs, not the
  narrower `ty check custom_components` this branch had only been
  spot-checked against -- flags as invalid-assignment. Switched to
  monkeypatch.setattr, matching every other new test on this branch.

- coordinator.py's subdevice-enumeration failure comment claimed the
  probe "retries naturally next cycle." It doesn't: _run_discovery sets
  self._discovered = True unconditionally later in the same cycle, which
  is what gates the whole block, so a failure here is a first-and-only
  attempt, not a retried one -- a composite appliance's sibling
  subdevices are missing for the config entry's lifetime until reload.
  (A separate flag to retry wouldn't actually fix that either: every
  platform's async_setup_entry enumerates coordinator.bound exactly
  once, so a later-successful enumeration still couldn't add entities
  without a reload.) Corrected the comment and raised debug to warning,
  since the effect is silent and permanent otherwise.

- config_flow.py's new diagnostic-handshake fallback (_resolve_alert)
  was being called once per failing candidate, inside _handshake_and_read's
  scan loop -- contradicting _diagnostic_alert's own docstring ("only
  runs once every real candidate has already failed"). Two real costs:
  up to CLIENTHELLO_PROBE_TIMEOUT_S extra latency per failing port on
  the sweep-fallback path (several candidates), and -- more seriously --
  the diagnostic commits DTLS association state on each port it touches,
  which can make _probe_and_validate's own CertRejected re-mint retry
  (a fresh _handshake_and_read call against that same scan) time out
  against the very port it just polluted, per the RFC 6347 §4.2.8
  concern already documented elsewhere in this file. Moved to a new
  _diagnose_failures helper called once, after the loop, against the
  single best (confirmed-live) candidate.

- _resolve_alert also ignored the alert's level: ProbeResult.alert is
  set for a received alert of either severity, but only a fatal one (2)
  means the appliance broke off the handshake over it -- a warning
  (e.g. close_notify) was being read as a rejection reason. Older
  library exception text never had this ambiguity (OpenSSL only renders
  an exception for a fatal alert), so this was a bug the redaction
  fallback introduced. Now filters to level == 2.

- async_raw_read's new HomeAssistantError wrapper reported a translated
  error but left a confirmed-dead session installed, so every
  subsequent read/write would keep failing identically for up to the
  next full poll interval. Now closes the session on any non-TimeoutError
  failure, matching _poll_once's own posture.

- async_raw_write_sequence's verify_after fallback (vcode, vrep = 0, {})
  is indistinguishable from a real 4.04 by raw_code alone. Added a
  read_error field so a caller can tell "couldn't verify" from "the
  device said no."

Tests: 8 new/extended (once-not-per-port diagnostic count, alert-level
filtering, the warning log + permanent-loss framing, session-closing on
both async_raw_read and the verify_after path, read_error surfacing).
Full suite (1527 tests), ruff, ruff format, and -- critically --
`ty check custom_components tests` (the CI-matching invocation) all pass.
2026-08-14 18:43:44 +00:00
Marc Billow 389c65adbc README: drop the reconnect-timing paragraph, keep it in code
Excessive for user-facing docs -- this is implementation detail
(_defer_reconnect_for's tolerance logic) that belongs in the
coordinator's own comments, where it still lives, not in "Known
device behavior". No functional change.
2026-08-14 18:16:32 +00:00
Marc Billow 74bdc04f56 Document the reconnect-timing change from smartthings-local's fail-fast fix
Answers a review callout on the 0.1.6 upgrade that wasn't actually
addressed, only mentioned in a commit message: a dead reader now raises
SessionClosedError (a ConnectionError) instead of hanging a request out
to its timeout and surfacing as an ambiguous TimeoutError. Mechanically
this was already routed correctly -- SessionClosedError isn't a
TimeoutError, so _defer_reconnect_for's isinstance check already skips
its multi-cycle tolerance for it -- but nothing recorded *why*, and the
callout's whole point was that downstream (i.e. this repo) should hear
about the resulting timing change explicitly, not infer it from the
dependency bump.

Before 0.1.6, a truly dead reader was indistinguishable here from a
slow blockwise transfer: both could only ever surface as a TimeoutError,
so _POLL_TIMEOUT_LIMIT's multi-cycle tolerance (~2 minutes at the
default 30s interval) was the only thing standing between a genuinely
dead session and a reconnect. Now that the library confirms reader
death directly, that failure mode skips the tolerance and reconnects on
the very first occurrence -- intended, and strictly faster recovery,
but a real change in observed timing worth calling out for anyone
correlating reconnect-log cadence with device behavior.

- _poll_once, _defer_reconnect_for, and the _POLL_TIMEOUT_LIMIT comment
  now say so directly, cross-referencing each other.
- README's "Known device behavior" section gets a paragraph so this
  isn't only visible to someone reading the coordinator's source.
- Two new unit tests pin the distinction directly:
  _defer_reconnect_for(SessionClosedError()) is False (no tolerance),
  while SessionTimeoutError keeps the existing _POLL_TIMEOUT_LIMIT
  tolerance -- so a future change can't quietly merge the two paths
  back together.

Full suite (1523 tests), ruff, and ty pass.
2026-08-14 18:15:32 +00:00
Marc Billow f949ff04c2 coordinator: close four uncaught-exception gaps around smartthings_local
A follow-up review of the 0.1.6 upgrade found four call sites where a
library exception (new typed one or the old bare ConnectionError/
TimeoutError it replaced) could escape this integration's own
reconnect/logging or a service call's translation layer entirely,
instead of being handled the way equivalent failures already are
elsewhere in this file:

- _attempt_observe_mode's own _connect_session() reconnect (fires only
  when the session was closed out from under it concurrently) had no
  try/except, and neither did either of its two call sites in
  _async_update_data. A failure there escaped uncaught: HA's
  DataUpdateCoordinator has its own final safety net so nothing crashed
  the config entry, but non-TimeoutError failures logged a full ERROR
  traceback instead of this integration's deliberately quiet "poll
  failed, reconnecting" voice, and skipped its own reconnect bookkeeping
  entirely. Fixed by catching around just the connect call (the only
  unguarded raise path in the method -- subscribe_hrefs/
  await_observe_notifies already handle their own failures), landing in
  the same "give up on push this cycle" state abandon_observe_attempt()
  already produces for the subscribe-failed and stale-session branches.
  Deliberately does not touch _close_session() (self._session is
  already None here -- _connect_session only ever publishes it after a
  full success), _reconnect_is_frequent() (that window records the poll
  path's own reconnects; feeding it a secondary path's failure would
  over-trigger its warning threshold), or _resubscribe_due (that flag
  means "a live session nothing has tried yet" -- setting it here would
  re-enter the doomed handshake every cycle instead of letting
  _last_observe_attempt_ts pace the retry).

- _enumerate_subdevices_blocking's _connect_session() call (first
  discovery only) had the same gap. Fixed the same way: log and fall
  through on the resources _poll_once already returned this cycle,
  rather than losing first discovery over a failed subdevice probe.

- async_raw_read (backing the read_resource service) had no exception
  handling at all -- a session/network failure during a live debug read
  reached the service caller as a raw, untranslated library exception,
  unlike write_resource's equivalent path. Now wrapped the same way
  async_send_command/async_raw_write_sequence already are, raising
  HomeAssistantError with a new debug_read_failed translation key
  (added to all 7 shipped locales).

- async_raw_write_sequence's verify_after tail sat outside the method's
  own try/except, so a failed confirmation read discarded the write
  results that had already landed by throwing past them. Now caught
  per-href inside the verify loop instead: a failed read is treated the
  same as a 4.04/empty one (held=None, "couldn't verify" -- not lost or
  misreported as a revert), and the rest of the batch still gets
  checked.

Design for the first fix (the trickiest -- it's mid-lock, and has to
interact correctly with observe-mode state and the poll path's own
bookkeeping without corrupting either) was worked through with a
dedicated review pass before implementing.

Tests: new coverage for all four (test_coordinator.py's
test_attempt_observe_mode_survives_a_failed_reconnect, a new
test_coordinator_error_handling.py for the subdevice-enumeration case,
and two additions to test_services.py for the read-service and
verify_after cases). Full suite (1521 tests), ruff, and ty all pass.
2026-08-14 18:09:09 +00:00
Marc Billow 3f9789512d Update to smartthings-local 0.1.6, handle redacted typed errors
Bumps the smartthings-local floor from >=0.1.2 to >=0.1.6 (manifest,
requirements-dev.txt, Dockerfile) and adopts the interface/behavior
changes introduced along the way:

- 0.1.3 ("redacted typed failures", PR #23) replaced connect()'s
  ConnectionError(f"DTLS handshake error: {e}") with fixed, redacted
  exceptions (SessionError, SessionTimeoutError, etc.) that never carry
  backend text -- including the TLS alert name. The config flow's
  _classify_handshake_failure relied on parsing that text out of the
  exception (_alert_name) to tell a rejected certificate from any other
  handshake failure; against a current library that regex never matches
  again, silently downgrading every setup failure to the generic
  "cannot_connect" message.

  Fixed by adding _resolve_alert: it still tries _alert_name first (a
  harmless fallback if it ever matches), then falls back to one bounded
  smartthings_local.protocol.dtls_probe.diagnose_dtls_handshake() call
  against the specific port that failed, which classifies the fatal
  Alert straight from the raw record instead of an exception string.
  _handshake_and_read now threads the resolved per-port alerts into
  _classify_handshake_failure, so CertRejected vs. HandshakeFailed keeps
  working the way it did before the redaction.

- 0.1.3 also moved the DTLS session onto a connected UDP socket (see
  endpoint.py's open_connected_udp_socket), which changes why
  coordinator._local_source_port needs a unique port per device -- the
  kernel now demuxes by the full local-port/remote-peer tuple instead of
  relying on an unconnected recvfrom(). Docstring updated to match.

- 0.1.6's reader-thread fail-fast fix (_check_live/_reader_running) makes
  a dead reader raise SessionClosedError immediately instead of hanging
  a request out to its timeout. No code change needed: SessionClosedError
  is a ConnectionError subclass (not TimeoutError), so the coordinator's
  existing _defer_reconnect_for/isinstance(e, TimeoutError) split already
  routes it to the immediate-reconnect path.

Every other new/changed piece (endpoint.py, dtls_probe.py bounded
probing, auth.py's CertificateAuth/PskAuth providers) stays behind
compatible built-in exception types and unchanged get()/post()/
subscribe()/ping() signatures, per the library's own compatibility
table, so the coordinator's and observe.py's broad exception handling
needed no changes.

Tests: added coverage for _resolve_alert's exception-text vs.
diagnostic-handshake fallback, _classify_handshake_failure with a
resolved alerts mapping, and an end-to-end config-flow re-mint test
against a FakeSession that raises the new redacted SessionError instead
of the old text-bearing ConnectionError.

Full suite (1517 tests), ruff, and ty all pass against smartthings-local
0.1.6 installed from PyPI.
2026-08-14 17:50:10 +00:00
Marc Billow cbe881818b test_services: widen _get_reps' value type to match queue_get
queue_get accepts dict | list since #335's Collection test started
queueing a batch list, but _get_reps was still typed list[dict] --
ty flagged the list.append() as invalid. No behavior change.
2026-08-13 03:08:20 +00:00
Marc Billow 2b85e20108 Bump version to 0.21.2 2026-08-13 02:44:15 +00:00
Marc Billow e5cd212a34 read_resource: a Collection's list body is not an empty resource (#335)
`_raw_read_blocking` decoded the CBOR body and kept it only when it was a
Property map, so a Collection -- which answers the `[devcol rep, {href,
rep}, ...]` batch `parse_device0_batch` reads -- came back as `2.05` with
`rep: {}`. That renders as "the resource exists and has nothing in it",
which is the opposite of what a populated batch means, and `/device/0`
itself would have read the same way.

It cost a real result: issue #335's board answers `/sec/devices` (the
`x.com.samsung.devcol` sibling of `/device/0`, and the one remaining place
a composite appliance could be enumerating its indoor units) with exactly
that empty-looking 2.05, and it was nearly written off as a dead end.

The read path now returns the decoded body alongside `rep`, and the service
response carries it as `body` whenever it isn't the map `rep` already has --
omitted for the ordinary case rather than duplicating every rep in every
response. Records the probe round this came out of: indexed leaves 4.04 on
that board, and the UUID prefix confirmed routable by a positive control, so
Patterns A/B/C are ruled out there on evidence rather than on absence.
2026-08-13 02:43:12 +00:00
Marc Billow 11c71a62e8 docs: where else a composite AC's sibling hrefs could live (#335)
Issue #335's board reports a sibling in subdeviceIdList and then 4.04s on
all 26 seeds enumerate_subdevices tries, which reads like "there is nothing
there". Comparing every captured /oic/res in the corpus says otherwise: only
the ARTIK051_DONGLE_FAC_18K board advertises its operational tree at all.
The other five list the onboarding surface and stop -- the range board hides
a live /device/1 behind a ten-link /oic/res -- so an href's absence from
/oic/res is not evidence, and Pattern A's index scan is dead weight
everywhere except the board it was written against.

What that leaves untried is the bare indexed leaf: every indexed href this
project has ever seen arrived inside a /device/<n> batch, and /device/1
4.04ing is evidence about the Collection, not about /mode/vs/1. Leaves
without their Collection is already confirmed BORA behavior in the other
namespace (issue #205). Records the probe list, the OCF composite-device
clause that suggests /sec/devices, a positive control for whether the UUID
prefix routes at all, and the dead ends worth not re-treading.
2026-08-13 02:43:11 +00:00
43 changed files with 2418 additions and 208 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
# repeats the install attempt on every container recreate. Baking
# smartthings-local into the image keeps the dev container usable
# offline and avoids relying on that runtime install path.
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.2"
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.6"
+1 -1
View File
@@ -159,7 +159,7 @@ data:
href: /mode/vs/0
```
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. A sixth key, `body`, appears only when the response isn't a Property map: a Collection (`/device/0`, and the `x.com.samsung.devcol` siblings some boards expose) answers a CBOR list, which `rep` can't carry, and which would otherwise read as an accepted-but-empty resource. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
+109 -2
View File
@@ -2,9 +2,15 @@
from __future__ import annotations
import inspect
import logging
import re
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as PARTICULATE_UNIT,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -12,7 +18,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
from .registry.identity import resolve_serial
from .services import async_setup_services
@@ -125,6 +131,100 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
)
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
# and a unit in the release that introduced entry version 3. Deliberately
# not every family reading /sensors/vs/0: range_hood and airconditioner
# still declare no unit for their identically-named sensors, and relabelling
# their statistics to µg/m³ would assert a unit those entities don't report
# -- creating the very mismatch this migration exists to prevent.
_PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"})
# unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor
# key, optionally carrying a subdevice prefix and a trailing `_<n>` instance
# (registry/adapter._key, discovery.instance_suffix). Matching the tail rather
# than rebuilding the whole id keeps this working for a renamed entity, whose
# entity_id -- and so its statistic_id -- no longer follows from the key.
_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)(?:_\d+)?$")
@callback
def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Point existing particulate statistics at the unit they always were.
These sensors recorded long-term statistics with no unit, and the
release carrying this migration gives them µg/m³. Home Assistant treats
that as a unit change it can't convert and *suppresses statistics
generation entirely* for the entity until someone resolves the repair
(sensor.recorder._update_issues -> UNITS_CHANGED_ISSUE, and the matching
`continue` in its compile path). Silently freezing the history we just
finished labelling is the worst of both outcomes, so the metadata is
corrected up front instead.
Only the metadata row is rewritten, never the recorded values. The
readings were always µg/m³ concentrations (issue #325); what was missing
was the label, so there is nothing to convert and no way for this to
distort history. That is also why it uses
`async_update_statistics_metadata` and not `change_statistics_unit`,
which would scale every stored value.
A no-op when this device family isn't one that gained the unit, or when
the device never recorded any statistics -- the underlying UPDATE simply
matches no rows.
Returns False only when the recorder wasn't loaded, meaning the caller
should leave the entry on its old version and try again next start.
"""
if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3:
return True
if "recorder" not in hass.config.components:
# after_dependencies orders the recorder ahead of us when it's
# configured, so this is either an install without it (nothing to
# relabel, and the retry costs one set lookup per start) or a boot
# where it failed to come up. Not distinguishable here, and burning
# the one-shot migration on the second case would leave the
# statistics suppressed for good.
_LOGGER.debug("recorder not loaded, deferring statistics relabel")
return False
from homeassistant.components.recorder.statistics import (
STATISTIC_UNIT_TO_UNIT_CONVERTER,
async_update_statistics_metadata,
)
kwargs: dict[str, Any] = {"new_unit_of_measurement": PARTICULATE_UNIT}
# `new_unit_class` only exists from HA 2025.11; hacs.json still supports
# 2025.1, where passing it is a TypeError -- which would propagate out of
# async_migrate_entry and fail the whole entry. Where it is supported it
# must be named, since omitting it is deprecated from HA 2026.11. µg/m³
# has a converter, so the value is 'concentration' rather than None.
if "new_unit_class" in inspect.signature(async_update_statistics_metadata).parameters:
converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT)
kwargs["new_unit_class"] = converter.UNIT_CLASS if converter is not None else None
ent_reg = er.async_get(hass)
for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
if registry_entry.domain != "sensor":
continue
if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id):
continue
_LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id)
try:
async_update_statistics_metadata(hass, registry_entry.entity_id, **kwargs)
except Exception:
# Relabelling is a convenience: without it the user gets Home
# Assistant's own units_changed repair, which is where they were
# before this migration existed. Never worth failing setup over,
# so no recorder-side surprise can cost them the integration.
_LOGGER.warning(
"Could not relabel statistics unit for %s; Home Assistant will "
"offer a units-changed repair for it instead",
registry_entry.entity_id,
exc_info=True,
)
return True
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an entry to the current version.
@@ -132,8 +232,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
can key its registry entries before the first poll (issue #236), and
repairs whatever the old placeholder-keyed registration already
orphaned.
v2 -> v3 relabels the recorded statistics for the particulate sensors,
which gained a device_class/unit in the same release (issue #325).
"""
if entry.version > 2:
if entry.version > 3:
return False # downgrade: this release doesn't know the newer shape
if entry.version == 1:
@@ -147,6 +250,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_repair_placeholder_keys(hass, entry, serial)
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
if entry.version == 2 and _relabel_particulate_statistics(hass, entry):
hass.config_entries.async_update_entry(entry, version=3)
_LOGGER.debug("migrated entry %s to version 3", entry.entry_id)
return True
+118 -15
View File
@@ -493,27 +493,84 @@ _CERT_ALERTS = frozenset(
}
)
# OpenSSL renders a received fatal alert into its error text as e.g.
# "tlsv1 alert unknown ca", which DtlsCoapSession.connect() wraps in a
# ConnectionError. Reading it back tells us what the appliance objected to.
#
# Deliberately not the library's diagnostic probe (stateless=False): that
# mode commits association state on the device, and an orphaned association
# makes the next attempt time out (RFC 6347 §4.2.8) -- a bad trade on a
# path the user is about to retry.
# Older smartthings-local (< 0.1.3) rendered a received fatal alert straight
# into the handshake exception's text, e.g. "tlsv1 alert unknown ca" wrapped
# in a ConnectionError -- reading it back told us what the appliance
# objected to. 0.1.3's "redacted typed failures" removed that: connect()'s
# exceptions now carry a fixed, non-sensitive message with the real OpenSSL
# text neither included nor chained (see smartthings_local.errors --
# "backend errors can contain remote endpoints, local paths, or credential
# metadata"). _alert_name is kept as a harmless fallback for exception text
# that does carry it; _resolve_alert below is what actually classifies a
# failure against a current library.
_ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)")
def _alert_name(exc: Exception) -> str | None:
"""The TLS alert an appliance sent, if this failure carried one."""
"""The TLS alert an appliance sent, if this failure's exception text
carried one (only ever true against smartthings-local < 0.1.3)."""
match = _ALERT_RE.search(str(exc).lower())
return match.group(1).strip().replace(" ", "_") if match else None
def _diagnostic_alert(host: str, port: int, cert_pem: str, key_pem: str):
"""One opt-in stateful handshake against `port`, using our real
credentials, so a fatal Alert can be classified from the raw record
itself rather than parsed out of an exception's text.
This is the library's diagnose_dtls_handshake -- deliberately not used
for the primary candidate scan (it commits association state on the
device, and an orphaned association makes the *next* attempt time out
per RFC 6347 §4.2.8). Here it only runs once every real candidate has
already failed, to explain a failure that's happening either way --
one more orphaned association is a fair trade for a message that says
why, on a path the user is about to retry regardless.
Imported lazily, like `_clienthello_probe`, so an install whose
smartthings-local predates this API degrades to a generic message
instead of failing to load the config flow at all.
"""
from smartthings_local.protocol.dtls_probe import diagnose_dtls_handshake
return diagnose_dtls_handshake(
host,
port,
cert_pem=cert_pem,
key_pem=key_pem,
timeout=CLIENTHELLO_PROBE_TIMEOUT_S,
retries=CLIENTHELLO_PROBE_RETRIES,
)
def _resolve_alert(exc: Exception, host: str, port: int, cert_pem: str, key_pem: str) -> str | None:
"""The TLS alert `port`'s failed handshake carried, if any -- the
exception's own text first (cheap, and all an older library ever
offers), then one bounded diagnostic handshake against a current one
that redacts it (see _diagnostic_alert)."""
name = _alert_name(exc)
if name is not None:
return name
try:
result = _diagnostic_alert(host, port, cert_pem, key_pem)
except Exception:
return None
if result.alert is None:
return None
level, name = result.alert
# ProbeResult.alert is set for a *received* alert record of either
# level -- fatal (2) means the appliance actually broke off the
# handshake over it; a warning (1, e.g. close_notify on an otherwise
# ordinary close) is not evidence of a rejection and must not be read
# as one. The old exception-text path never had this ambiguity: an
# OpenSSL exception only ever rendered for a fatal alert.
return name if level == 2 else None
def _classify_handshake_failure(
host: str,
scan: _PortScan,
failures: list[tuple[int, Exception]],
alerts: dict[int, str] | None = None,
) -> CannotConnect:
"""Turn "no port worked" into the most specific thing we can honestly
say, in rough order of how much the evidence tells us: an alert means
@@ -521,13 +578,21 @@ def _classify_handshake_failure(
certificate); a confirmed DTLS port that then timed out is likely still
holding a session from a previous attempt; otherwise the sweep's own
shape is the evidence.
`alerts` is the per-port classification `_handshake_and_read` already
resolved (exception text, or a diagnostic handshake -- see
_resolve_alert); a caller with only raw failures (or an older library)
still gets `_alert_name`'s exception-text reading as a fallback.
"""
alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name]
cert_alerts = [name for name in alerts if name in _CERT_ALERTS]
resolved = dict(alerts or {})
for port, exc in failures:
resolved.setdefault(port, _alert_name(exc))
alert_names = [name for name in resolved.values() if name]
cert_alerts = [name for name in alert_names if name in _CERT_ALERTS]
if cert_alerts:
return CertRejected(f"{host} rejected our certificate (alert {cert_alerts[0]})")
if alerts:
return HandshakeFailed(f"{host} refused the DTLS handshake (alert {alerts[0]})")
if alert_names:
return HandshakeFailed(f"{host} refused the DTLS handshake (alert {alert_names[0]})")
if scan.confirmed:
return HandshakeTimeout(
f"DTLS server confirmed on {host}:{scan.confirmed} but the handshake never completed"
@@ -626,6 +691,40 @@ def _read_device(sess, host: str, port: int) -> dict:
}
def _diagnose_failures(
host: str,
scan: _PortScan,
failures: list[tuple[int, Exception]],
cert_pem: str,
key_pem: str,
) -> dict[int, str]:
"""At most one diagnostic handshake (see _diagnostic_alert) across every
port `_handshake_and_read` just gave up on -- not one per port.
Called only after that loop has fully exhausted `scan.candidates`, never
interleaved with it: `_diagnostic_alert`'s own docstring says the extra
orphaned association it costs is a fair trade "on a path the user is
about to retry regardless" -- true for the retry `_probe_and_validate`
itself makes on a CertRejected (a fresh `_handshake_and_read` call
against this same `scan`), but only if that retry's real handshake
attempts are the ones landing on a clean slate. Running the diagnostic
per candidate mid-loop would pollute exactly the port(s) that retry is
about to reattempt; running several of them multiplies both the latency
(each is its own bounded handshake) and the pollution for no extra
classification value, since _classify_handshake_failure only ever needs
one alert to decide.
Targets a confirmed-live port over an unconfirmed sweep candidate --
the one actually worth spending the extra handshake on.
"""
if not failures:
return {}
by_port = dict(failures)
port = next((p for p in scan.confirmed if p in by_port), next(iter(by_port)))
alert = _resolve_alert(by_port[port], host, port, cert_pem, key_pem)
return {port: alert} if alert is not None else {}
def _handshake_and_read(host: str, scan: _PortScan, cert_pem: str, key_pem: str) -> dict:
"""Handshake each candidate in turn, returning the first device that answers."""
from smartthings_local.protocol.dtls_session import DtlsCoapSession
@@ -649,7 +748,8 @@ def _handshake_and_read(host: str, scan: _PortScan, cert_pem: str, key_pem: str)
if sess is not None:
with contextlib.suppress(Exception):
sess.close()
raise _classify_handshake_failure(host, scan, failures)
alerts = _diagnose_failures(host, scan, failures, cert_pem, key_pem)
raise _classify_handshake_failure(host, scan, failures, alerts)
def _probe_and_validate(
@@ -693,7 +793,10 @@ def _probe_and_validate(
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 2
# v3 relabels the particulate sensors' recorded statistics; a freshly
# created entry has none to relabel, so it starts at the migrated
# version rather than walking through v2 (see async_migrate_entry).
VERSION = 3
def __init__(self) -> None:
self._host: str = ""
+164 -21
View File
@@ -103,10 +103,15 @@ def _local_source_port(host: str) -> int:
time per RFC 6347 §4.2.8, instead of holding it 5-15 min. See
DTLS_LOCAL_PORT_BASE. Requires smartthings-local >= 0.1.1.
Must stay unique per device on this host too: the library's socket is
unconnected, so two devices sharing a port would mis-demux each other's
datagrams. Last IPv4 octet as offset for the common case; a stable
CRC32 fold otherwise.
Must stay unique per device on this host too. That used to be load-
bearing for demuxing: an unconnected socket handed every device's
datagrams to whichever recvfrom() happened to be listening on their
shared port. smartthings-local >= 0.1.3 connect()s its UDP socket
instead (see endpoint.py's open_connected_udp_socket), so the kernel
already filters incoming datagrams to each session's own resolved peer
-- but a distinct port per device keeps that guarantee from ever
depending on it, and keeps captures/logs unambiguous. Last IPv4 octet
as offset for the common case; a stable CRC32 fold otherwise.
"""
try:
offset = int(ipaddress.IPv4Address(host)) & 0xFF
@@ -203,7 +208,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# A block-level ACK timeout on the summary GET doesn't prove the session
# is dead (see _poll_once) -- require this many in a row before treating
# it as one, so one slow transfer doesn't tear down a working OBSERVE
# subscription.
# subscription. Only covers that ambiguous case: smartthings-local
# >= 0.1.6 raises a distinct SessionClosedError, not a TimeoutError, the
# moment a dead reader thread is confirmed, and _defer_reconnect_for
# never defers that -- see its docstring for what changed there.
_POLL_TIMEOUT_LIMIT: int = 3
# Named (not inline literals) so the write-settle window in
@@ -530,7 +538,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
single missed read is not worth surfacing.
"""
try:
code, rep = await self.async_raw_read(cloudcourse.COURSE_HREF)
code, rep, _body = await self.async_raw_read(cloudcourse.COURSE_HREF)
except Exception:
# One missed probe; the caller is a retry loop.
self._log.debug("cloud-course probe failed", exc_info=True)
@@ -717,6 +725,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
not that the session is dead (earlier blocks succeeded). Left open;
`_async_update_data` decides whether repeated timeouts warrant a
reconnect. Any other exception is unambiguous -- close immediately.
smartthings-local >= 0.1.6 tells those two cases apart itself now:
a reader thread that has actually died raises `SessionClosedError`
(a ConnectionError, not a TimeoutError) the moment the next request
notices, instead of the old behavior of quietly hanging out to this
call's own timeout and surfacing as an ambiguous `TimeoutError`.
See `_defer_reconnect_for` for what that changes about how soon a
confirmed-dead session gets reconnected.
"""
if self._session is None:
self._connect_session()
@@ -1164,7 +1180,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._session is None:
# _poll_once already connects on a real poll; only fires if
# the session was closed out from under us concurrently.
await self.hass.async_add_executor_job(self._connect_session)
try:
await self.hass.async_add_executor_job(self._connect_session)
except Exception as e:
# Not a poll failure -- the poll that reached this line
# already succeeded, and observe mode is only an
# optimization on top of it. Give up on push this cycle
# the same way the two branches below do, rather than
# letting this escape _async_update_data uncaught: none
# of this integration's reconnect bookkeeping would run,
# and the base coordinator logs an ERROR traceback in
# place of the deliberately quiet "poll failed,
# reconnecting" voice used everywhere else in this file.
#
# Not counted by _reconnect_is_frequent() (that window
# records reconnects the poll path itself performed --
# feeding it a secondary path's failure would push the
# next routine poll reconnect over the warn threshold),
# and nothing to _close_session(): _connect_session only
# publishes self._session once connect() and
# start_reader() have both already succeeded, so it's
# still None here. No _resubscribe_due either -- that
# flag means "a live session nothing has tried yet",
# and setting it would re-enter this doomed handshake
# every cycle; _last_observe_attempt_ts (stamped above)
# already paces the retry to _RECOVERY_RETRY_S.
self._log.info(
"observe-mode reconnect failed (%s), staying on polling: %s",
type(e).__name__,
e,
)
self._observe.abandon_observe_attempt()
return
sess = self._session
if sess is None:
return
@@ -1220,6 +1267,19 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
`_POLL_TIMEOUT_LIMIT` consecutive timeouts pile up. Any other
exception reconnects immediately.
That includes `SessionClosedError`, which is the point: before
smartthings-local 0.1.6, a reader thread that had actually died was
indistinguishable from a slow transfer -- both surfaced here only as
a `TimeoutError`, so this tolerance was the only thing standing
between a truly dead session and a reconnect, worst case about
`_POLL_TIMEOUT_LIMIT` poll cycles (~2 minutes at the default 30s
interval). 0.1.6 confirms reader death directly and raises a
ConnectionError subclass for it instead, which isn't a TimeoutError
and so skips this tolerance entirely -- a genuinely dead session now
reconnects on the very first occurrence. Intended (see this repo's
README, "Known device behavior"), not a regression, but a real
change in observed reconnect timing for that one failure mode.
Never defers before first discovery (issue #254): deferring returns
an empty dict, which the base coordinator treats as a successful
first refresh -- and since platforms enumerate `bound` once, the
@@ -1322,9 +1382,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# cycle's snapshot so discovery sees every subdevice on the
# first poll rather than waiting a cycle.
async with self._session_lock:
resources = await self.hass.async_add_executor_job(
self._enumerate_subdevices_blocking, resources
)
try:
resources = await self.hass.async_add_executor_job(
self._enumerate_subdevices_blocking, resources
)
except Exception as e:
# _connect_session() inside here only runs at all if the
# session the poll above just used got closed out from
# under us within this same cycle -- rare, but not
# impossible, and unguarded before this. Losing the
# subdevice probe isn't losing first discovery: `resources`
# keeps the value _poll_once already returned, so
# discovery below still runs on the master's own data,
# same posture _poll_subdevice_seed takes for one sibling
# going quiet.
#
# Not a one-cycle blip, though: `_run_discovery` a few
# lines below sets `self._discovered = True`
# unconditionally this same cycle, which is what gates
# this whole block -- there is no next cycle where this
# is retried. A composite appliance whose enumeration
# fails here loses its sibling subdevices' entities for
# this config entry's lifetime (a reload probes again).
# warning, not debug, because of that: it's silent and
# permanent otherwise, with nothing in the log pointing
# at why a device is missing entities it should have.
self._log.warning(
"subdevice enumeration failed on first discovery; "
"any sibling subdevices will be missing until this "
"config entry is reloaded: %s",
e,
)
source = "sweep" if self._discovered else "poll"
first_cycle = not self._discovered
@@ -1576,12 +1664,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.debug("raw write follow-up read failed: %s", e)
return code, new_rep
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict]:
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict, Any]:
"""Debug primitive: a live GET, deliberately bypassing the cache
(issue #300) -- the cache can be up to a poll interval stale,
exactly the staleness that makes testing whether a write held or
got silently reverted by the board unreliable. Blocking -- runs in
executor."""
executor.
Returns `(code, rep, body)`. `rep` is the decoded body only when it
is a Property map, since that's the shape the observe cache and
every capability are written against. `body` is whatever CBOR
actually decoded to, and exists because a Collection answers a
*list*, not a map: `/device/0` and its `x.com.samsung.devcol`
siblings return the `[devcol rep, {href, rep}, ...]` batch
`parse_device0_batch` reads. Reporting only `rep` rendered those as
an accepted-but-empty `2.05 {}`, which reads as "the resource is
there and has nothing in it" -- the opposite of what a full batch
means, and how issue #335's `/sec/devices` was nearly written off.
"""
if self._session is None:
self._connect_session()
sess = self._session
@@ -1589,6 +1689,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise RuntimeError("no session")
code, payload = sess.get(path_segs, timeout=10.0)
rep: dict = {}
body: Any = None
if code == 0x45 and payload:
try:
body = cbor2.loads(payload)
@@ -1598,11 +1699,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if isinstance(body, dict):
self._observe.apply(href, body, source="poll")
rep = body
return code, rep
return code, rep, body
async def async_raw_read(self, href: str) -> tuple[int, dict]:
async def async_raw_read(self, href: str) -> tuple[int, dict, Any]:
"""Debug-only live GET (issue #300, backs the read_resource
service). Same href validation as async_raw_write."""
service). Same href validation as async_raw_write. Three-tuple --
see `_raw_read_blocking` for why the raw body comes back alongside
the Property-map `rep`."""
path_segs = _href_to_path_segs(href)
if not path_segs:
raise ServiceValidationError(
@@ -1611,9 +1714,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
norm_href = "/" + "/".join(path_segs)
async with self._session_lock:
return await self.hass.async_add_executor_job(
self._raw_read_blocking, path_segs, norm_href
)
try:
return await self.hass.async_add_executor_job(
self._raw_read_blocking, path_segs, norm_href
)
except Exception as e:
# Unlike async_raw_write_sequence, there's nothing to
# reconnect-and-retry here -- a live debug read either lands
# or it doesn't, and a service call is the one place on this
# path a raw session exception would otherwise reach a user
# untranslated (write_resource already goes through
# HomeAssistantError; this brings read_resource in line).
if not isinstance(e, TimeoutError):
# Same TimeoutError-vs-anything-else split as
# _poll_once: a block-ACK timeout alone doesn't prove
# the session is dead, but anything else does -- and
# leaving a confirmed-dead one installed would fail
# every read/write identically until the next real
# poll cycle's own reconnect notices.
await self.hass.async_add_executor_job(self._close_session)
self._log.warning("debug read failed for %s: %s", norm_href, e)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="debug_read_failed",
translation_placeholders={"href": norm_href, "error": str(e)},
) from e
async def async_raw_write_sequence(
self,
@@ -1721,9 +1846,26 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
verified: dict[str, Any] = {}
async with self._session_lock:
for href in dict.fromkeys(r["href"] for r in results):
vcode, vrep = await self.hass.async_add_executor_job(
self._raw_read_blocking, _href_to_path_segs(href), href
)
read_error: str | None = None
try:
vcode, vrep, _vbody = await self.hass.async_add_executor_job(
self._raw_read_blocking, _href_to_path_segs(href), href
)
except Exception as e:
# The write already landed -- see `results` above,
# built before this wait ever started. A failed
# confirmation read (the session dying in the gap
# verify_after just waited out, say) must not lose
# that outcome behind a raised exception here, and
# one href's failure shouldn't stop the rest of the
# batch from being checked. Same "couldn't verify"
# posture as a 4.04/empty read below: held stays
# None, not False -- but raw_code 0 alone is also
# what a 4.04 produces, so read_error is what tells
# the two apart for a caller inspecting the response.
self._log.debug("raw write verification read failed for %s: %s", href, e)
vcode, vrep = 0, {}
read_error = str(e)
# None, not False, when the re-read brought back nothing
# to compare: every comparison against an empty rep is
# False, which would report a 4.04 as a revert -- the one
@@ -1738,6 +1880,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if read_ok
else None
),
"read_error": read_error,
}
response["verified"] = verified
+3 -2
View File
@@ -1,6 +1,7 @@
{
"domain": "localthings",
"name": "LocalThings",
"after_dependencies": ["recorder"],
"codeowners": ["@mbillow"],
"config_flow": true,
"dependencies": [],
@@ -10,7 +11,7 @@
"requirements": [
"cbor2>=5.4.6",
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.2"
"smartthings-local>=0.1.6"
],
"version": "0.21.1"
"version": "0.22.0"
}
@@ -1,9 +1,7 @@
"""Stick-vacuum clean/auto-empty station device registry (issue #131).
"""Stick-vacuum clean/auto-empty station device registry (issues #131 / #219).
See capabilities/vacuum_station.py's module docstring for why this only
covers the station's own dustbag/dustbin/UV-sanitize state and not any
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
dump this was built from reports none of that.
Station dustbag/dustbin/UV-sanitize state plus, when present (VS9700),
wand battery/charging via `/status/stick/vs/0`. No suction/room-map control.
"""
from ..capabilities import common, ignored, vacuum_station
@@ -20,6 +18,7 @@ REGISTRY = DeviceRegistry(
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
vacuum_station.STICK_BODY,
]
),
)
@@ -10,18 +10,25 @@ the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board confirms what its scale means -- left unbound rather than guessed;
index 0 is the only slot any family has ever read.
A second `value` list element on the particulate-matter types (Dust's
`['31', '2']`) is the device's own graded air-quality level for that
reading -- see common.sensor_item_value. Still unbound here: the grade's
floor differs by board family, and CleanLevel already carries the
aggregate. This board's own readings are load-bearing evidence for the
PM mapping, though: 23 grading one step above the floor as FineDust is
what rules out a PM10-width band for that field.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate
values: Samsung's own two-tier Korean convention maps only to a PM10/PM2.5
pair, and this board's three-tier naming doesn't confirm where the extra
tier or a PM1 reading fits. A wrong guess would silently mislabel every
reading forever, so they're plain `measurement` sensors named after the
device's own field instead, matching air_purifier.AIR_QUALITY's precedent.
Dust/FineDust/SuperFineDust carry the same HA `device_class`/`unit` as the
purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 /
SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this
board shares rather than on anything purifier-specific, so typing one
family and not the other would have been an inconsistency, not caution.
These sensors have recorded *unitless* long-term statistics since issue
#210, though, and Home Assistant suppresses statistics generation outright
for an entity whose unit no longer matches its recorded metadata -- so
stamping a unit on would have silently stopped the history it was meant to
label. __init__.py's v2->v3 entry migration relabels that metadata first.
"""
from datetime import time as dt_time
@@ -31,12 +38,15 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
# _AIR_QUALITY_SENSORS' fourth column (state_class) is deliberately discarded
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
# device_class/unit are taken from the shared rows; state_class deliberately
# is not. air_purifier leaves Odor/CleanLevel unstamped because they read as
# graded indices on that family, while this board has stamped all five as
# `measurement` since it was added (issue #210). Consuming the column would
# silently drop long-term statistics for two sensors on shipped devices, so
# the shared rows supply only the key/icon/type here.
# `measurement` since it was added (issue #210) -- consuming that column
# would silently drop long-term statistics for two sensors on shipped
# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on
# device-side grading this board shares (see the module docstring), and
# __init__.py's v2->v3 entry migration relabels the unitless statistics
# these five have been recording so the new unit doesn't suppress them.
SENSORS = Capability(
href="/sensors/vs/0",
poll_tier="warm",
@@ -47,9 +57,11 @@ SENSORS = Capability(
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement",
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, _ in _AIR_QUALITY_SENSORS
for key, icon, sensor_type, _state_class, device_class, unit in _AIR_QUALITY_SENSORS
),
SensorDesc(
key="co2",
@@ -51,13 +51,13 @@ def _has_top_level_modes(rep, resources):
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
# The fourth column is state_class, which is what makes Home Assistant keep
# long-term statistics for a sensor -- without one, a reading is only in the
# short-term recorder history and disappears with the next purge (10 days by
# default), so it can't back a long-range air-quality graph. The values are
# already numeric (sensor_item_value returns int), so nothing else was in the
# way; three sensors in this same module (filter_progress, fan_speed_level,
# hepa_filter_usage) already declare one.
# Columns: key, icon, device item type, state_class, device_class, unit.
# state_class is what makes Home Assistant keep long-term statistics --
# without one, a reading is only in the short-term recorder history and
# disappears with the next purge (10 days by default), so it can't back a
# long-range air-quality graph. The values are already numeric
# (sensor_item_value returns int); three sensors in this same module
# (filter_progress, fan_speed_level, hepa_filter_usage) already declare one.
#
# Only the three particulate readings get it. They fall monotonically with
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
@@ -67,15 +67,40 @@ def _has_top_level_modes(rep, resources):
# indices instead, where the mean of a grade isn't obviously meaningful; left
# without a state_class rather than guessing.
#
# Deliberately no device_class/unit here: pm1/pm25/pm10 would assert the
# reading is a µg/m³ concentration, and the dumps never say so. That's a
# separate call from making the series recordable at all.
# device_class/unit: Dust=PM10, FineDust=PM2.5, SuperFineDust=PM1, all
# μg/m³ (issue #325). Three independent lines, none of them naming order --
# which is what the earlier "plausible but unconfirmed" note rejected:
#
# 1. The device grades its own readings. Each dust item's value[] is
# [concentration, grade] (see common.sensor_item_value); the grade band
# is not shared across the three fields -- a reading of 18 grades one
# step *above* the floor as SuperFineDust (air_monitor fixture) but *at*
# the floor as Dust (range_hood fixture), both 1-based families. So the
# firmware itself treats them as three different scales ordered
# coarse-to-fine, rather than one repeated measurement.
# 2. Where each field's floor/second-band boundary falls brackets the
# Korean CAI bands: Dust good at 18, graded up at 31 (CAI PM10 breaks
# at 30/31); FineDust good at 14, graded up at 23 (CAI PM2.5 breaks at
# 15/16); SuperFineDust good at 9, graded up at 18 (PM2.5-style, which
# is what a PM1 reading gets -- there is no standard PM1 index).
# 3. A live ARTIK051_TVTL read against the SmartThings app at the same
# moment: Dust matched the app's PM10 exactly, the other two were 1
# μg/m³ off in the same order, and the app shows exactly these three
# tiers, so there is no fourth candidate to assign.
#
# Dust >= FineDust >= SuperFineDust holds on all 11 fixtures that report
# this resource, which is the cumulative-mass ordering PM10 >= PM2.5 >= PM1
# requires by definition. The unit literal must stay HA's own spelling of
# μg/m³ (U+03BC GREEK SMALL LETTER MU, not U+00B5 MICRO SIGN) -- they render
# alike but only U+03BC is in DEVICE_CLASS_UNITS, and the mismatch is a
# runtime warning per entity, not a test failure. Pinned by
# tests/test_sensor_device_class_units.py.
_AIR_QUALITY_SENSORS = (
("dust", "mdi:blur", "Dust", "measurement"),
("fine_dust", "mdi:blur", "FineDust", "measurement"),
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement"),
("odor", "mdi:scent", "Odor", None),
("clean_level", "mdi:air-filter", "CleanLevel", None),
("dust", "mdi:blur", "Dust", "measurement", "pm10", "μg/m³"),
("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "μg/m³"),
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "μg/m³"),
("odor", "mdi:scent", "Odor", None, None, None),
("clean_level", "mdi:air-filter", "CleanLevel", None, None, None),
)
AIR_QUALITY = Capability(
@@ -87,9 +112,11 @@ AIR_QUALITY = Capability(
field="x.com.samsung.da.items",
icon=icon,
state_class=state_class,
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
),
)
@@ -101,8 +101,15 @@ def _threshold_write(payload, rep, href=None):
def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given
x.com.samsung.da.type. Dust/FineDust/SuperFineDust report a 2-element
array; only v[0] is used, since the second element's meaning is
unconfirmed. No device_class is set: the resource exposes no unit."""
array; only v[0] is used. v[1] is the device's own graded air-quality
level for that reading, left unbound because its floor differs by
family -- see common.sensor_item_value for the full note.
No device_class here: unlike air_purifier (issue #325), no AC family
has had its dust readings correlated against the app, and every AC
fixture reports permanent zeros or ties, so this file's own dumps
supply no grade-band evidence either. Returns a string rather than an
int, which these diagnostic entities have always done."""
for it in items or []:
if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_:
v = it.get("x.com.samsung.da.value")
@@ -1551,12 +1558,10 @@ AIR_QUALITY = Capability(
# CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC
# families don't report. Same field/shape air_monitor.SENSORS
# already models with device_class='carbon_dioxide'/unit='ppm', so
# this matches that descriptor rather than guessing fresh -- unlike
# the pm10/pm25/pm1 mapping air_monitor.py's own docstring
# deliberately rejects for the three dust-type keys above (Samsung's
# two-tier PM10/PM2.5 convention doesn't confirm where a third tier
# or PM1 fits), ppm for a field literally named CO2 isn't a guess of
# that kind.
# this matches that descriptor rather than guessing fresh. The dust
# keys above stay untyped for the reason in _sensor_item_value: the
# pm10/pm25/pm1 mapping is confirmed for the purifier and monitor
# families (issue #325), but no AC family has evidence of its own.
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
@@ -249,12 +249,42 @@ def _power_sensor_exists(rep, resources):
return not model_allows_power_on_off(resources)
def diagnosis_status(value):
"""'Ready' -> the catalog's 'ready'; anything else is left raw.
Shared by dishwasher and dryer, which report the same field.
"""
return "ready" if value == "Ready" else value
def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot to read
(index 0 is the raw measurement on every family seen so far). Shared
by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and
air_monitor.SENSORS, which all read the same resource shape."""
air_monitor.SENSORS, which all read the same resource shape.
value[] is 2-element on the fields that carry a magnitude
(Dust/FineDust/SuperFineDust/CO2) and 1-element on Odor/CleanLevel.
That asymmetry is what index 1 means: it is the device's own graded
air-quality level for that reading -- the same kind of value Odor and
CleanLevel already *are*, which is why those two have no second slot.
It reads 0-2 against index 0's observed 0-31, tracks index 0 within a
device, and CleanLevel equals the highest per-field grade on 9 of the
11 fixtures reporting this resource (the range hood and one RAC report
a higher CleanLevel than any dust grade, so they fold in something
else).
Index 1 is deliberately left unbound rather than exposed as an entity:
its floor is not portable. ARTIK051_TVTL grades good air as 0, while
AVT-WW-TP1 / A-VTWW-TP2 / TP1X / ASM-KR-TP1 / AHD-WW-TP1 all grade it
as 1, so a shared descriptor would need a per-family offset to mean
anything, and CleanLevel already carries the aggregate. The grade is
still load-bearing as *evidence*: it is what confirms the three dust
fields are three different scales rather than one repeated reading --
see air_purifier._AIR_QUALITY_SENSORS and
tests/test_air_quality_grade_column.py.
"""
for item in items or ():
if not isinstance(item, dict):
continue
@@ -9,6 +9,7 @@ wash, auto release dry) are read locally here.
from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import diagnosis_status
from .laundry import bool_option_switch, cycle_select
# ---------------------------------------------------------------------------
@@ -76,6 +77,9 @@ DIAGNOSIS = Capability(
field="x.com.samsung.da.diagnosisStart",
icon="mdi:stethoscope",
entity_category="diagnostic",
device_class="enum",
options=("ready",),
value_fn=diagnosis_status,
),
ButtonDesc(
key="diagnosis_start",
@@ -11,6 +11,7 @@ the /course/vs/0 cycle select -- lives in laundry.py.
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from .common import diagnosis_status
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
@@ -91,7 +92,12 @@ DRYER_DIAGNOSIS = Capability(
poll_tier="warm",
entities=(
SensorDesc(
key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic"
key="diagnosis",
field="x.com.samsung.da.diagnosisStart",
entity_category="diagnostic",
device_class="enum",
options=("ready",),
value_fn=diagnosis_status,
),
),
)
@@ -6,6 +6,7 @@ Shared by dryer/dishwasher/oven/washer families.
import math
from datetime import UTC, datetime, timedelta
from ...catalog import translated_states
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
@@ -25,7 +26,7 @@ def _to_ocf(v):
def _progress(v):
return "Idle" if v in (None, "None") else v
return "idle" if v in (None, "None") else str(v).lower()
def _int(v):
@@ -201,13 +202,17 @@ OPERATIONAL_STATE = Capability(
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
device_class="enum",
options=tuple(sorted(translated_states("sensor", "progress"))),
rep_fn=lambda rep: (
"Idle"
"idle"
if not _state_is_active(rep)
else _progress(rep.get("x.com.samsung.da.progress"))
),
sticky_fn=_just_finished,
sticky_value_fn=lambda rep: "Finish",
# The catalog key, not the device's 'Finish': rep_fn is normalized
# now, and a held value outside `options` is what HA rejects.
sticky_value_fn=lambda rep: "finish",
sticky_bypass_fn=_new_cycle_running,
),
SensorDesc(
@@ -1,19 +1,14 @@
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131).
(models A-VSKR-TP1-22-VS9500AL / A-VSWW-TP1-23-VS9700, issues #131 / #219).
The reporter's diagnostics dump shows no vacuum-body state at all (no
suction level, no battery, no cleaning-mode/room-mapping control, no
docked/undocked status even) -- only the clean station's own dustbag,
dustbin auto-empty settings, and UV-C sanitizing-cycle status. This
strongly suggests the WiFi/DTLS module lives in the station, not the
handheld stick: the station is the only "device" this integration's local
API can see, and the wand's own controls are unrelated hardware not
reachable this way. Modeled as its own device type -- these hrefs don't
overlap with any existing family, so there's no shared-href ambiguity to
resolve against another type (see registry/by_type/__init__.py's
docstring for that rule).
The WiFi/DTLS module lives in the clean station. Older VS9500 dumps
(#131) exposed only dustbag/dustbin/UV-C station state. VS9700 dumps
(#219) additionally expose `/status/stick/vs/0` with the wand's battery
%, cleaning/charging status, and BLE link -- still no suction/room-map
control. Modeled as its own device type -- these hrefs don't overlap with
any existing family (see registry/by_type/__init__.py's docstring).
Resources verified against the issue #131 diagnostics dump.
Resources verified against issue #131 and #219 diagnostics dumps.
"""
from ..capability import Capability
@@ -156,3 +151,43 @@ CLEANSTATION_STATUS = Capability(
),
),
)
# Wand body state reported through the station (VS9700 / issue #219). Absent
# on the older VS9500 dump (#131) -- discovery drops entities when the href
# is missing.
STICK_BODY = Capability(
href="/status/stick/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="battery",
field="x.com.samsung.da.stickbattery",
device_class="battery",
state_class="measurement",
unit="%",
value_fn=int_or_none,
),
BinarySensorDesc(
key="battery_charging",
field="x.com.samsung.da.stickcleaningstatus",
device_class="battery_charging",
value_fn=lambda v: v == "Charging",
),
SensorDesc(
key="stick_cleaning_status",
field="x.com.samsung.da.stickcleaningstatus",
icon="mdi:vacuum",
),
SensorDesc(
key="stick_operation_mode",
field="x.com.samsung.da.stickoperationmode",
icon="mdi:broom",
),
BinarySensorDesc(
key="stick_ble_connected",
field="x.com.samsung.da.stickbleconnection",
device_class="connectivity",
value_fn=lambda v: v == "On",
),
),
)
@@ -39,9 +39,12 @@ from .laundry import (
# editCourseList and screenshots (issue #22, a combo's own course set, not
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
# code after selecting each cycle on the appliance (issue #80). Two code
# pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels)
# legitimately share a label across different course tables -- not typos.
# code after selecting each cycle on the appliance (issue #80). 2 more
# ('0A' Towels, 'B0' Mixed Load) reported for a WW90DG5G34ABLE on the same
# Table_02 family (issue #363). Several codes legitimately share a label
# across different course tables -- '21'/'65' Colors, '27'/'5E'/'78'
# Rinse+Spin, '0A'/'33'/'54'/'70' Towels -- not typos. (This list said
# "'24' Towels" until issue #343 found 24/33 transposed; 24 is Bedding.)
#
# No static fallback list is kept here: other models have different actual
# course sets, so hardcoding one device's list would show/hide the wrong
+19 -2
View File
@@ -49,8 +49,8 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
SensorDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
if desc.options:
self._attr_options = list(desc.options)
# 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
@@ -63,6 +63,23 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
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)
+8 -1
View File
@@ -179,7 +179,7 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
# Same normalize-before-translate order as the write path above.
canonical = normalize_href(href)
actual_href = subdevice.to_actual(canonical)
code, rep = await coordinator.async_raw_read(actual_href)
code, rep, body = await coordinator.async_raw_read(actual_href)
read_result: dict[str, Any] = {
"href": canonical,
"actual_href": actual_href,
@@ -187,6 +187,13 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
"raw_code": code,
"rep": rep,
}
# `body` only when it isn't the Property map already in `rep` -- a
# Collection (`/device/0`, `/sec/devices`) answers a CBOR list, which
# `rep` can't carry and which used to vanish into an empty-looking
# 2.05 (issue #335). Omitted for the ordinary map case rather than
# duplicating every rep in every response.
if body is not None and not isinstance(body, dict):
read_result["body"] = body
return cast(ServiceResponse, read_result)
@@ -108,6 +108,9 @@
},
"softener_low": {
"name": "Málo aviváže"
},
"stick_ble_connected": {
"name": "Tyč připojena přes BLE"
}
},
"button": {
@@ -258,7 +261,11 @@
"name": "Zvuk bzučáku",
"state": {
"off": "Vypnuto",
"on": "Zapnuto"
"on": "Zapnuto",
"volume_off": "Vypnuto",
"volume_low": "Nízká",
"volume_med": "Střední",
"volume_high": "Vysoká"
}
},
"discharging_time": {
@@ -643,6 +650,7 @@
"04": "Rychlé praní",
"06": "XXL prádlo",
"08": "Máchání+odstřeďování",
"0a": "Ručníky",
"17": "Stažený program",
"1b": "Bavlna",
"1c": "Eco 40-60",
@@ -709,6 +717,7 @@
"8f": "Intenzivní studená",
"96": "Méně mikrovláken",
"a0": "15min rychlé praní",
"b0": "Smíšená náplň",
"35": "Eko bavlna"
}
},
@@ -872,6 +881,12 @@
"stick_status": {
"name": "Stav tyče"
},
"stick_operation_mode": {
"name": "Provozní režim tyče"
},
"stick_cleaning_status": {
"name": "Stav čištění tyče"
},
"uvc_operation_time": {
"name": "Doba provozu UV-C"
},
@@ -919,10 +934,16 @@
"name": "Teplota"
},
"diagnosis": {
"name": "Diagnostika"
"name": "Diagnostika",
"state": {
"ready": "Připraveno"
}
},
"diagnosis_status": {
"name": "Stav diagnostiky"
"name": "Stav diagnostiky",
"state": {
"ready": "Připraveno"
}
},
"drum_clean_cycles_remaining": {
"name": "Čištění bubnu za"
@@ -1066,7 +1087,21 @@
"name": "Teplota sondy"
},
"progress": {
"name": "Průběh"
"name": "Průběh",
"state": {
"idle": "Nečinný",
"weightsensing": "Detekce náplně",
"wash": "Praní",
"rinse": "Máchání",
"spin": "Odstřeďování",
"finish": "Dokončeno",
"steaming": "Napařování",
"airwashing": "Osvěžení vzduchem",
"drying": "Sušení",
"cooling": "Chlazení",
"predrain": "Vypouštění",
"prewash": "Předpírka"
}
},
"progress_percentage": {
"name": "Průběh v procentech"
@@ -1558,6 +1593,9 @@
"command_failed": {
"message": "Příkaz pro {href} selhal i po opětovném připojení: {error}"
},
"debug_read_failed": {
"message": "Čtení z {href} selhalo: {error}"
},
"debug_too_many_writes": {
"message": "Zadejte 1 až 10 zápisů."
},
@@ -108,6 +108,9 @@
},
"remote_control": {
"name": "Intelligente Steuerung"
},
"stick_ble_connected": {
"name": "Stick per BLE verbunden"
}
},
"button": {
@@ -258,7 +261,11 @@
"name": "Signalton",
"state": {
"off": "Aus",
"on": "Ein"
"on": "Ein",
"volume_off": "Aus",
"volume_low": "Niedrig",
"volume_med": "Mittel",
"volume_high": "Hoch"
}
},
"discharging_time": {
@@ -606,6 +613,7 @@
"04": "Schnellwäsche",
"06": "XXL-Wäsche",
"08": "Spülen + Schleudern",
"0a": "Handtücher",
"1b": "Baumwolle",
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -653,6 +661,7 @@
"8f": "Kaltwäsche Intensiv",
"96": "Weniger Mikrofasern",
"a0": "Schnelle Wäsche 15'",
"b0": "Gemischte Beladung",
"17": "Heruntergeladen",
"69": "KI-Wäsche",
"6a": "Wolle",
@@ -872,6 +881,12 @@
"stick_status": {
"name": "Stick-Status"
},
"stick_operation_mode": {
"name": "Stick-Betriebsmodus"
},
"stick_cleaning_status": {
"name": "Stick-Reinigungsstatus"
},
"uvc_operation_time": {
"name": "UV-C Betriebszeit"
},
@@ -913,10 +928,16 @@
"name": "Temperatur"
},
"diagnosis": {
"name": "Diagnose"
"name": "Diagnose",
"state": {
"ready": "Bereit"
}
},
"diagnosis_status": {
"name": "Diagnosestatus"
"name": "Diagnosestatus",
"state": {
"ready": "Bereit"
}
},
"drum_clean_cycles_remaining": {
"name": "Trommelreinigung fällig in"
@@ -1060,7 +1081,21 @@
"name": "Fühlertemperatur"
},
"progress": {
"name": "Fortschritt"
"name": "Fortschritt",
"state": {
"idle": "Leerlauf",
"weightsensing": "Beladungserkennung",
"wash": "Waschen",
"rinse": "Spülen",
"spin": "Schleudern",
"finish": "Fertig",
"steaming": "Dämpfen",
"airwashing": "Luftreinigung",
"drying": "Trocknen",
"cooling": "Abkühlen",
"predrain": "Abpumpen",
"prewash": "Vorwäsche"
}
},
"progress_percentage": {
"name": "Fortschritt in Prozent"
@@ -1558,6 +1593,9 @@
"command_failed": {
"message": "Der Befehl an {href} ist auch nach erneutem Verbinden fehlgeschlagen: {error}"
},
"debug_read_failed": {
"message": "Das Lesen von {href} ist fehlgeschlagen: {error}"
},
"debug_too_many_writes": {
"message": "Geben Sie zwischen 1 und 10 Schreibvorgänge an."
},
@@ -108,6 +108,9 @@
},
"softener_low": {
"name": "Softener low"
},
"stick_ble_connected": {
"name": "Stick BLE connected"
}
},
"button": {
@@ -258,7 +261,11 @@
"name": "Buzzer sound",
"state": {
"off": "Off",
"on": "On"
"on": "On",
"volume_off": "Off",
"volume_low": "Low",
"volume_med": "Medium",
"volume_high": "High"
}
},
"discharging_time": {
@@ -643,6 +650,7 @@
"04": "Quick Wash",
"06": "XXL Laundry",
"08": "Rinse+Spin",
"0a": "Towels",
"17": "Downloaded",
"1b": "Cotton",
"1c": "Eco 40-60",
@@ -709,7 +717,8 @@
"88": "Pet Care",
"8f": "Intense Cold",
"96": "Less Microfiber",
"a0": "15' Quick Wash"
"a0": "15' Quick Wash",
"b0": "Mixed Load"
}
},
"washer_dry_level": {
@@ -872,6 +881,12 @@
"stick_status": {
"name": "Stick status"
},
"stick_operation_mode": {
"name": "Stick operation mode"
},
"stick_cleaning_status": {
"name": "Stick cleaning status"
},
"uvc_operation_time": {
"name": "UV-C operation time"
},
@@ -919,10 +934,16 @@
"name": "Temperature"
},
"diagnosis": {
"name": "Diagnosis"
"name": "Diagnosis",
"state": {
"ready": "Ready"
}
},
"diagnosis_status": {
"name": "Diagnosis status"
"name": "Diagnosis status",
"state": {
"ready": "Ready"
}
},
"drum_clean_cycles_remaining": {
"name": "Drum clean due in"
@@ -1066,7 +1087,21 @@
"name": "Probe temperature"
},
"progress": {
"name": "Progress"
"name": "Progress",
"state": {
"idle": "Idle",
"weightsensing": "Weight sensing",
"wash": "Washing",
"rinse": "Rinsing",
"spin": "Spinning",
"finish": "Finished",
"steaming": "Steaming",
"airwashing": "Air washing",
"drying": "Drying",
"cooling": "Cooling",
"predrain": "Pre-drain",
"prewash": "Pre-wash"
}
},
"progress_percentage": {
"name": "Progress percent"
@@ -1558,6 +1593,9 @@
"command_failed": {
"message": "The command to {href} failed even after reconnecting: {error}"
},
"debug_read_failed": {
"message": "The read from {href} failed: {error}"
},
"debug_too_many_writes": {
"message": "Provide between 1 and 10 writes."
},
@@ -173,6 +173,9 @@
"command_failed": {
"message": "El comando para {href} falló incluso después de reconectar: {error}"
},
"debug_read_failed": {
"message": "La lectura desde {href} falló: {error}"
},
"debug_too_many_writes": {
"message": "Proporciona entre 1 y 10 escrituras."
},
@@ -301,6 +304,9 @@
},
"auto_clean_running": {
"name": "Limpieza automática en curso"
},
"stick_ble_connected": {
"name": "Aspiradora conectada por BLE"
}
},
"button": {
@@ -451,7 +457,11 @@
"name": "Volumen",
"state": {
"off": "Apagado",
"on": "Encendido"
"on": "Encendido",
"volume_off": "Apagado",
"volume_low": "Bajo",
"volume_med": "Medio",
"volume_high": "Alto"
}
},
"discharging_time": {
@@ -836,6 +846,7 @@
"04": "Lavado rápido",
"06": "Colada XXL",
"08": "Aclarar + Centrifugar",
"0a": "Toallas",
"17": "Descargado",
"1b": "Algodón",
"1c": "Eco 40-60",
@@ -884,6 +895,7 @@
"8f": "Lavado en frío",
"96": "Menos microfibras",
"a0": "Lavado rápido 15'",
"b0": "Carga mixta",
"69": "Lavado IA",
"6a": "Lana",
"6b": "Denim",
@@ -1062,6 +1074,12 @@
"stick_status": {
"name": "Estado de la aspiradora"
},
"stick_operation_mode": {
"name": "Modo de funcionamiento de la aspiradora"
},
"stick_cleaning_status": {
"name": "Estado de limpieza de la aspiradora"
},
"uvc_operation_time": {
"name": "Tiempo de funcionamiento UV-C"
},
@@ -1109,10 +1127,16 @@
"name": "Temperatura"
},
"diagnosis": {
"name": "Diagnóstico"
"name": "Diagnóstico",
"state": {
"ready": "Listo"
}
},
"diagnosis_status": {
"name": "Estado del diagnóstico"
"name": "Estado del diagnóstico",
"state": {
"ready": "Listo"
}
},
"drum_clean_cycles_remaining": {
"name": "Limpieza de tambor en"
@@ -1256,7 +1280,21 @@
"name": "Temperatura de la sonda"
},
"progress": {
"name": "Progreso"
"name": "Progreso",
"state": {
"idle": "Inactiva",
"weightsensing": "Detección de carga",
"wash": "Lavado",
"rinse": "Aclarado",
"spin": "Centrifugado",
"finish": "Finalizado",
"steaming": "Vaporización",
"airwashing": "Lavado con aire",
"drying": "Secado",
"cooling": "Enfriamiento",
"predrain": "Drenaje previo",
"prewash": "Prelavado"
}
},
"progress_percentage": {
"name": "Porcentaje de progreso"
@@ -108,6 +108,9 @@
},
"softener_low": {
"name": "Aggiungi ammorbidente"
},
"stick_ble_connected": {
"name": "Scopa elettrica connessa via BLE"
}
},
"button": {
@@ -258,7 +261,11 @@
"name": "Suono cicalino",
"state": {
"off": "Spento",
"on": "Acceso"
"on": "Acceso",
"volume_off": "Spento",
"volume_low": "Basso",
"volume_med": "Medio",
"volume_high": "Alto"
}
},
"discharging_time": {
@@ -643,6 +650,7 @@
"04": "Lavaggio rapido",
"06": "Bucato XXL",
"08": "Risciacquo+Centrifuga",
"0a": "Asciugamani",
"17": "Scaricato",
"1b": "Cotone",
"1c": "Eco 40-60",
@@ -709,7 +717,8 @@
"79": "Solo centrifuga",
"88": "Cura animali",
"35": "Cotone E",
"a0": "Rapido 15'"
"a0": "Rapido 15'",
"b0": "Carico misto"
}
},
"washer_dry_level": {
@@ -872,6 +881,12 @@
"stick_status": {
"name": "Stato scopa elettrica"
},
"stick_operation_mode": {
"name": "Modalità operativa scopa elettrica"
},
"stick_cleaning_status": {
"name": "Stato pulizia scopa elettrica"
},
"uvc_operation_time": {
"name": "Tempo funzionamento UV-C"
},
@@ -919,10 +934,16 @@
"name": "Temperatura"
},
"diagnosis": {
"name": "Diagnosi"
"name": "Diagnosi",
"state": {
"ready": "Pronto"
}
},
"diagnosis_status": {
"name": "Stato diagnosi"
"name": "Stato diagnosi",
"state": {
"ready": "Pronto"
}
},
"drum_clean_cycles_remaining": {
"name": "Pulizia cestello fra"
@@ -1066,7 +1087,21 @@
"name": "Temperatura sonda"
},
"progress": {
"name": "Avanzamento"
"name": "Avanzamento",
"state": {
"idle": "Inattivo",
"weightsensing": "Rilevamento del carico",
"wash": "Lavaggio",
"rinse": "Risciacquo",
"spin": "Centrifuga",
"finish": "Completato",
"steaming": "Vapore",
"airwashing": "Lavaggio ad aria",
"drying": "Asciugatura",
"cooling": "Raffreddamento",
"predrain": "Scarico preliminare",
"prewash": "Prelavaggio"
}
},
"progress_percentage": {
"name": "Avanzamento percentuale"
@@ -1558,6 +1593,9 @@
"command_failed": {
"message": "Il comando per {href} è fallito anche dopo la riconnessione: {error}"
},
"debug_read_failed": {
"message": "La lettura da {href} è fallita: {error}"
},
"debug_too_many_writes": {
"message": "Specificare da 1 a 10 scritture."
},
@@ -108,6 +108,9 @@
},
"softener_low": {
"name": "섬유유연제 부족"
},
"stick_ble_connected": {
"name": "스틱 BLE 연결됨"
}
},
"button": {
@@ -258,7 +261,11 @@
"name": "부저음",
"state": {
"off": "끄기",
"on": "켜기"
"on": "켜기",
"volume_off": "끔",
"volume_low": "낮음",
"volume_med": "중간",
"volume_high": "높음"
}
},
"discharging_time": {
@@ -643,6 +650,7 @@
"04": "쾌속세탁",
"06": "XXL 세탁",
"08": "헹굼+탈수",
"0a": "타월",
"17": "다운로드 코스",
"1b": "면",
"1c": "에코 40-60",
@@ -691,6 +699,7 @@
"8f": "강력 냉수 세탁",
"96": "미세플라스틱저감",
"a0": "15분 쾌속세탁",
"b0": "혼합 세탁",
"69": "AI 맞춤세탁",
"6a": "울",
"6b": "데님",
@@ -872,6 +881,12 @@
"stick_status": {
"name": "스틱 청소기 상태"
},
"stick_operation_mode": {
"name": "스틱 작동 모드"
},
"stick_cleaning_status": {
"name": "스틱 청소 상태"
},
"uvc_operation_time": {
"name": "UV-C 작동 시간"
},
@@ -919,10 +934,16 @@
"name": "온도"
},
"diagnosis": {
"name": "진단"
"name": "진단",
"state": {
"ready": "준비됨"
}
},
"diagnosis_status": {
"name": "진단 상태"
"name": "진단 상태",
"state": {
"ready": "준비됨"
}
},
"drum_clean_cycles_remaining": {
"name": "통세척까지 남은 횟수"
@@ -1066,7 +1087,21 @@
"name": "탐침 온도계 현재 온도"
},
"progress": {
"name": "진행률"
"name": "진행률",
"state": {
"idle": "대기",
"weightsensing": "세탁물 감지",
"wash": "세탁",
"rinse": "헹굼",
"spin": "탈수",
"finish": "완료",
"steaming": "스팀",
"airwashing": "에어워시",
"drying": "건조",
"cooling": "냉각",
"predrain": "사전 배수",
"prewash": "애벌빨래"
}
},
"progress_percentage": {
"name": "진행률"
@@ -1558,6 +1593,9 @@
"command_failed": {
"message": "재연결 후에도 {href} 명령이 실패했습니다: {error}"
},
"debug_read_failed": {
"message": "{href}에서 읽기가 실패했습니다: {error}"
},
"debug_too_many_writes": {
"message": "1~10개의 쓰기를 지정하세요."
},
@@ -108,6 +108,9 @@
},
"softener_low": {
"name": "Wasverzachter bijna op"
},
"stick_ble_connected": {
"name": "Steel via BLE verbonden"
}
},
"button": {
@@ -258,7 +261,11 @@
"name": "Zoemergeluid",
"state": {
"off": "Uit",
"on": "Aan"
"on": "Aan",
"volume_off": "Uit",
"volume_low": "Laag",
"volume_med": "Gemiddeld",
"volume_high": "Hoog"
}
},
"discharging_time": {
@@ -643,6 +650,7 @@
"04": "Snelle was",
"06": "XXL was",
"08": "Spoelen+centrifugeren",
"0a": "Handdoeken",
"17": "Gedownload",
"1b": "Katoen",
"1c": "Eco 40-60",
@@ -709,6 +717,7 @@
"8f": "Intensief koud",
"96": "Minder microvezels",
"a0": "15' Snelle was",
"b0": "Gemengde was",
"35": "Eco katoen"
}
},
@@ -872,6 +881,12 @@
"stick_status": {
"name": "Status steel"
},
"stick_operation_mode": {
"name": "Bedieningsmodus steel"
},
"stick_cleaning_status": {
"name": "Schoonmaakstatus steel"
},
"uvc_operation_time": {
"name": "UV-C-bedrijfstijd"
},
@@ -919,10 +934,16 @@
"name": "Temperatuur"
},
"diagnosis": {
"name": "Diagnose"
"name": "Diagnose",
"state": {
"ready": "Gereed"
}
},
"diagnosis_status": {
"name": "Diagnosestatus"
"name": "Diagnosestatus",
"state": {
"ready": "Gereed"
}
},
"drum_clean_cycles_remaining": {
"name": "Trommelreiniging over"
@@ -1066,7 +1087,21 @@
"name": "Sondetemperatuur"
},
"progress": {
"name": "Voortgang"
"name": "Voortgang",
"state": {
"idle": "Inactief",
"weightsensing": "Beladingsdetectie",
"wash": "Wassen",
"rinse": "Spoelen",
"spin": "Centrifugeren",
"finish": "Voltooid",
"steaming": "Stomen",
"airwashing": "Luchtreiniging",
"drying": "Drogen",
"cooling": "Koelen",
"predrain": "Vooraf afpompen",
"prewash": "Voorwas"
}
},
"progress_percentage": {
"name": "Voortgangspercentage"
@@ -1558,6 +1593,9 @@
"command_failed": {
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
},
"debug_read_failed": {
"message": "Het lezen van {href} is mislukt: {error}"
},
"debug_too_many_writes": {
"message": "Geef tussen de 1 en 10 schrijfacties op."
},
@@ -0,0 +1,184 @@
# Composite AC subdevices: where else a sibling's hrefs could live
Open question behind issue #335 (`ARTIK051_FAC_BORA_19K`, a 2-in-1 floor +
wall AC): the board reports a sibling in `/subdevices/vs/0`'s
`subdeviceIdList`, but every seed `registry/subdevices.enumerate_subdevices`
tries comes back 4.04, so `subdevices` and `subdevices_skipped` are both
empty and the wall unit never becomes an entity.
This file records what that actually rules out (less than it looks like),
why, and which hrefs are worth reading next.
## `/oic/res` does not enumerate the resource tree on modern firmware
This is the finding that reopens the question. Across every fixture that
carries a captured `/oic/res`:
| board | links | `sec:true` | lists `/device/0`? |
| --- | --- | --- | --- |
| `ARTIK051_DONGLE_FAC_18K` | 91 | 78 | yes |
| `TP2X_FAC_BORA_21K` (2-in-1) | 17 | 6 | no |
| `TP2X_FAC_BORA_21K` (#205 flat) | 17 | 6 | no |
| `TP1X_DA_KS_RANGE_0101X` | 10 | 6 | no |
| `AWM-WW-AID-26-ONEBODY` | 15 | 9 | no |
| `ARTIK051_FAC_BORA_19K` (#335) | 18 | 6 | no |
The `ARTIK051_DONGLE_FAC_18K` board — the one Pattern A was built against —
is the outlier, not the model. Everywhere else `/oic/res` lists the
onboarding surface and nothing else: `/oic/d`, `/oic/p`, the security and
EasySetup/WiFiConf/CoapCloudConf/DevConf resources, file transfer, and the
`sec/*` pair. On issue #335's board the six `sec:true` links are exactly
doxm, pstat and the four setup URIs; every other listed link is `sec:false`.
The entire secure operational tree — `/device/0` included, which
demonstrably answers, since the dump comes from it — is absent.
Two consequences, both load-bearing:
1. **Nothing is learned from an href's absence in `/oic/res`.** On the range
board (issue #324) `/oic/res` lists ten onboarding links and no
`/device/0`, yet `/device/1` answers a full indexed dual-cavity sibling.
It was found only by `_SPECULATIVE_DEVICE_INDICES`, never by enumeration
of the links.
2. **Pattern A's `/oic/res` index scan is dead weight on these boards.** It
contributes nothing anywhere except the dongle board, so in practice
indexed siblings are found by the speculative `/device/1`, `/device/2`
probe alone.
## What issue #335 has actually ruled out
All 26 probes in the report returned false. Twenty-three of them are the
issue #205 flat fallback walking the master's own href list under the
sibling's UUID prefix, plus `/<uuid>/device/0`, `/device/1`, `/device/2`
and `/multidevice/vs/0`. Four more were read by hand from the issue thread
(`/<uuid>/information/vs/{1,2}`, `/<uuid>/device/{1,2}`), all 4.04.
So what is ruled out is: the UUID-prefixed namespace (Pattern B/C), and the
indexed **Collection** (`/device/<n>`). What has never been read on this
board — or on any `FAC_BORA` board — is **a bare indexed leaf**:
`/mode/vs/1`, `/temperatures/vs/1`, and friends. Every indexed href ever
probed by this project arrived via a `/device/<n>` batch; none was ever
GETed directly.
That gap matters because the "leaves exist, their Collection does not" shape
is already confirmed on this exact product family, just in the other
namespace: issue #205's `TP2X_FAC_BORA_21K` answers
`/<uuid>/information/vs/0` while `/<uuid>/device/0` comes back empty. A
board that mounts sibling leaves without mounting a sibling Collection is
the documented BORA behavior, so `/device/1`'s 4.04 is evidence about the
Collection and not about `/mode/vs/1`.
## What the OCF spec says about composite devices
The Core/Device specifications model this as a *Composite Device*: one
Platform representing the whole appliance, `/oic/d` carrying the Device
Types of every constituent Device, and — the relevant part — a **Collection
per distinct Device in the composition**, each Collection's `rt` including
the Device Type it represents.
Issue #335's `/oic/d` reports `["oic.wk.d", "oic.d.airconditioner"]`, which
is consistent with a two-indoor-unit composite (both constituents are air
conditioners, so the type appears once) and equally consistent with a single
unit. It does not discriminate.
The Collection half does suggest something untried. `x.com.samsung.devcol`
is Samsung's Collection type, carried by `/device/0` — and on the dongle
board `/oic/res` advertises a second resource with the same
`["x.com.samsung.devcol", "oic.wk.col"]` pair: **`/sec/devices`**. A
collection of devices, sitting alongside `/device/0`, never read by this
project or by any issue thread. If the composite enumeration is exposed
anywhere as a first-class resource, that is the shape it would take.
## Results of the second probe round
The reporter ran these live. Three answers, all informative.
**Indexed leaves do not exist.** `/information/vs/1`, `/power/vs/1`,
`/mode/vs/1` → 4.04. Pattern A is ruled out on this board properly now:
not just the `/device/1` Collection, but the leaf namespace it would have
carried.
**The UUID prefix routes, and is empty of operational resources.** The
control pair settles it:
/c24e25e9-.../file/list/vs/0 → 2.05, two items
/file/list/vs/0 → 2.05, the same two items
(/opt/data/energy.db, /opt/data/hass.db)
So the sibling's prefix is a live, routed namespace — the 23 flat-fallback
4.04s under it are the firmware answering "no such resource", not a dead
prefix swallowing everything. Pattern B/C is ruled out on this board on
positive evidence rather than on absence. That the two listings are
identical is expected either way: one board, one flash, one filesystem.
**`/sec/devices` exists — and this project could not see what's in it.**
It answered `2.05` with `rep: {}`, which reads as "the resource is there and
has nothing in it". It is not. `coordinator._raw_read_blocking` decoded the
CBOR body and then kept it *only if it was a Property map*:
```python
if isinstance(body, dict):
rep = body
```
A Collection answers a **list** — the `[devcol rep, {href, rep}, ...]` batch
`parse_device0_batch` reads. `/device/0` itself would have rendered exactly
the same accepted-but-empty `2.05 {}` through `read_resource`. Fixed: the
read path now returns the decoded body alongside `rep`, and the service
response carries it as `body` whenever it isn't the map already in `rep`.
`/sec/devices` therefore remains the one open lead, and needs one re-read on
a build carrying that fix.
## Still worth reading
**1 — `/sec/devices`, again.** Same `x.com.samsung.devcol` + `oic.wk.col`
pair as `/device/0`, so its body should be a batch naming its members. If a
composite enumeration is exposed anywhere, it is here.
**2 — the file-transfer pair.** `/oic/res` advertises
`/c24e25e9-.../file/transfer/vs/0` alongside the master's, and the prefix is
now known to route. Issue #301 documents the shape: a baseline GET returns
one item, `x.com.samsung.name` plus `x.com.samsung.blob`, no write needed to
see whatever it currently serves. If the prefixed endpoint serves *different
bytes* than the master's, that is the first hard local evidence the wall
unit exists as a data producer, and `/opt/data/energy.db` would be where its
runtime history lives.
/file/transfer/vs/0
/c24e25e9-55dd-ba18-d567-000000000001/file/transfer/vs/0
Mind the blob: #301 measured 2172 B on a `KRAC_18K`, and a raw `bytes` value
in a service response is not guaranteed to survive rendering in Developer
Tools. Ask for `x.com.samsung.name` and whether a blob field appears, not
for the blob pasted into a comment.
## Dead ends, so they aren't re-tried
- `/hass/state/vs/0`, `/hass/command/vs/0` — advertised in `/oic/res` on
every board here, and indexed per subdevice on the dongle board
(`/hass/state/vs/{0,1,2}`), which makes them look like a subdevice-aware
state channel. They are not: 4.04 on every interface on
`ARTIK051_KRAC_18K` (see `ac-filter-reset.md`). Cheap enough to retry once
on #335's newer build, but expect nothing.
- `/multidevice/vs/0` — probed, 4.04. Absent on this board; only the dongle
family exposes it.
- `/actions/vs/0` — GET returns `{}` on baseline and `oic.if.a`; publishes
no schema (`ac-filter-reset.md`).
## Where this lands if `/sec/devices` is empty too
Then the sibling is named in `subdeviceIdList` for the cloud's benefit and
has no local operational surface at all on this firmware — every namespace
it could occupy has now been read directly, and the UUID one was confirmed
routable first, so the negatives mean what they say. That closes issue #335
as a firmware limitation rather than leaving it open against a probe
strategy that was never actually exercised.
Worth keeping in view for the enumeration code either way: both remaining
patterns hinge on a Collection, and this board answers neither `/device/1`
nor a prefixed `/device/0`. An indexed flat-probe fallback — the mirror of
issue #205's prefixed one, gated on a board that claims a sibling but
materialized nothing — would have cost 8 round trips here and returned the
same 4.04s the reporter got by hand. It is worth building only if some
other board turns out to serve indexed leaves without their Collection;
this one does not.
+1 -1
View File
@@ -6,7 +6,7 @@ pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test
# (also declared in custom_components/localthings/manifest.json).
smartthings-local>=0.1.2
smartthings-local>=0.1.6
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
+167
View File
@@ -238,6 +238,11 @@ class FakeSession:
instances: ClassVar[list[FakeSession]] = []
reject_certs: ClassVar[set[str]] = set()
# smartthings-local >= 0.1.3 ("redacted typed failures") no longer puts
# the alert in connect()'s exception text -- set True to model that, so
# a test can check the diagnostic-handshake fallback (_resolve_alert)
# instead of the legacy _alert_name text-parsing path.
redact_rejection: ClassVar[bool] = False
def __init__(self, host, port, cert_pem=None, key_pem=None, **kwargs):
self.host, self.port, self.cert_pem = host, port, cert_pem
@@ -245,6 +250,10 @@ class FakeSession:
def connect(self):
if self.cert_pem in FakeSession.reject_certs:
if FakeSession.redact_rejection:
from smartthings_local.errors import SessionError
raise SessionError()
raise ConnectionError(
"DTLS handshake error: [('SSL routines', '', 'sslv3 alert bad certificate')]"
)
@@ -268,6 +277,7 @@ def fake_dtls(monkeypatch):
FakeSession.instances = []
FakeSession.reject_certs = set()
FakeSession.redact_rejection = False
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", lambda: "test-uuid")
monkeypatch.setattr(
config_flow,
@@ -480,6 +490,77 @@ async def test_rejected_reused_leaf_is_reminted(
assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"]
async def test_rejected_reused_leaf_is_reminted_against_a_redacted_library(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
"""Same flow as test_rejected_reused_leaf_is_reminted, but against a
connect() failure shaped like smartthings-local >= 0.1.3 -- a fixed,
redacted exception with no alert text at all (see errors.py's
"Classified errors"). The re-mint decision has to come from
_resolve_alert's diagnostic-handshake fallback instead of _alert_name."""
from custom_components.localthings import config_flow
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other")
existing.add_to_hass(hass)
_patch_clienthello(monkeypatch, {49154})
FakeSession.reject_certs = {MOCK_LEAF_CERT_PEM}
FakeSession.redact_rejection = True
class _DiagnosticResult:
alert = (2, "bad_certificate")
diagnosed: list[int] = []
def _diagnostic_alert(host, port, cert_pem, key_pem):
diagnosed.append(port)
return _DiagnosticResult()
monkeypatch.setattr(config_flow, "_diagnostic_alert", _diagnostic_alert)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: MOCK_HOST}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_LEAF_CERT_PEM] == "FULLCHAIN"
assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"]
assert diagnosed == [49154]
def test_diagnostic_handshake_runs_once_not_once_per_failing_port(monkeypatch) -> None:
"""_diagnostic_alert commits association state on the device (see its
own docstring) -- running it once per failing candidate instead of once
overall would both add latency (each is its own bounded handshake) and
multiply that pollution right before _probe_and_validate might retry a
real handshake against these very same ports. Three candidates fail
here; the diagnostic must run exactly once, against the confirmed-live
port, not three times against every candidate in scan order."""
from custom_components.localthings import config_flow
FakeSession.instances = []
FakeSession.reject_certs = {MOCK_LEAF_CERT_PEM}
FakeSession.redact_rejection = True
monkeypatch.setattr("smartthings_local.protocol.dtls_session.DtlsCoapSession", FakeSession)
diagnosed: list[int] = []
class _DiagnosticResult:
alert = (2, "bad_certificate")
def _diagnostic_alert(host, port, cert_pem, key_pem):
diagnosed.append(port)
return _DiagnosticResult()
monkeypatch.setattr(config_flow, "_diagnostic_alert", _diagnostic_alert)
scan = _scan(confirmed=[49153, 49154], candidates=[49153, 49154, 49155])
with pytest.raises(config_flow.CertRejected):
config_flow._handshake_and_read(MOCK_HOST, scan, MOCK_LEAF_CERT_PEM, "KEY")
assert diagnosed == [49153] # confirmed-live, and only once
async def test_unconfirmed_port_failure_is_not_reminted(
hass: HomeAssistant, monkeypatch, fake_dtls
) -> None:
@@ -554,6 +635,92 @@ def test_cert_alert_is_reported_as_a_certificate_problem() -> None:
assert err.error_key == "cert_rejected"
def test_classify_handshake_failure_uses_a_resolved_alert_over_exception_text() -> None:
"""_handshake_and_read passes its own resolved `alerts` mapping (built
via _resolve_alert, which is what actually classifies a failure against
smartthings-local >= 0.1.3's redacted exceptions) -- it must win even
when the exception text itself says nothing."""
from custom_components.localthings.config_flow import (
CertRejected,
_classify_handshake_failure,
)
err = _classify_handshake_failure(
MOCK_HOST,
_scan(confirmed=[49154]),
[(49154, RuntimeError("session operation failed"))],
{49154: "bad_certificate"},
)
assert isinstance(err, CertRejected)
assert err.error_key == "cert_rejected"
def test_resolve_alert_prefers_exception_text_over_the_diagnostic_handshake() -> None:
"""A library still stamping the alert into its exception text (< 0.1.3)
answers for free; the diagnostic handshake must not run at all then."""
from custom_components.localthings.config_flow import _resolve_alert
def _must_not_run(*args, **kwargs):
raise AssertionError("must not run the diagnostic handshake")
with patch("custom_components.localthings.config_flow._diagnostic_alert", _must_not_run):
name = _resolve_alert(
_openssl_alert("tlsv1 alert unknown ca"), MOCK_HOST, 49154, "CERT", "KEY"
)
assert name == "unknown_ca"
def test_resolve_alert_falls_back_to_the_diagnostic_handshake() -> None:
"""smartthings-local >= 0.1.3 redacts the exception text (see errors.py's
"Classified errors"), so the only way left to learn *why* a handshake
failed is the library's own classification of the raw alert record."""
from smartthings_local.errors import SessionError
from custom_components.localthings import config_flow
class _Result:
alert = (2, "bad_certificate")
with patch.object(config_flow, "_diagnostic_alert", lambda *a, **k: _Result()):
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
assert name == "bad_certificate"
def test_resolve_alert_ignores_a_non_fatal_alert() -> None:
"""ProbeResult.alert is set for a *received* alert of either level, but
only a fatal one (2) means the appliance actually broke off the
handshake over it -- a warning-level alert (e.g. close_notify on an
otherwise ordinary close) is not evidence of a rejection. The old
exception-text path never had this ambiguity: OpenSSL's exception only
ever rendered for a fatal alert, so nothing pre-0.1.3 could confuse the
two -- the diagnostic-handshake fallback must not introduce the mix-up."""
from smartthings_local.errors import SessionError
from custom_components.localthings import config_flow
class _Result:
alert = (1, "close_notify") # warning level, not fatal
with patch.object(config_flow, "_diagnostic_alert", lambda *a, **k: _Result()):
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
assert name is None
def test_resolve_alert_is_none_when_the_diagnostic_handshake_also_fails() -> None:
"""A best-effort extra probe: its own failure must not raise out of
_resolve_alert, it just leaves the caller with no alert to report."""
from smartthings_local.errors import SessionError, SessionTimeoutError
from custom_components.localthings import config_flow
def _boom(*args, **kwargs):
raise SessionTimeoutError()
with patch.object(config_flow, "_diagnostic_alert", _boom):
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
assert name is None
def test_non_cert_alert_is_kept_distinct_from_a_cert_problem() -> None:
"""A cipher or version mismatch is also a deliberate refusal, but no
amount of fiddling with CA credentials will fix it."""
+63
View File
@@ -13,6 +13,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from smartthings_local.errors import SessionClosedError, SessionError, SessionTimeoutError
from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL,
@@ -531,6 +532,36 @@ async def test_total_poll_failure_downgrades_observe_mode_to_poll(
assert coordinator.last_update_success is True
def test_defer_reconnect_for_reconnects_immediately_on_a_confirmed_dead_session(
hass: HomeAssistant, mock_entry
) -> None:
"""smartthings-local >= 0.1.6 raises SessionClosedError -- a
ConnectionError, not a TimeoutError -- the moment a dead reader thread
is confirmed, instead of the old behavior of letting the request hang
out to its own timeout and surface as an ambiguous TimeoutError.
_defer_reconnect_for must never extend the block-ACK tolerance to a
failure this unambiguous; see its docstring for the full reasoning."""
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._discovered = True
assert coordinator._defer_reconnect_for(SessionClosedError()) is False
def test_defer_reconnect_for_still_tolerates_an_ambiguous_timeout(
hass: HomeAssistant, mock_entry
) -> None:
"""The other half of the same distinction: a plain block-ACK timeout --
still a TimeoutError, including smartthings-local's own
SessionTimeoutError subclass -- keeps its multi-cycle tolerance rather
than being swept into the immediate-reconnect path above."""
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._discovered = True
for _ in range(coordinator._POLL_TIMEOUT_LIMIT - 1):
assert coordinator._defer_reconnect_for(SessionTimeoutError()) is True
assert coordinator._defer_reconnect_for(SessionTimeoutError()) is False
async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
@@ -786,6 +817,38 @@ async def test_attempt_observe_mode_discards_stale_commit_after_session_swap(
assert coordinator._resubscribe_due is True
async def test_attempt_observe_mode_survives_a_failed_reconnect(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The session was closed out from under this attempt concurrently
(rare, but real -- see the docstring above), and the reconnect it tries
on the way back in fails too (smartthings-local >= 0.1.3's redacted
SessionError, or any other exception). That must not escape
_async_update_data uncaught: it should land in the same "give up on
push this cycle" state the subscribe-failed and stale-session branches
already produce, not skip this integration's own logging/state handling
entirely."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._session = None
coordinator._reconnect_times = []
with patch.object(
coordinator,
"_connect_session",
side_effect=SessionError(),
):
await coordinator._attempt_observe_mode() # must not raise
assert coordinator.observe_mode == MODE_POLL
assert coordinator._observe.subscribed_hrefs == set()
assert coordinator._resubscribe_due is False
# Not the poll path's own reconnect-frequency window (see the fix's
# comment) -- this failure must not count toward it.
assert coordinator._reconnect_times == []
async def test_maybe_retry_observe_mode_uses_most_recent_attempt_not_just_mode_change(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
+4 -2
View File
@@ -38,7 +38,9 @@ async def test_migration_recovers_serial_from_unique_id(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 2
# Straight through to the current version: v2 -> v3 is a statistics
# relabel that no-ops for a family without particulate sensors.
assert entry.version == 3
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
@@ -257,7 +259,7 @@ async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) ->
written by a newer release."""
from custom_components.localthings import async_migrate_entry
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3)
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=4)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is False
@@ -0,0 +1,221 @@
"""The v2 -> v3 entry migration that relabels particulate statistics.
Dust/FineDust/SuperFineDust gained a pm10/pm25/pm1 device_class and a
µg/m³ unit (issue #325) after having recorded long-term statistics with no
unit at all. Home Assistant treats that as a unit change it cannot convert
and *suppresses statistics generation* for the entity until a human
resolves the repair, so the metadata is corrected during migration instead.
Only the metadata row is touched, never the recorded values -- the readings
were always µg/m³, so there is nothing to convert.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings import async_migrate_entry
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
from .conftest import ENTRY_DATA, MOCK_SERIAL
RELABEL = "homeassistant.components.recorder.statistics.async_update_statistics_metadata"
@pytest.fixture(autouse=True)
def _recorder_loaded(hass: HomeAssistant):
"""Most tests here assume a normal install, where after_dependencies has
pulled the recorder in. The deferral test below undoes it."""
hass.config.components.add("recorder")
return hass
def _entry(hass: HomeAssistant, device_type: str) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, CONF_DEVICE_TYPE: device_type},
unique_id=f"{DOMAIN}_{MOCK_SERIAL}",
version=2,
)
entry.add_to_hass(hass)
return entry
def _add_sensor(hass: HomeAssistant, entry: MockConfigEntry, key: str, **kwargs):
return er.async_get(hass).async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{key}",
config_entry=entry,
**kwargs,
)
async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None:
entry = _entry(hass, "air_purifier")
expected = {
_add_sensor(hass, entry, key).entity_id for key in ("dust", "fine_dust", "super_fine_dust")
}
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert {call.args[1] for call in relabel.call_args_list} == expected
for call in relabel.call_args_list:
assert call.kwargs["new_unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
# µg/m³ has a converter, so the class must be named, not None --
# passing neither is deprecated and breaks in HA Core 2026.11.
assert call.kwargs["new_unit_class"] == "concentration"
assert entry.version == 3
async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant) -> None:
"""Odor/CleanLevel/CO2 share the resource but keep the units they had."""
entry = _entry(hass, "air_monitor")
dust = _add_sensor(hass, entry, "dust")
for key in ("odor", "clean_level", "co2", "dustbag_usage", "dustbin_auto_close"):
_add_sensor(hass, entry, key)
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert [call.args[1] for call in relabel.call_args_list] == [dust.entity_id]
async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) -> None:
"""range_hood and airconditioner still declare no unit for their
identically-named sensors. Relabelling their statistics would assert a
unit those entities don't report -- creating the very mismatch this
migration exists to prevent."""
for device_type in ("range_hood", "airconditioner"):
entry = _entry(hass, device_type)
_add_sensor(hass, entry, "dust")
_add_sensor(hass, entry, "fine_dust")
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert relabel.call_args_list == [], device_type
assert entry.version == 3
async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
hass: HomeAssistant,
) -> None:
"""A boot where the recorder didn't come up must not burn the one-shot
migration -- doing so would leave the statistics suppressed for good.
The entry stays on v2 so the next start retries."""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
hass.config.components.remove("recorder")
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert relabel.call_args_list == []
assert entry.version == 2
# ...and the retry lands once the recorder is there.
hass.config.components.add("recorder")
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert len(relabel.call_args_list) == 1
assert entry.version == 3
async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant) -> None:
"""`new_unit_class` only exists from HA 2025.11, and hacs.json still
declares 2025.1 as the minimum. Passing it to the older signature is a
TypeError out of async_migrate_entry, which fails the whole entry -- so
the kwarg is feature-detected rather than assumed.
Stands in for an older HA by patching in that exact signature; the
autospec'd tests above cover the modern one.
"""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
seen: list[dict] = []
def old_signature(hass, statistic_id, *, new_statistic_id=None, **kwargs):
seen.append(kwargs)
with patch(RELABEL, old_signature):
assert await async_migrate_entry(hass, entry) is True
assert seen == [{"new_unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}]
assert entry.version == 3
async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> None:
"""Relabelling is a convenience -- without it the user gets HA's own
units_changed repair, which is where they were before. An older HA whose
async_update_statistics_metadata has a different signature, or any other
recorder-side surprise, must not cost them the integration."""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
with patch(RELABEL, autospec=True, side_effect=TypeError("older HA signature")):
assert await async_migrate_entry(hass, entry) is True
assert entry.version == 3
async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
hass: HomeAssistant,
) -> None:
"""statistic_id is the entity_id, which the user can rename. Matching the
unique_id tail and reading entity_id back off the registry is what keeps
this correct for a renamed sensor -- reconstructing an entity_id from the
descriptor key would relabel a statistic nobody is recording."""
entry = _entry(hass, "air_purifier")
renamed = _add_sensor(hass, entry, "dust", suggested_object_id="living_room_pm10")
assert renamed.entity_id == "sensor.living_room_pm10"
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert [call.args[1] for call in relabel.call_args_list] == ["sensor.living_room_pm10"]
async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant) -> None:
"""_key() can prefix a subdevice and append an instance number, so the
match is on the tail rather than the whole unique_id. The instance form
is `_<n>` (discovery.instance_suffix), not a bare digit."""
entry = _entry(hass, "air_purifier")
ent_reg = er.async_get(hass)
for unique_suffix in ("indoor_0_dust", "fine_dust_1", "super_fine_dust"):
ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
config_entry=entry,
)
# Near-misses that must not match.
for unique_suffix in ("dustbag_full", "dustbin_auto_close", "dust_filter_reset"):
ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
config_entry=entry,
)
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert len(relabel.call_args_list) == 3
async def test_a_fresh_entry_starts_at_the_migrated_version(hass: HomeAssistant) -> None:
"""A newly created entry has no statistics to relabel, so the config flow
mints v3 directly rather than walking through the migration."""
from custom_components.localthings.config_flow import LocalThingsConfigFlow
assert LocalThingsConfigFlow.VERSION == 3
@@ -30,30 +30,54 @@ def test_graded_sensors_are_left_without_a_state_class():
assert _desc(key).state_class is None, key
def test_no_unit_or_device_class_is_asserted():
"""state_class alone makes the series recordable. pm1/pm25/pm10 with
µg/m³ would additionally assert the reading is a mass concentration,
which no dump states."""
for key in PARTICULATE + GRADED:
def test_particulate_sensors_declare_pm_device_class_and_unit():
"""Dust/FineDust/SuperFineDust map to PM10/PM2.5/PM1 (issue #325) -- see
air_purifier._AIR_QUALITY_SENSORS for the three lines of evidence and
tests/test_air_quality_grade_column.py for the device-side ones.
The expected unit comes from HA's own constant rather than a literal:
typing it out is how PR #365 landed U+00B5 MICRO SIGN where HA uses
U+03BC, which renders identically and would make this test agree with
the bug."""
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as UG_M3
expected = {
"dust": ("pm10", UG_M3),
"fine_dust": ("pm25", UG_M3),
"super_fine_dust": ("pm1", UG_M3),
}
for key, (device_class, unit) in expected.items():
desc = _desc(key)
assert desc.device_class == device_class, key
assert desc.unit == unit, key
for key in GRADED:
desc = _desc(key)
assert desc.unit is None, key
assert desc.device_class is None, key
assert desc.unit is None, key
def test_state_class_comes_from_the_shared_tuples_fourth_column():
"""The rows carry their own state_class rather than a parallel lookup, so
a new sensor can't be added here without deciding the question."""
def test_metadata_comes_from_the_shared_tuples_own_columns():
"""The rows carry their own state_class/device_class/unit rather than a
parallel lookup, so a new sensor can't be added here without deciding
each question. Unit validity against HA is a separate guard --
tests/test_sensor_device_class_units.py."""
for row in air_purifier._AIR_QUALITY_SENSORS:
assert len(row) == 4, row
assert len(row) == 6, row
assert row[3] in ("measurement", None), row
assert row[4] in ("pm10", "pm25", "pm1", None), row
# A device_class without a unit would leave HA inferring one.
assert (row[4] is None) == (row[5] is None), row
def test_air_monitor_keeps_stamping_every_shared_sensor():
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column
on purpose: that board (issue #210) has stamped all five as `measurement`
since it was added, and consuming the column would silently drop long-term
statistics for Odor/CleanLevel there. Guards the import end to end and the
deliberate divergence together."""
def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
"""air_monitor imports _AIR_QUALITY_SENSORS and consumes device_class and
unit -- the mapping rests on device-side grading that board shares (issue
#325), so typing one family and not the other would be an inconsistency.
state_class is still discarded: that board (issue #210) has stamped all
five as `measurement` since it was added, and consuming the column would
silently drop long-term statistics for Odor/CleanLevel there. Guards the
import end to end and the deliberate divergence together."""
from custom_components.localthings.registry.capabilities import air_monitor
assert air_monitor.SENSORS.href == "/sensors/vs/0"
@@ -62,6 +86,14 @@ def test_air_monitor_keeps_stamping_every_shared_sensor():
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
)
assert desc.state_class == "measurement", key
assert desc.device_class == _desc(key).device_class, key
assert desc.unit == _desc(key).unit, key
# And the graded pair stays untyped on both families.
for key in GRADED:
desc = next(
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
)
assert (desc.device_class, desc.unit) == (None, None), key
def test_every_air_quality_sensor_still_reads_a_plain_int():
+135
View File
@@ -0,0 +1,135 @@
"""What the second element of a /sensors/vs/0 dust reading means, and why
it is what confirms Dust/FineDust/SuperFineDust are PM10/PM2.5/PM1.
`x.com.samsung.da.value` is `[concentration, grade]` on the fields that
carry a magnitude and `[grade]` on Odor/CleanLevel, which are grades
already. Index 1 is never bound to an entity (its floor differs by board
family), but it is the device's own opinion about its own readings, and
that makes it the one piece of evidence for the PM mapping that doesn't
depend on Samsung's field names or on a user's screenshot.
These assertions read the shipped fixtures rather than restating numbers,
so a re-captured dump that contradicts the mapping fails here instead of
silently weakening the argument in air_purifier.py's comment.
"""
import json
import pathlib
FIXTURES = pathlib.Path(__file__).parent / "fixtures"
DUST_TYPES = ("Dust", "FineDust", "SuperFineDust")
def _items(fixture: str):
dump = json.loads((FIXTURES / f"{fixture}_device.json").read_text(encoding="utf-8"))
for entry in dump["device0"]:
if entry.get("href") == "/sensors/vs/0":
return {
item.get("x.com.samsung.da.type"): item.get("x.com.samsung.da.value")
for item in entry.get("rep", {}).get("x.com.samsung.da.items") or []
}
raise AssertionError(f"{fixture} has no /sensors/vs/0")
def _fixtures_reporting_sensors():
for path in sorted(FIXTURES.glob("*_device.json")):
dump = json.loads(path.read_text(encoding="utf-8"))
entries = dump.get("device0")
if not isinstance(entries, list):
continue
if any(e.get("href") == "/sensors/vs/0" for e in entries):
name = path.name.removesuffix("_device.json")
if any(t in _items(name) for t in DUST_TYPES):
yield name
def test_magnitude_fields_carry_a_grade_and_graded_fields_do_not():
"""The shape asymmetry is the whole argument for what index 1 is: the
fields that already *are* grades have no second slot."""
checked = 0
for fixture in _fixtures_reporting_sensors():
items = _items(fixture)
for type_ in (*DUST_TYPES, "CO2"):
if type_ in items:
assert len(items[type_]) == 2, (fixture, type_, items[type_])
checked += 1
for type_ in ("Odor", "CleanLevel"):
if type_ in items:
assert len(items[type_]) == 1, (fixture, type_, items[type_])
assert checked >= 30
def test_concentration_falls_with_particle_size_on_every_fixture():
"""PM10 >= PM2.5 >= PM1 by definition -- they are cumulative masses, so
a violation would mean the three fields aren't nested size tiers at
all."""
for fixture in _fixtures_reporting_sensors():
items = _items(fixture)
if not all(t in items for t in DUST_TYPES):
continue
coarse, fine, finest = (int(items[t][0]) for t in DUST_TYPES)
assert coarse >= fine >= finest, (fixture, coarse, fine, finest)
def test_the_same_reading_grades_differently_as_dust_than_as_superfinedust():
"""18 is one step above the grade floor as SuperFineDust but sits *at*
the floor as Dust, on two families that both grade good air as 1.
One shared threshold cannot produce both, so the firmware treats the
coarse field as tolerating more than the fine one -- three scales
ordered coarse-to-fine, which is what PM10/PM2.5/PM1 requires and what
"all three are the same kind of reading" cannot explain.
"""
monitor, hood = _items("air_monitor"), _items("range_hood")
assert monitor["SuperFineDust"] == ["18", "2"]
assert hood["Dust"] == ["18", "1"]
# Both families put good air at grade 1, so the two grades are
# comparable -- ARTIK051_TVTL's 0-based floor is the reason this
# comparison is drawn between these two fixtures and not against it.
assert monitor["Odor"] == ["1"]
assert hood["CleanLevel"] == ["2"]
assert _items("air_purifier")["Dust"] == ["11", "0"]
def test_grade_boundaries_bracket_the_korean_cai_bands():
"""Where each field crosses from its floor to the next grade lines up
with the band that field's PM tier is graded on in Korea's CAI:
PM10 breaks at 30/31, PM2.5 at 15/16. A PM1 reading has no standard
index and is graded on PM2.5-like widths.
"""
monitor, hood = _items("air_monitor"), _items("range_hood")
# Dust: still at the floor at 18, above it at 31 -> boundary in (18, 31].
assert (hood["Dust"], monitor["Dust"]) == (["18", "1"], ["31", "2"])
# FineDust: at the floor at 14, above it at 23 -> boundary in (14, 23].
assert (hood["FineDust"], monitor["FineDust"]) == (["14", "1"], ["23", "2"])
# SuperFineDust: at the floor at 9, above it at 18 -> boundary in (9, 18],
# strictly below where Dust's sits.
assert (hood["SuperFineDust"], monitor["SuperFineDust"]) == (["9", "1"], ["18", "2"])
def test_clean_level_aggregates_the_per_field_grades():
"""CleanLevel is the highest per-field grade on every family except the
range hood and one RAC, which report a higher CleanLevel than any dust
grade -- those two fold in something this resource doesn't expose, so
CleanLevel is never derived from the dust grades in code."""
exceptions = {"range_hood", "airconditioner_tp1x_da_ac_rac_01011"}
for fixture in _fixtures_reporting_sensors():
items = _items(fixture)
if "CleanLevel" not in items:
continue
grades = [int(v[1]) for v in items.values() if len(v) == 2]
if not grades:
continue
aggregate = int(items["CleanLevel"][0])
if fixture in exceptions:
assert aggregate > max(grades), (fixture, aggregate, grades)
else:
assert aggregate == max(grades), (fixture, aggregate, grades)
def test_grade_floor_is_zero_based_on_artik051_tvtl_and_one_based_elsewhere():
"""Why index 1 stays unbound: a shared descriptor would need a
per-family offset to mean anything."""
assert _items("air_purifier")["CleanLevel"] == ["0"]
for fixture in ("air_monitor", "air_purifier_avt_ww", "air_purifier_vtww", "range_hood"):
assert int(_items(fixture)["CleanLevel"][0]) >= 1, fixture
+76
View File
@@ -0,0 +1,76 @@
"""Regression tests for a handful of call sites in coordinator.py that used
to let a smartthings-local exception (EndpointError, SessionError,
SessionTimeoutError, SessionClosedError, ... -- or the equivalent bare
ConnectionError/TimeoutError/OSError an older library version raised) escape
uncaught instead of going through this integration's own reconnect/logging
or getting translated into a HomeAssistantError for a service caller.
"""
from __future__ import annotations
import pytest
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_PORT,
DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
ENTRY_DATA = {
CONF_HOST: "10.0.0.198",
CONF_PORT: 49154,
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
}
def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id="localthings_ERRHANDLING-TEST",
)
entry.add_to_hass(hass)
return LocalThingsCoordinator(hass, entry)
async def test_subdevice_enumeration_failure_does_not_abort_first_discovery(
hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""_enumerate_subdevices_blocking's own _connect_session() call only
fires if the session the poll above just used got closed out from under
it within the same cycle -- rare, but until this fix, unguarded: an
exception there escaped _async_update_data entirely instead of going
through this integration's own logging, matching what already happens
for the main poll's own reconnect.
Not a one-cycle blip once caught, though: `_discovered` flips True this
same cycle regardless (gating first discovery, not subdevice success),
so this is the *only* attempt a composite appliance's siblings ever get
without a config-entry reload -- logged at warning for exactly that
reason, not debug.
Empty resources keep _run_discovery from binding anything (hot/warm
hrefs stay empty), so _attempt_observe_mode's own session touch never
runs either -- this test is purely about the enumeration failure not
escaping _async_update_data.
"""
coordinator = _coordinator(hass)
monkeypatch.setattr(coordinator, "_poll_once", dict)
def _boom(_resources):
raise ConnectionError("session closed")
monkeypatch.setattr(coordinator, "_enumerate_subdevices_blocking", _boom)
with caplog.at_level("WARNING"):
result = await coordinator._async_update_data()
assert coordinator._discovered is True
assert result == {}
assert "subdevice enumeration failed" in caplog.text
+12 -1
View File
@@ -6,7 +6,7 @@ check the dishwasher wiring and its device-specific options.
"""
from custom_components.localthings.registry.capabilities import dishwasher
from custom_components.localthings.registry.entities import SwitchDesc
from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc
class TestCycleOptions:
@@ -60,3 +60,14 @@ class TestDishwasherOptions:
assert desc.exists_fn is not None
assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False
assert desc.exists_fn({"x.com.samsung.da.options": ["AutoDoorRelease_On"]}, {}) is True
def test_diagnosis_status_is_a_translatable_enum():
desc = next(
e
for e in dishwasher.DIAGNOSIS.entities
if e.key == "diagnosis_status" and isinstance(e, SensorDesc)
)
assert desc.device_class == "enum"
assert desc.options == ("ready",)
assert desc.value_fn("Ready") == "ready"
+16 -1
View File
@@ -5,7 +5,7 @@ from custom_components.localthings.registry.capabilities.operational import (
_just_finished,
_new_cycle_running,
)
from custom_components.localthings.registry.entities import NumberDesc
from custom_components.localthings.registry.entities import NumberDesc, SensorDesc
def test_machine_state_maps_samsung_to_ocf():
@@ -83,6 +83,21 @@ class TestNewCycleRunning:
)
def test_progress_is_a_translatable_enum():
desc = next(
e for e in OPERATIONAL_STATE.entities if e.key == "progress" and isinstance(e, SensorDesc)
)
assert desc.device_class == "enum"
assert desc.options is not None
assert "rinse" in desc.options
assert "Rinse" not in desc.options
assert desc.rep_fn is not None
assert (
desc.rep_fn({"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Rinse"})
== "rinse"
)
class TestProgressPercentage:
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
after a cycle ends instead of resetting it, so it must be gated on
+20
View File
@@ -7,6 +7,7 @@ from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.laundry import (
BUZZER_SOUND,
cycle_select,
washer_cycle_fallback,
)
@@ -47,6 +48,25 @@ def test_options_field_unaffected():
assert entity.options == ["Lo", "Hi"]
def test_buzzer_volume_options_normalize_to_translation_keys():
desc = next(e for e in BUZZER_SOUND.entities if e.key == "buzzer_sound")
entity = _make_select(
desc,
"/buzzersound/vs/0",
{
"/buzzersound/vs/0": {
"supportedBuzzerSound": [
"Volume_Off",
"Volume_Low",
"Volume_Med",
"Volume_High",
]
}
},
)
assert entity.options == ["volume_off", "volume_low", "volume_med", "volume_high"]
def test_callable_options_receives_full_resource_snapshot():
"""A callable options is handed the coordinator's full href->rep
snapshot, not just this entity's own href's rep -- needed for course
+94
View File
@@ -0,0 +1,94 @@
"""Guards against a SensorDesc unit Home Assistant won't accept for the
device_class it's paired with.
Unlike the SwitchDesc case (issue #349), a bad sensor unit doesn't raise --
sensor.py hands `unit` to `_attr_native_unit_of_measurement` and HA only
logs a warning per entity, once, telling the user to report a bug against
this integration. So the failure mode is a quiet stream of "not a valid
unit for the device class" warnings plus a support burden, with nothing in
the UI to hint anything is wrong.
The specific trap this exists for: HA spells its micrograms-per-cubic-metre
unit with U+03BC GREEK SMALL LETTER MU, and DEVICE_CLASS_UNITS holds only
that spelling. U+00B5 MICRO SIGN renders identically in an editor, in a
terminal, and in a code review diff, but is a different string and fails
the membership test. PR #365 shipped all three particulate units with
U+00B5.
Mirrors test_switch_device_class.py: scans every by_type registry rather
than a fixture, so a new capability making the same mistake fails here.
"""
import importlib
import pkgutil
from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS, SensorDeviceClass
from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.entities import SensorDesc
def _all_registries():
for mod_info in pkgutil.iter_modules(by_type.__path__):
if mod_info.name.startswith("_"):
continue
mod = importlib.import_module(
f"custom_components.localthings.registry.by_type.{mod_info.name}"
)
reg = getattr(mod, "REGISTRY", None)
if reg is not None:
yield reg
def _sensor_descs():
seen = set()
for reg in _all_registries():
caps = [c for cs in reg.capabilities.values() for c in cs] + list(reg.pattern_capabilities)
for cap in caps:
for entity in cap.entities:
if isinstance(entity, SensorDesc) and (reg.name, entity.key) not in seen:
seen.add((reg.name, entity.key))
yield reg.name, entity
def test_every_sensordesc_device_class_is_valid_for_ha():
bad = []
for reg_name, desc in _sensor_descs():
if desc.device_class is None:
continue
try:
SensorDeviceClass(desc.device_class)
except ValueError:
bad.append((reg_name, desc.key, desc.device_class))
assert bad == []
def test_every_declared_unit_is_valid_for_its_device_class():
"""Descriptors carrying a `unit_fn` are exempt: those resolve their unit
from the live rep (a device reporting Celsius vs Fahrenheit), so there
is no static value to check here."""
bad = []
for reg_name, desc in _sensor_descs():
if desc.device_class is None or desc.unit_fn is not None:
continue
units = DEVICE_CLASS_UNITS.get(SensorDeviceClass(desc.device_class))
if units is not None and desc.unit not in units:
bad.append(
(reg_name, desc.key, desc.device_class, desc.unit, sorted(str(u) for u in units))
)
assert bad == []
def test_particulate_units_use_has_own_mu_codepoint():
"""The membership test above already fails on U+00B5, but only while a
PM device_class is attached. Asserting the codepoint directly keeps the
reason legible when someone re-types the literal."""
from custom_components.localthings.registry.capabilities import air_purifier
micro_sign, greek_mu = chr(0x00B5), chr(0x03BC)
for _key, _icon, _type, _state_class, device_class, unit in air_purifier._AIR_QUALITY_SENSORS:
if device_class is None:
continue
assert unit is not None, device_class
assert unit == f"{greek_mu}g/m³", (device_class, [hex(ord(c)) for c in unit])
assert micro_sign not in unit, device_class
+115
View File
@@ -0,0 +1,115 @@
"""An enum sensor's reported state must always be inside its options.
Home Assistant raises for an enum sensor whose state isn't in `options`
(sensor/__init__.py: "provides state value ... which is not in the list of
options provided"), so a value outside the list isn't a cosmetic problem --
it takes the entity out.
Two ways that bites, both from PR #341 giving `progress` a `device_class`
of enum:
- the sticky hold (issue #345) froze the entity at the device's raw
'Finish' while `rep_fn` had been normalized to 'finish', so every
completed cycle -- the exact path #345 exists to serve -- produced a
state outside the options;
- any progress value not in the translation catalog. Every token the
shipped fixtures advertise is covered today, but this registry's rule is
that an unrecognized device value renders raw rather than breaking, and
Samsung ships more devices than we have dumps for.
"""
from __future__ import annotations
from typing import cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SensorDesc
from custom_components.localthings.sensor import LocalThingsSensor
_HREF = "/operational/state/vs/0"
_PROGRESS = next(
e for e in OPERATIONAL_STATE.entities if e.key == "progress" and isinstance(e, SensorDesc)
)
_ALL_BOUND = [
BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
for desc in OPERATIONAL_STATE.entities
]
class _FakeConfigEntry:
def __init__(self):
self.options: dict = {}
class _FakeCoordinator:
def __init__(self):
self.device_serial = "TEST-SERIAL"
self.config_entry = _FakeConfigEntry()
self.resources: dict[str, dict] = {}
def resource(self, href: str) -> dict:
return self.resources.get(href) or {}
@property
def data(self) -> dict:
return flatten(_ALL_BOUND, self.resources)
def _sensor(desc):
coordinator = _FakeCoordinator()
bound = BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
return LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound), coordinator
def _set(coordinator, **fields):
coordinator.resources[_HREF] = {f"x.com.samsung.da.{k}": v for k, v in fields.items()}
def test_the_sticky_hold_freezes_at_a_value_inside_the_options():
"""Issue #345's grace window fires on every finished cycle, so a held
value outside the options would break the common path, not an edge."""
sensor, coordinator = _sensor(_PROGRESS)
_set(coordinator, state="Run", progress="Wash")
assert sensor.native_value == "wash"
# Cycle finishes, then the device drops out of active -- the hold engages.
_set(coordinator, state="Run", progress="Finish")
assert sensor.native_value in sensor.options
_set(coordinator, state="Ready", progress="Finish")
held = sensor.native_value
assert held == "finish"
assert held in sensor.options
def test_a_progress_value_we_cannot_translate_still_reports():
"""An unrecognized device value renders raw rather than taking the
entity out -- the same rule the course tables follow."""
sensor, coordinator = _sensor(_PROGRESS)
_set(coordinator, state="Run", progress="SomeFutureStage")
value = sensor.native_value
assert value == "somefuturestage"
assert value in sensor.options
# ...and admitting it doesn't drop the translated ones.
assert "rinse" in sensor.options
def test_known_values_do_not_grow_the_options_list():
assert _PROGRESS.options is not None
sensor, coordinator = _sensor(_PROGRESS)
_set(coordinator, state="Run", progress="Rinse")
assert sensor.options == list(_PROGRESS.options)
def test_a_non_enum_sensor_has_no_options():
percentage = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage")
sensor, coordinator = _sensor(percentage)
_set(coordinator, state="Run", progressPercentage="40")
assert sensor.options is None
assert sensor.native_value == 40
+42 -42
View File
@@ -92,10 +92,10 @@ def test_holds_finish_after_state_leaves_active():
sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
_replace(coordinator, state="Ready") # device has moved on
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
def test_holds_finish_even_when_state_already_idle_at_first_observation():
@@ -106,11 +106,11 @@ def test_holds_finish_even_when_state_already_idle_at_first_observation():
sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Ready", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
# Still held on a later poll, even once the device stops repeating it.
_replace(coordinator, state="Ready")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
def test_progress_percentage_holds_100_regardless_of_the_raw_field_at_finish():
@@ -132,10 +132,10 @@ def test_real_data_flows_through_unheld_while_active():
sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Spin")
assert sensor.native_value == "Spin"
assert sensor.native_value == "spin"
_replace(coordinator, state="Run", progress="Rinse")
assert sensor.native_value == "Rinse"
assert sensor.native_value == "rinse"
def test_never_finished_stays_idle():
@@ -144,10 +144,10 @@ def test_never_finished_stays_idle():
sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Spin")
assert sensor.native_value == "Spin"
assert sensor.native_value == "spin"
_replace(coordinator, state="Ready")
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
def test_a_new_cycle_starting_overrides_the_hold():
@@ -156,13 +156,13 @@ def test_a_new_cycle_starting_overrides_the_hold():
sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
_replace(coordinator, state="Ready")
assert sensor.native_value == "Finish" # still held
assert sensor.native_value == "finish" # still held
_replace(coordinator, state="Run", progress="Wash")
assert sensor.native_value == "Wash"
assert sensor.native_value == "wash"
def test_a_running_stage_after_finish_does_not_break_the_hold():
@@ -177,24 +177,24 @@ def test_a_running_stage_after_finish_does_not_break_the_hold():
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40")
assert sensor.native_value == "Drying"
assert sensor.native_value == "drying"
_replace(coordinator, state="Run", progress="Cooling", progressPercentage="95")
assert sensor.native_value == "Cooling"
assert sensor.native_value == "cooling"
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
# The tail: a running stage again, state already idle.
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
# ...then the device settles, still inside the window.
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
time.sleep(0.25)
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
def test_progress_percentage_survives_the_same_tail():
@@ -223,16 +223,16 @@ def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released():
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
_replace(coordinator, state="Pause", progress="Wash")
assert sensor.native_value == "Finish" # held out, not released
assert sensor.native_value == "finish" # held out, not released
time.sleep(0.1)
assert sensor.native_value == "Idle" # what a paused appliance always shows
assert sensor.native_value == "idle" # what a paused appliance always shows
_replace(coordinator, state="Run", progress="Wash")
assert sensor.native_value == "Wash"
assert sensor.native_value == "wash"
def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
@@ -243,20 +243,20 @@ def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
for _ in range(3):
time.sleep(0.05)
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
# 0.15s of flapping so far -- the window still ends 0.3s after the
# first Finish, not 0.3s after the most recent re-entry.
time.sleep(0.2)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
@@ -274,23 +274,23 @@ def test_a_finish_after_the_window_closes_does_not_re_arm_it():
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
time.sleep(0.1)
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
# A real cycle in between is what makes it available again.
_replace(coordinator, state="Run", progress="Drying")
assert sensor.native_value == "Drying"
assert sensor.native_value == "drying"
_replace(coordinator, state="Run", progress="Finish")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
def test_hold_expires_after_sticky_seconds():
@@ -304,13 +304,13 @@ def test_hold_expires_after_sticky_seconds():
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
_replace(coordinator, state="Ready")
assert sensor.native_value == "Finish" # still within the window
assert sensor.native_value == "finish" # still within the window
time.sleep(0.1)
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever():
@@ -325,17 +325,17 @@ def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever():
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
time.sleep(0.03)
# Device still (incorrectly) reports Finish on every subsequent poll --
# must not restart the window.
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
time.sleep(0.03) # 0.06s total since the first sighting -- past 0.05s
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
assert sensor.native_value == "idle"
def test_non_sticky_sensor_is_unaffected():
@@ -361,11 +361,11 @@ def test_cycle_active_and_machine_state_are_never_held():
)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert progress_sensor.native_value == "Finish"
assert progress_sensor.native_value == "finish"
assert machine_state_sensor.native_value == "active"
_replace(coordinator, state="Ready")
assert progress_sensor.native_value == "Finish" # held
assert progress_sensor.native_value == "finish" # held
assert machine_state_sensor.native_value == "idle" # real-time, unaffected
@@ -377,8 +377,8 @@ def test_a_partial_update_that_omits_progress_does_not_erase_the_hold():
sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
_apply(coordinator, state="Ready") # partial merge, doesn't restate progress
assert coordinator.resources[_HREF]["x.com.samsung.da.progress"] == "Finish"
assert sensor.native_value == "Finish"
assert sensor.native_value == "finish"
+126 -3
View File
@@ -54,13 +54,18 @@ class _FakeSession:
self.post_calls: list[tuple[list[str], bytes]] = []
self.get_calls: list[list[str]] = []
self._post_code = post_code
self._get_reps: dict[str, list[dict]] = {}
self._get_reps: dict[str, list[dict | list]] = {}
def queue_get(self, href: str, rep: dict) -> None:
def queue_get(self, href: str, rep: dict | list) -> None:
"""Queue one more canned rep for `href`'s next GET. Once an href's
queue is down to one entry, that entry keeps answering every
further GET -- a test only needs to queue the values that
actually change across calls."""
actually change across calls.
A list models a Collection's answer (the `[devcol rep, {href, rep},
...]` batch), which is not a Property map and so is a shape the
read path has to carry separately -- see the collection test below.
"""
self._get_reps.setdefault(href.strip("/"), []).append(rep)
def post(self, path_segs, payload, timeout=None):
@@ -292,6 +297,7 @@ async def test_write_resource_verify_after_reports_held(hass, coordinator, devic
verified = response["verified"]["/mode/vs/0"]
assert verified["held"] is True
assert verified["rep"] == {"x.field": "target"}
assert verified["read_error"] is None
async def test_write_resource_verify_after_reports_reverted(hass, coordinator, device_id):
@@ -319,6 +325,42 @@ async def test_write_resource_verify_after_reports_reverted(hass, coordinator, d
assert verified["rep"] == {"x.field": "original"}
async def test_write_resource_verify_after_survives_a_failed_confirmation_read(
hass, coordinator, device_id, monkeypatch
):
"""The write itself already landed (see `results`, built before
verify_after's wait even starts) by the time the confirmation read runs
-- a session dying in the gap verify_after waits out (smartthings-local's
redacted SessionClosedError/SessionTimeoutError, or any other exception)
must not lose that outcome behind a raised exception. Same "couldn't
verify" posture as a 4.04/empty read: `held` stays None, not False."""
fake = _FakeSession()
fake.queue_get("mode/vs/0", {"x.field": "target"}) # write's own follow-up read
coordinator._session = fake
def _boom(path_segs, href):
raise ConnectionError("session closed")
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
with patch(_SLEEP_TARGET, new_callable=AsyncMock):
response = await _call_write(
hass,
device_id,
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
verify_after=30,
)
# The write's own results survive even though verification blew up.
assert response["results"][0]["accepted"] is True
verified = response["verified"]["/mode/vs/0"]
assert verified["held"] is None
assert verified["rep"] == {}
# raw_code 0 alone is indistinguishable from a real 4.04 -- read_error
# is what tells a caller this was an unreachable session, not a reply.
assert verified["read_error"] == "session closed"
async def test_write_resource_no_verified_key_when_verify_after_is_zero(
hass, coordinator, device_id
):
@@ -576,6 +618,87 @@ async def test_read_resource_with_href_does_live_get(hass, coordinator, device_i
assert response["href"] == "/mode/vs/0"
assert response["actual_href"] == "/mode/vs/0"
assert response["rep"] == {"x.field": "live"}
# No duplicate copy of a Property map that `rep` already carries.
assert "body" not in response
async def test_read_resource_surfaces_a_collections_list_body(hass, coordinator, device_id):
"""A Collection answers a CBOR list, not a Property map, so `rep` can't
hold it (issue #335: `/sec/devices` came back as an accepted-but-empty
2.05, which reads as "exists, nothing in it" -- the opposite of what a
populated batch means)."""
batch = [
{"rt": ["x.com.samsung.devcol", "oic.wk.col"]},
{"href": "/mode/vs/0", "rep": {"x.field": "live"}},
]
fake = _FakeSession()
fake.queue_get("sec/devices", batch)
coordinator._session = fake
response = await _call_read(hass, device_id, href="/sec/devices")
assert response["code"] == "2.05"
assert response["rep"] == {}
assert response["body"] == batch
async def test_read_resource_failure_is_surfaced_as_a_home_assistant_error(
hass, coordinator, device_id, monkeypatch
):
"""A session/network failure during a live debug read (e.g.
smartthings-local's redacted SessionError, or any other exception) must
not reach the service caller raw and untranslated -- write_resource
already goes through HomeAssistantError on failure, and async_raw_read
must match that instead of letting the exception escape uncaught."""
def _boom(path_segs, href):
raise ConnectionError("session closed")
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
with pytest.raises(HomeAssistantError):
await _call_read(hass, device_id, href="/mode/vs/0")
async def test_read_resource_failure_closes_a_confirmed_dead_session(
hass, coordinator, device_id, monkeypatch
):
"""A non-timeout failure is unambiguous (same TimeoutError-vs-anything-
else split as _poll_once) -- leaving a confirmed-dead session installed
would fail every subsequent read/write identically until the next real
poll cycle's own reconnect notices, up to a full update_interval later."""
coordinator._session = _FakeSession()
def _boom(path_segs, href):
raise ConnectionError("session closed")
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
with pytest.raises(HomeAssistantError):
await _call_read(hass, device_id, href="/mode/vs/0")
assert coordinator._session is None
async def test_read_resource_timeout_does_not_close_the_session(
hass, coordinator, device_id, monkeypatch
):
"""The other half of the same distinction: a block-ACK TimeoutError
alone doesn't prove the session is dead (see _poll_once), so unlike
any other failure it must not tear down a session that might still be
perfectly fine."""
fake = _FakeSession()
coordinator._session = fake
def _boom(path_segs, href):
raise TimeoutError("GET timeout")
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
with pytest.raises(HomeAssistantError):
await _call_read(hass, device_id, href="/mode/vs/0")
assert coordinator._session is fake
async def test_read_resource_without_href_returns_cached_snapshot_and_does_not_get(
@@ -0,0 +1,161 @@
"""The v2 -> v3 statistics relabel against a real recorder, not a mock.
tests/localthings/test_statistics_migration.py proves the migration calls
HA's API with the right arguments for the right entities. It cannot prove
that call does what the migration needs, because the recorder is patched
out. This drives an in-memory recorder end to end: statistics recorded
unitless, migration run, metadata inspected -- and, most importantly, the
recorded *values* checked to be untouched, which is the claim that makes
doing this automatically safe rather than something to ask each user about.
Lives here rather than under tests/localthings/ on purpose: that package's
autouse `enable_custom_integrations` fixture depends on `hass`, which
starts Home Assistant before `recorder_mock` can claim its database URL.
Nothing here loads the integration -- `async_migrate_entry` is called
directly -- so the entry only needs the one key the v2 -> v3 step reads.
"""
from __future__ import annotations
from datetime import timedelta
from functools import partial
from typing import cast
import pytest
from homeassistant.components.recorder.models import StatisticMeanType, StatisticMetaData
from homeassistant.components.recorder.statistics import (
async_import_statistics,
get_metadata,
statistics_during_period,
)
from homeassistant.components.recorder.util import get_instance
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.components.recorder.common import (
async_wait_recording_done,
)
from custom_components.localthings import async_migrate_entry
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
from custom_components.localthings.registry.entities import SensorDesc
SERIAL = "TEST-SERIAL-0000"
RECORDED = [11.0, 9.0, 14.0]
async def _seed_unitless_statistics(hass: HomeAssistant, entity_id: str) -> None:
"""Record hourly statistics the way these sensors always have: numeric
means, no unit of measurement at all."""
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
async_import_statistics(
hass,
{
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": entity_id,
"unit_class": None,
"unit_of_measurement": None,
},
[
{"start": start + timedelta(hours=i), "mean": value, "min": value, "max": value}
for i, value in enumerate(RECORDED)
],
)
await async_wait_recording_done(hass)
async def _metadata(hass: HomeAssistant, entity_id: str) -> StatisticMetaData:
result = await get_instance(hass).async_add_executor_job(
partial(get_metadata, hass, statistic_ids={entity_id})
)
return result[entity_id][1]
async def _means(hass: HomeAssistant, entity_id: str) -> list[float]:
rows = await get_instance(hass).async_add_executor_job(
statistics_during_period,
hass,
dt_util.utcnow() - timedelta(days=1),
None,
{entity_id},
"hour",
None,
{"mean"},
)
return [cast(float, row["mean"]) for row in rows.get(entity_id, [])]
@pytest.fixture
def purifier_entry(hass: HomeAssistant) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE_TYPE: "air_purifier"},
unique_id=f"{DOMAIN}_{SERIAL}",
version=2,
)
entry.add_to_hass(hass)
return entry
def _dust_entity(hass: HomeAssistant, entry: MockConfigEntry):
return er.async_get(hass).async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{SERIAL}_dust", config_entry=entry
)
async def test_relabels_metadata_without_touching_recorded_values(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
dust = _dust_entity(hass, purifier_entry)
await _seed_unitless_statistics(hass, dust.entity_id)
before = await _metadata(hass, dust.entity_id)
assert before["unit_of_measurement"] is None
assert await _means(hass, dust.entity_id) == RECORDED
assert await async_migrate_entry(hass, purifier_entry) is True
await async_wait_recording_done(hass)
after = await _metadata(hass, dust.entity_id)
assert after["unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
assert after["unit_class"] == "concentration"
# The readings were always µg/m³; only the label was missing. Nothing is
# converted, so the recorded history still says exactly what it said.
assert await _means(hass, dust.entity_id) == RECORDED
async def test_recorded_unit_ends_up_matching_what_the_descriptor_declares(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
"""The invariant the migration exists to establish, stated directly.
HA raises units_changed -- and suppresses statistics generation -- when
an entity's unit disagrees with the unit recorded against its
statistic_id. Rather than drive HA's validation to observe that, this
asserts the condition that validation reads: after migrating, the
recorded metadata says exactly what the descriptor says. Asserting our
own invariant instead of Home Assistant's reaction to it keeps the test
off internals that change between versions (both `_update_issues` and
`validate_statistics` have gained parameters), and tests this repo
rather than that one."""
from custom_components.localthings.registry.capabilities import air_purifier
dust = _dust_entity(hass, purifier_entry)
await _seed_unitless_statistics(hass, dust.entity_id)
desc = next(
d
for d in air_purifier.AIR_QUALITY.entities
if d.key == "dust" and isinstance(d, SensorDesc)
)
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] != desc.unit
assert await async_migrate_entry(hass, purifier_entry) is True
await async_wait_recording_done(hass)
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] == desc.unit
+21
View File
@@ -190,6 +190,27 @@ def test_confirmed_washer_table_02_missing_course_names():
}
def test_confirmed_washer_table_02_ww90dg5g34able_course_names():
"""Issue #363: 0A/B0 rendered as raw hex on a WW90DG5G34ABLE
(DA_WM_TP1_21_COMMON), whose other Table_02 labels the reporter
confirmed were already correct.
0A joins 33/54/70 as a Towels code -- checked in every locale, since a
locale that translated 0A differently from the Towels codes it shares a
meaning with would still pass the key-topology test, the same gap
issue #343 fell through.
"""
for language in _languages():
states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"]
assert states["0a"] == states["33"], language
assert states["b0"] != states["34"], language
english = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"]
assert {code: english[code] for code in ("0a", "b0")} == {
"0a": "Towels",
"b0": "Mixed Load",
}
def test_reported_washer_standard_courses_all_have_table_02_labels():
"""Every non-personal code in the reported washer's live course list
must resolve through the Table_02 catalog instead of appearing as raw