Compare commits

..
Author SHA1 Message Date
Marc Billow ba089dbb5c Bump smartthings-local floor to 0.1.8
Two releases landed since the 0.1.6 pin, both confirmed by upstream
(QuiteYellow, in issue #361) as additive/opt-in with no interface
changes on our side:

- 0.1.7: server-certificate profiles (SamsungServerProfile), a bounded
  DTLS handshake deadline (connect() now defaults to a 12s bound
  instead of none), and a cancellable connect() via
  ConnectCancellation. Our connect() call sites in coordinator.py and
  config_flow.py pass no args, so they pick up the bounded handshake
  for free; the cert-profile and cancellation pieces are opt-in and
  unused here.

- 0.1.8: fixes blockwise OBSERVE notification reassembly
  (QuiteYellow/SmartThings-Local#39) -- a notification carrying only
  the first Block2 block was previously handed straight to
  on_notification instead of being reassembled, and separately, the
  Block2 loop could append a retransmitted/late block as if it were
  the next one, or miscompute the next block offset after a mid-
  transfer size downshift. Both corrupt a multi-block observed
  resource without necessarily truncating it -- the "premature end of
  stream" / "error decoding unicode string" CBOR failures reported in
  issue #361 on /mode/vs/0. All error types stay within the existing
  compatible-built-in table (ConnectionError/TimeoutError subclasses),
  so no exception handling changes.

`>=0.1.6` already permitted pip to resolve 0.1.8 on a fresh install,
but an environment that already has 0.1.6 or 0.1.7 satisfying that
floor won't be upgraded by Home Assistant's requirement check -- which
is what #361's reporter is very likely still hitting on 0.22.0.
Raising the floor to >=0.1.8 forces that upgrade on the next release.

Verified against smartthings-local 0.1.8 from PyPI: full suite (1573
tests), ruff, and ty all pass. No source changes needed beyond the
three version pins (manifest.json, requirements-dev.txt, Dockerfile).
2026-08-16 03:44:11 +00:00
Marc Billow 0d0ffb57dd Merge pull request #375 from mbillow/claude/issue-367-hnrc8y
airconditioner: ungate outdoor_temperature from is_legacy_board
2026-08-15 16:47:45 -05:00
Marc Billow fe0db8586f airconditioner: ungate outdoor_temperature from is_legacy_board
The OutdoorTemp_ options token was only surfaced on legacy boards
(is_legacy_board), even though 14 of 17 fixtures carrying the token are
non-legacy. issue #367 confirmed with a 48h field capture (r=0.92
against weather.forecast_home) that the token tracks real outdoor
temperature independent of board generation, and that no non-legacy
board exposes an alternative outdoor-temperature resource.

Split a token-presence-only exists_fn (_has_option_token_any_board) for
this token, leaving _has_option_token's legacy gate untouched for the
other options[] settings that still need it. The -55 offset itself was
only field-validated on Celsius-locale boards, so a second gate
(_reports_celsius, reading the board's own /temperatures/vs/0) keeps
the sensor off the one Fahrenheit-locale fixture on record rather than
guess whether the same offset and unit still apply there. Ships
enabled_default=False since multi-split installs report the same token
on every indoor head, which would otherwise create one duplicate active
sensor per head.

Updates the golden fixtures for the 13 affected Celsius-locale boards
and the artik051_krac test that had asserted outdoor_temperature stays
off newer boards; adds coverage for the Fahrenheit-locale gate.
2026-08-15 21:38:56 +00:00
Marc Billow 8814fffa4f airconditioner: ungate outdoor_temperature from is_legacy_board
The OutdoorTemp_ options token was only surfaced on legacy boards
(is_legacy_board), even though 14 of 17 fixtures carrying the token are
non-legacy. issue #367 confirmed with a 48h field capture (r=0.92
against weather.forecast_home) that the token tracks real outdoor
temperature independent of board generation, and that no non-legacy
board exposes an alternative outdoor-temperature resource.

Split a token-presence-only exists_fn (_has_option_token_any_board) for
this token, leaving _has_option_token's legacy gate untouched for the
other options[] settings that still need it. Ships enabled_default=False
since multi-split installs report the same token on every indoor head,
which would otherwise create one duplicate active sensor per head.

Updates the golden fixtures for the 14 affected boards and the
artik051_krac test that had asserted outdoor_temperature stays off
newer boards.
2026-08-15 21:22:09 +00:00
Marc Billow 12922909c0 Merge pull request #374 from mbillow/claude/pr-303-code-review-6mghcs
Load a config entry offline from the last discovery snapshot
2026-08-15 15:54:25 -05:00
Marc Billow 9d3782a29a Harden the snapshot path against three review findings
Widen async_rehydrate's guard to cover the identity and Subdevice rebuild,
not just the replay. A stored row missing a field the current dataclass
declares raised KeyError straight out of async_setup_entry, which only
handles ConfigEntryNotReady -- so the entry landed in SETUP_ERROR, which HA
never retries, with its DTLS session left open on the fixed source port the
next attempt binds. It now fails the same way an unreachable device does.

Write the snapshot immediately instead of through async_delay_save. A
deferred write outlives whatever queued it: removing an entry inside the
delay window deleted the file and then had it recreated, orphaned, when the
timer fired; and a reload scheduled by _reconcile_rehydrated read the
pre-reload snapshot back off disk, so a device going quiet again mid-reload
rehydrated the stale set and reconciled a second time. Banking it before the
reconcile fixes the ordering. Failures are logged rather than raised -- a
board reporting something the JSON encoder rejects must not break polling.
2026-08-15 20:32:46 +00:00
Marc Billow edff7385c6 Raise the coverage-gap Repair only from a live poll
A coverage gap is a claim about what the device currently reports, so
replaying a discovery snapshot shouldn't make it. Offline it would restate
the last live poll's conclusion while pointing the user at a diagnostics
download that stays empty until the appliance answers, and any drift in the
resolved device name between snapshot and live would churn the issue.

Not deduplication: HA already keys issues on (domain, issue_id), preserves
dismissed_version across async_get_or_create, and reloads non-persistent
issues with their dismissal intact -- one row per entry, and an "Ignore"
survives restarts.
2026-08-15 20:13:02 +00:00
Marc Billow e684146f61 Load a config entry offline from the last discovery snapshot (#295)
An appliance switched off at the wall used to take its whole config entry
down with it: async_setup_entry raised ConfigEntryNotReady, so the device
read as failed and its entities existed only as registry rows until the
appliance came back.

Loading the entry anyway isn't enough on its own. Entities here are the
output of discovery, discovery only runs inside a successful poll, and
platforms enumerate `bound` exactly once at forward time -- so an entry
that loads while offline loads empty, and with no listeners subscribed the
base coordinator stops rescheduling and never polls again.

Bank the resources dict each successful first cycle hands _run_discovery,
along with the subdevice candidate list and the /oic identity that route
the registry, and replay it through _run_discovery when the first refresh
fails. Storing the poll input rather than a rendered entity list keeps one
implementation of discovery instead of two: the offline entity set is
produced by the same code that produced the live one.

Three things fall out of that:

- Platforms judge entity existence against `discovery_resources`, not the
  live cache. The live cache deliberately stays empty, which is what keeps
  a restored entity `unavailable` rather than rendering a stale value for
  an appliance nobody can currently reach.
- A live discovery that disagrees with the snapshot reloads the entry --
  platforms can't adopt a changed set in place, so a firmware update or a
  sibling subdevice that starts answering needs a fresh setup.
- The entry holds one coordinator listener for its lifetime, so polling is
  scheduled regardless of how many entities are live.

An entry that has never reached the device has no snapshot, keeps raising
ConfigEntryNotReady, and closes its session on the way out as before -- no
metadata to build a device from, and it leaves room for setup flows that
need to interact with the appliance (#168).

Restores the two tests PR #303 rewrote, narrowed to that no-snapshot path.
2026-08-15 20:05:58 +00:00
Marc Billow bc03ada208 docs(offline-setup): what PR #303 measures, and what a working version needs
PR #303 loads the entry when the first poll fails. Measured on its
branch, that produces an entry with zero bound entities and zero
coordinator listeners, so DataUpdateCoordinator never reschedules and
the device never recovers without a manual reload.

Record why entities can't be created offline here (discovery is the only
source of `bound`, and platforms enumerate it once), what a working
version would need (persisted discovery snapshot, reconcile-on-reconnect,
a listener that keeps polling alive), and the cheaper retry-and-reload
option that solves the filed issue on its own.
2026-08-15 19:38:28 +00:00
firstof9@gmail.com 67012c57d7 Allow non-blocking setup when device is offline (#295)
When a device is offline or unreachable during Home Assistant startup,
 previously raised . HA's built-in
retry mechanism uses exponential backoff up to 15 minutes, which leads to
a poor user experience for local LAN devices.

Catch initial connection errors during  and log a
warning instead of failing setup. This allows platforms to set up and
entities to be created (in an unavailable state), while the coordinator
continues background retry polling.
2026-08-15 19:38:20 +00:00
Andy Warwick dbc5c55a97 docs(ac-filter-reset): record a board family with no local reset (#354)
The investigation is written around the FilterTime_<N> option token on
/mode/vs/0, and its conclusion holds for the ARTIK051_KRAC_18K it was
measured on. An ARTIK051_PRAC_20K has no such token: no FilterTime, no
FilterAlarmTime, no FilterCleanAlarm anywhere in its options blob. It
keeps the counter in /filter/airdustfilter/vs/0 as a percentage of a
500-hour interval instead.

Neither route resets it. FilterCleanAlarm_Clear to /mode/vs/0 returns
4.00 with the options blob byte-identical; writing filterUsage as the
string "0" returns 4.00; writing it as an integer returns 5.00. That
last difference is the useful part — two payloads differing only in JSON
type returning different codes rules out an unresolved href or an
unrecognised field name, leaving read-only as the reading.

Adds a scope line to the intro and a section documenting the board, its
two resource dumps, the attempt table, and an observation-only
workaround for percentage-counter boards.
2026-08-14 22:00:49 -05:00
Marc Billow 19a2c03609 Merge pull request #372 from mbillow/claude/dryer-type-translations
dryer/dishwasher: dryer_type translation (#366) + missing progress state
2026-08-14 21:59:27 -05:00
Marc Billow 76f2c3c0cd progress: add dryingwithdooropen ('Venting') across all locales
Confirmed on a live dishwasher's sensor.*_progress history (not in any
shipped fixture): Prewash -> Wash -> Rinse -> Drying ->
DryingWithDoorOpen -> Finish -> Idle. The door-open drying-assist stage
had no catalog entry, so it fell through to the sensor.py fallback and
rendered as the raw lowercased string 'dryingwithdooropen' instead of a
readable name.

No code change needed -- operational.py's progress SensorDesc already
derives its options from the catalog via translated_states(), so a new
state key is picked up automatically.
2026-08-15 02:56:50 +00:00
Marc Billow a59ef6cca9 dryer: translate dryer_type, folding in #366's Dutch additions
#366 added Dutch state translations for dryer_type ('Electricity') and
progress, but predates #371's lowercase-key normalization: its progress
states were already superseded (every one it added is already in nl.json's
current 'state' table, lowercase), and its dryer_type addition used the
pre-#371 capitalized key.

dryer_type itself was never wired for translation at all -- no
device_class, no options -- so nothing in any locale's dryer_type.state
table was ever read. Give it device_class=enum, options=("electricity",)
(the only value confirmed across shipped fixtures), and the same
value_fn=lower() normalization #371 used elsewhere, then add the
'electricity' state across all seven locale catalogs, not just Dutch.
2026-08-15 02:44:01 +00:00
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
Marc Billow 6d73ac8694 Bump version to 0.21.1 2026-08-13 02:31:48 +00:00
Marc Billow 1cfe126313 Merge pull request #360 from mbillow/claude/issue-357-5bsr88
laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
2026-08-12 11:28:59 -04:00
Marc Billow 8d1ecb4f2a laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
Adds washer_cycle_table_00 and dryer_cycle_table_00 translation catalog
entries, confirmed by the issue #357 reporter selecting each cycle on a
WF45R6300AW/US washer and DVE45R6300W/A3 dryer and reading back the raw
course code. Table_00 is a separate, older course-code family from the
existing Table_02/Table_03 catalogs -- laundry.cycle_select's table_href
scoping already keeps them apart, so this is a translations-only change.

Table_00 was previously used only as an example of an unconfirmed table in
tests; those now use Table_99 for that role, and new tests assert the
confirmed codes translate and that the resolved key routes to the new
table-scoped catalog entries.

Mirrored to all shipped languages (cs/de/es/it/ko/nl) to keep
tests/test_translations.py's key-for-key invariant.
2026-08-12 15:25:54 +00:00
Marc Billow 0c1231794a Merge pull request #359 from mbillow/claude/pr-346-regression-debug-a3x3pj
laundry: a post-Finish running stage is the cycle ending, not a new one
2026-08-12 10:43:47 -04:00
Marc Billow 67b28ed10f laundry: cite the machine_state history confirming #358's tail
The reporter's machine_state history for the same two cycles flips to
idle on the exact second progress reads 'Drying' (12:08:51 and 14:01:26),
which settles what the previous commits had to infer: rep_fn returns
'Idle' whenever state isn't active, so it cannot have produced that
value, and the only remaining path was the ungated sticky_live_fn the
bypass returned in its place. Replaying the sequence against the pre-fix
path reproduces the reported Cooling, Finish, Drying, Idle exactly; the
fix holds Finish through it.

Comments only -- swap the inference for the observation that confirms it.
2026-08-12 14:39:18 +00:00
Marc Billow f12f67b2b3 laundry: one hold per cycle, so the sticky bound is actually a bound
Review of the previous commit caught that its docstring promised more
than the code did. Not restarting an *open* window still let a progress
that flapped out of and back into Finish re-arm a full fresh window once
the first had expired, so the value could be held well past
sticky_seconds from the first Finish. The new test passed only because
its final read left the sticky condition matching; ending the flap on a
non-matching read re-armed and would have failed it.

Make the guarantee real instead of weakening the claim: arming marks the
hold spent, and only sticky_bypass_fn -- a cycle actually running --
clears it. Expiry on its own no longer re-opens the door, because with no
cycle in between a second Finish is the same Finish, and re-arming on it
strobes the entity Finish -> Idle -> Finish once per window, re-firing
the announcements #345 and #358 are both about.

That subsumes the old _sticky_armed edge-trigger flag, which existed to
stop a stuck field extending the window; "spent until a new cycle" covers
that case and the flap case together, before or after expiry.

Also give the flap test real headroom -- it fitted 0.09s of sleeps into a
0.1s window and would have failed spuriously on a loaded runner.
2026-08-12 14:18:56 +00:00
Marc Billow 2f7170c448 laundry: a post-Finish running stage is the cycle ending, not a new one
Fixes #358, a regression from #346. That PR's sticky_bypass_fn released
the Finish/100 hold on any concrete non-Finish progress code, ungated on
machine_state, reasoning that a new cycle's own progress can appear
before state catches up. But the reporting DA_WM_TP1_21_COMMON dryer
replays a running stage on the way *out* of a cycle: the issue's history
shows Cooling -> +60s Finish -> +24s 'Drying' -> +4s settled, twice,
identically. The bypass read that tail as a new cycle, dropped the hold,
and republished 'Drying' -- so progress read Drying, Cooling, Finish,
Drying, Idle instead of ending at Finish, Idle.

The tail is not new: rep_fn has always masked progress while state isn't
active, which is why it was invisible before #346. What surfaced it was
sticky_live_fn, a second, ungated view of the same field that the bypass
returned in rep_fn's place -- letting the hold publish a value the entity
otherwise never shows.

Both halves are fixed:

- The bypass (now _new_cycle_running) requires state == 'active'
  alongside the progress code. The arm condition stays ungated -- failing
  to arm loses the Finish entirely (#345), while releasing late costs
  nothing, since the hold expires on its own.
- sticky_live_fn is gone. rep_fn is the only definition of a live value;
  the hold decides only whether to freeze, and the bypass returns rep_fn's
  own result.

Also stop an already-open window from being restarted by a progress that
flaps in and out of Finish, so sticky_seconds is measured from the first
Finish of a cycle and the documented bound actually holds.

A paused new cycle no longer cuts the hold short (it did under the old
ungated bypass). Nothing live is withheld by that: rep_fn shows Idle
while paused with or without a hold, so the only change is a stale Finish
expiring on schedule -- and 'paused' cannot be told apart from this tail.
2026-08-12 13:44:24 +00:00
Marc Billow b8ef430ed4 Merge pull request #356 from mbillow/claude/alert-read-action-guidance-2lwu5a
Fix stuck alarm_code: never merge /alarms/vs/0 onto stale cache
2026-08-11 22:43:23 -04:00
Marc Billow cd3a47f9a4 Fix stuck alarm_code: never merge /alarms/vs/0 onto stale cache (#348)
ObserveManager.apply() shallow-merges every incoming rep onto whatever's
already cached for that href (issue #27's fix for /mode/vs/0's partial
notifies). That assumes an absent key always means "unchanged, keep the
old value" -- true for /mode/vs/0's supportedOptions, but backwards for
/alarms/vs/0: entity.py already documents {} as this resource's
canonical no-alarm state, and a live read_resource GET on the reporter's
washer confirmed the board sends exactly that {} when an alarm clears.
Merging it onto the prior rep left the stale ErrorCode_DC entry in the
cache forever, surviving power cycles and only clearing on a full
integration reload (which rebuilds the cache from scratch instead of
merging).

Add _is_alarms_href() to recognize /alarms/vs/<index> across every
subdevice-translated shape (MAIN identity, indexed renumbering, prefixed
UUID -- Subdevice.to_actual never touches the 'alarms/vs' stem) and have
apply() fully replace the cache for that href instead of merging. This
is a global fix: every family with an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so they were all exposed.
2026-08-12 02:41:02 +00:00
71 changed files with 4070 additions and 315 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.8"
+7 -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.
@@ -270,6 +270,12 @@ If reconnects become persistent (more than a handful per minute), something's ac
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
### Restarting while an appliance is powered off
If Home Assistant restarts while an appliance is unplugged or switched off at the wall, its device and entities still load — restored from the last successful discovery, showing `unavailable` until the appliance answers again. Automations and dashboards keep referring to entities that exist, and the integration retries in the background, so the device comes back on its own within a poll cycle of being powered on. Entities read `unavailable` rather than their last known values on purpose: the integration can't verify what a disconnected appliance is doing, and recorded history is kept by the recorder either way.
This only applies to an appliance the integration has reached at least once. A brand-new device that has never answered has nothing to restore from, so setting it up still requires it to be reachable.
### Multi-subdevice ("2-in-1") air conditioner systems
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
+142 -12
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,8 +18,8 @@ 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 .coordinator import LocalThingsCoordinator
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator, snapshot_store
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,23 +250,40 @@ 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
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
coordinator = LocalThingsCoordinator(hass, entry)
# Before the first refresh, so the coordinator keeps rescheduling even
# when that refresh fails and leaves nothing subscribed: the base class
# only re-arms its timer while it has listeners, and an offline load can
# legitimately have zero live entities (every one of them disabled, say).
# Without this the entry loads once and never polls again (issue #295).
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
# `_poll_once` deliberately leaves the session up on a TimeoutError
# (see its docstring), so a refresh failing that way leaves a live,
# bound UDP socket nothing would ever close. HA retries setup with a
# new coordinator, and the source port is fixed by design
# (`_local_source_port`), so an abandoned socket would squat the
# exact port the next attempt binds.
await coordinator.async_close()
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
except ConfigEntryNotReady:
# An entry that has polled successfully before comes up on its last
# known entity set and keeps retrying on the normal poll interval,
# rather than sitting in setup-retry with a device that reads as
# broken and entities that exist only as registry rows (issue #295).
#
# An entry that has never reached the device has no snapshot, so
# there is nothing to show and no device metadata to name it with --
# that case still fails, which is also what keeps the door open for
# setup flows that need to interact with the device (issue #168).
if not await coordinator.async_rehydrate():
await coordinator.async_close()
raise
hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
@@ -220,6 +340,16 @@ async def async_remove_config_entry_device(
return not (device.identifiers & live)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Delete the discovery snapshot this entry accumulated (issue #295).
Nothing else would: the store is keyed on entry_id, so re-adding the same
appliance mints a new one and the old file would linger in .storage
forever.
"""
await snapshot_store(hass, entry).async_remove()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
+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 = ""
+370 -24
View File
@@ -9,6 +9,7 @@ import logging
import threading
import time
import zlib
from dataclasses import asdict
from datetime import timedelta
from typing import Any, cast
@@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.protocol.dtls_session import DtlsCoapSession
@@ -47,7 +49,7 @@ from .const import (
from .learned import LEARNABLE, LearnedModes, persist
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.adapter import _key, flatten
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.capabilities.common import (
@@ -83,6 +85,20 @@ _LOGGER = logging.getLogger(__name__)
_SEED_PATH = ["device", "0"]
# Discovery snapshot (issue #295): exactly what the last successful first
# cycle fed _run_discovery, so a restart can register the same entities
# while the appliance is unreachable. Kept in .storage rather than on the
# config entry -- it's device state, not configuration, and runs to tens of
# kilobytes.
_SNAPSHOT_VERSION = 1
def snapshot_store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]:
"""This entry's discovery-snapshot store. A free function so
`async_remove_entry` can delete the file without standing up a whole
coordinator to reach it."""
return Store(hass, _SNAPSHOT_VERSION, f"{DOMAIN}.{entry.entry_id}.discovery")
class _NoOpDescriptor:
"""No-op: StateCache requires an on_observation hook; this integration
@@ -103,10 +119,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 +224,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
@@ -240,6 +264,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._identity: DeviceIdentity | None = None
self._discovered = False
self.bound = []
self._snapshot_store = snapshot_store(hass, entry)
# Both set only when this entry loaded from a snapshot instead of a
# live poll -- see async_rehydrate.
self._rehydrate_resources: dict[str, dict] | None = None
self._rehydrated_keys: frozenset[tuple[str, str]] | None = None
# Sibling indoor subdevices on this connection (issue #177); set
# once at first discovery, narrowed to the ones with live state (see
# subdevices.discover_partitioned). Never includes MAIN itself.
@@ -397,6 +426,35 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._canonical_cache[view_key] = view
return view
@property
def rehydrated(self) -> bool:
"""True while this entry's entities came from a snapshot rather than
a live poll (issue #295)."""
return self._rehydrate_resources is not None
@property
def discovery_resources(self) -> dict[str, dict]:
"""What entity._is_included should judge an entity's existence
against: the rehydration snapshot on an offline load, the live cache
otherwise.
Deliberately separate from `last_resources`, which stays empty until
the device answers -- that emptiness is what keeps a rehydrated
entity `unavailable` instead of rendering a snapshot's stale value.
Only read while platforms are being forwarded; nothing consults it
once the entities exist.
"""
if self._rehydrate_resources is None:
return self.last_resources
return self._rehydrate_resources
def discovery_canonical(self, subdevice: Subdevice) -> dict[str, dict]:
"""`discovery_resources` in `subdevice`'s canonical view -- the
exists_fn counterpart to canonical_resources."""
if self._rehydrate_resources is None:
return self.canonical_resources(subdevice)
return canonical_view(subdevice, self._rehydrate_resources, self.subdevices)
# ------------------------------------------------------------------
# Learned modes (issue #327)
# ------------------------------------------------------------------
@@ -530,7 +588,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 +775,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()
@@ -983,7 +1049,148 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._entry, data={**self._entry.data, **identity}
)
def _run_discovery(self, resources: dict[str, dict]) -> None:
# ------------------------------------------------------------------
# Discovery snapshot (issue #295)
# ------------------------------------------------------------------
def _bound_keys(self) -> frozenset[tuple[str, str]]:
"""This entity set's identity: one (subdevice, state key) pair per
bound entity. `_key` is the unique_id suffix, so two discoveries that
agree here would register byte-identical entities."""
return frozenset((b.subdevice.key, _key(b)) for b in self.bound)
async def _async_save_snapshot(
self, resources: dict[str, dict], candidates: list[Subdevice]
) -> None:
"""Record what this first cycle handed `_run_discovery`, so the next
restart can replay it while the appliance is unreachable.
`candidates` is the pre-narrowing subdevice list (issue #177):
`discover_partitioned` takes candidates and returns the live ones, so
replaying against the narrowed list would rediscover nothing for a
composite appliance's siblings.
Written now rather than through `async_delay_save`, because a pending
delayed write outlives whatever queued it: it lands after
`async_remove_entry` has deleted the file and recreates it orphaned,
and a reload scheduled by `_reconcile_rehydrated` would read the
pre-reload snapshot back off disk. This runs once per entry load, so
the immediate write costs nothing worth deferring.
"""
ident = self._identity
try:
await self._snapshot_store.async_save(
{
"resources": dict(resources),
"subdevice_candidates": [asdict(su) for su in candidates],
"identity": asdict(ident) if ident is not None else None,
}
)
except Exception as e:
# Never fail a poll over the snapshot -- a board reporting
# something the JSON encoder rejects would otherwise break
# polling outright. Worst case this entry can't load offline,
# which is where it was before any of this existed.
self._log.warning("could not write discovery snapshot: %s", e)
async def async_rehydrate(self) -> bool:
"""Register the last known entity set without reaching the device.
Replays the stored snapshot through `_run_discovery`, which is what
makes this faithful: same code path, same registry resolution, so an
offline load produces the entity set the device last actually
reported rather than a guess reconstructed from a parallel format.
Returns False when there's nothing stored (an entry that has never
polled successfully) or the replay produced nothing usable -- the
caller raises ConfigEntryNotReady in that case, exactly as before.
"""
try:
stored = await self._snapshot_store.async_load()
except Exception as e: # corrupt or unreadable store
self._log.warning("could not read discovery snapshot: %s", e)
return False
if not stored or not stored.get("resources"):
return False
resources = stored["resources"]
try:
ident = stored.get("identity")
if ident is not None:
self._identity = DeviceIdentity(
manufacturer=ident.get("manufacturer") or "",
model=ident.get("model") or "",
name=ident.get("name") or "",
serial=ident.get("serial"),
device_types=tuple(ident.get("device_types") or ()),
raw=ident.get("raw") or {},
)
# JSON gives lists back where Subdevice declares tuples, and it's
# a frozen (hashable) dataclass used as a dict key in flatten().
self.subdevices = [
Subdevice(
kind=su["kind"],
key=su["key"],
seed_path=tuple(su.get("seed_path") or ()),
flat_hrefs=tuple(su.get("flat_hrefs") or ()),
)
for su in stored.get("subdevice_candidates") or ()
]
self._run_discovery(resources, from_snapshot=True)
except Exception as e:
# A snapshot written by an older release can outlive both the
# stored shape and the registry it was discovered against. This
# has to catch the rebuild as well as the replay: an exception
# escaping here reaches async_setup_entry, which only handles
# ConfigEntryNotReady, so the entry would land in SETUP_ERROR --
# never retried, and with its session left open.
self._log.warning("discovery snapshot could not be replayed: %s", e, exc_info=True)
self.bound = []
self.subdevices = []
return False
# _run_discovery sets _discovered; put it back. The snapshot only
# supplied an entity set to register -- the first live poll must
# still enumerate subdevices and rediscover for real.
self._discovered = False
self._rehydrate_resources = resources
self._rehydrated_keys = self._bound_keys()
if not self.bound:
return False
self._log.info(
"device unreachable; restored %d entities from the last discovery "
"snapshot and will keep retrying every %ds",
len(self.bound),
SUMMARY_INTERVAL_S,
)
return True
@callback
def _reconcile_rehydrated(self) -> None:
"""Reload the entry when a live discovery disagrees with the snapshot
this load registered from.
Platforms enumerate `bound` exactly once, at forward time, so a
firmware update, a newly-answering sibling subdevice or a different
appliance at the same IP can't be picked up in place -- the entry has
to come back up against the live set.
"""
if self._rehydrated_keys is None:
return
stale = self._rehydrated_keys
self._rehydrated_keys = None
live = self._bound_keys()
if live == stale:
return
self._log.info(
"live discovery differs from the snapshot this entry loaded from "
"(%d entities gone, %d new); reloading",
len(stale - live),
len(live - stale),
)
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
def _run_discovery(self, resources: dict[str, dict], from_snapshot: bool = False) -> None:
# Diagnostics only -- names the firmware generation (e.g. '7.0 Air
# conditioner' is Tizen Lite); doesn't route, since every device
# that reports it is already typed by modelNum.
@@ -1099,7 +1306,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
model=model,
)
self._persist_identity(serial, model, mfr, device_type_name)
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
if not from_snapshot:
# A coverage gap is a claim about what the device reports, so only
# a live poll gets to make it. Replaying a snapshot would restate
# last run's conclusion while pointing the user at a diagnostics
# download that is empty until the appliance answers, and any
# drift in the device name between the two would churn the issue.
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
self._hot_hrefs = sorted(hot)
self._warm_hrefs = sorted(warm)
@@ -1164,7 +1377,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 +1464,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 +1579,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
@@ -1334,7 +1619,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# StateCache has no eviction, so the only way to keep them out
# is to not put them in. Safe to reorder: _run_discovery reads
# the passed dict, never the cache.
candidates = list(self.subdevices)
self._run_discovery(resources)
# Banked before the reconcile below, so a reload it schedules
# comes up against this discovery rather than the one that is
# being replaced.
await self._async_save_snapshot(resources, candidates)
self._reconcile_rehydrated()
resources = self._live_subdevice_resources(resources)
sweep_mismatch = False
if self._observe.mode == MODE_OBSERVE:
@@ -1576,12 +1867,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 +1892,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 +1902,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 +1917,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 +2049,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 +2083,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if read_ok
else None
),
"read_error": read_error,
}
response["verified"] = verified
+6 -2
View File
@@ -35,12 +35,16 @@ def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> boo
`bound`'s own subdevice's canonical view instead of the raw snapshot,
same rule as everywhere else a whole-resources-dict scan happens --
this is a free function, so it can't use self._resources.
Reads `discovery_resources`, not `last_resources`: on an offline load
(issue #295) the live cache is still empty, and judging existence
against it would filter every rehydrated entity away.
"""
rep = coordinator.last_resources.get(bound.href)
rep = coordinator.discovery_resources.get(bound.href)
if rep is None:
return False
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
return bound.desc.exists_fn(rep, coordinator.discovery_canonical(bound.subdevice))
if bound.desc.field:
if not rep or is_stub_rep(rep):
return True
+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.8"
],
"version": "0.21.0"
"version": "0.22.0"
}
+35 -1
View File
@@ -56,6 +56,26 @@ SUCCESS_FRACTION = 0.8
PUSH_HEALTH_WINDOW_S = 60.0
def _is_alarms_href(href: str) -> bool:
"""True for /alarms/vs/<index> in any subdevice-translated shape --
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
(registry/subdevices.py) only ever rewrites the trailing index
segment or prepends a prefix -- it never touches the 'alarms/vs'
stem -- so matching that fixed segment plus a wildcard tail catches
every shape without this module needing to be subdevice-aware.
See `ObserveManager.apply`'s use of this for why the href matters:
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
is a complete snapshot of every currently-active alarm, not a
possibly-partial field update -- so it must never be merged onto a
stale prior rep (issue #348).
"""
head, _, _ = href.rpartition("/")
return head.endswith("/alarms/vs")
class ObserveManager:
"""Per-device observe-mode state: mode, write-settle guard, and (later)
subscription/staleness tracking. Pure sync logic — safe to call from
@@ -122,6 +142,20 @@ class ObserveManager:
comes through, even though nothing about the device's actual
supported options changed.
`_is_alarms_href` is the one exception to that merge (issue #348):
/alarms/vs/0's `items` array is always sent as a complete
snapshot of every currently-active alarm, never a partial delta
-- confirmed by a live `read_resource` GET returning `{}` (no
`items` key at all) the moment a washer's board actually clears
an alarm, which entity.py already documents as this resource's
normal no-alarm shape. Merging that `{}` onto the prior rep the
same way as everywhere else silently kept the stale `items`
entry forever: an absent key merges as "unchanged" everywhere
else, but on this href absent specifically means "cleared".
Every family that exposes an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so this is a full
replace for all of them, not a washer-specific carve-out.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
@@ -149,7 +183,7 @@ class ObserveManager:
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
changed = self.cache.apply_rep(href, merged, source=source)
# Outside the cache lock -- the hook takes locks of its own and
# never reads the cache back. `source` is passed along rather than
@@ -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")
@@ -365,6 +372,33 @@ def _has_option_token(prefix):
)
def _has_option_token_any_board(prefix):
"""Token-presence test with no board-generation gate.
`_has_option_token` above requires `is_legacy_board`, which was right for
the settings it guards but wrong for a token whose presence is itself the
only signal that needs checking -- issue #367 found `OutdoorTemp_` live
on 14 of 23 recorded fixtures despite none of them being legacy boards.
Same shape as `beep`'s Volume_ test and `_has_display_light_option`,
which already treat token presence as sufficient across generations."""
return lambda rep, resources: _option_token(rep, prefix) is not None
def _reports_celsius(resources):
"""Whether this subdevice's own /temperatures/vs/0 declares Celsius (or
says nothing at all, `_temps_vs_unit`'s default).
Guards `OutdoorTemp_`'s -55 offset below: issue #367's field validation
(48h against weather.forecast_home, r=0.92) ran on Celsius-locale boards
only -- 13 of the 14 non-legacy fixtures that carry the token declare
Celsius, and the offset's own calibration comment is a Celsius reading
too. The one Fahrenheit-locale exception on record
(`airconditioner_lnx_rac_heatpump`) has nothing to confirm the same
additive constant, or degrees C rather than F, still hold -- so this
stays off boards that declare anything else, rather than guess."""
return _temps_vs_unit(resources.get(HREF_TEMPS_VS) or {}) == "°C"
def _option_token_on(prefix):
return lambda rep: _option_token(rep, prefix) == "On"
@@ -775,13 +809,34 @@ CLIMATE = Capability(
),
# Outdoor temperature, offset by 55 -- calibrated against an
# independent thermometer (token 75 while it read 20.3°C).
#
# exists_fn is token-presence-only (issue #367), not is_legacy_board:
# gating it there dropped the sensor on every non-legacy board that
# reports it -- 14 of 17 fixtures carrying the token in the issue's
# own survey. A 48h/289-sample field capture correlated it against
# weather.forecast_home at r=0.92, ruling out a firmware constant.
# `_reports_celsius` keeps that presence check from also claiming
# the -55 Celsius offset for board generations it was never
# validated on -- see its own docstring.
#
# enabled_default=False: multi-split installs (multiple indoor heads
# on one outdoor condenser) report the same token on every head, so
# a fix here creates one identical sensor per head rather than one
# per physical unit (same duplication as the shared energy/power
# counters in issue #329). Left disabled so a user with several
# heads can enable just one instead of getting N duplicates active
# by default.
SensorDesc(
key="outdoor_temperature",
rep_fn=_option_token_num("OutdoorTemp", offset=55),
exists_fn=_has_option_token("OutdoorTemp"),
exists_fn=lambda rep, resources: (
_has_option_token_any_board("OutdoorTemp")(rep, resources)
and _reports_celsius(resources)
),
device_class="temperature",
state_class="measurement",
unit="°C",
enabled_default=False,
icon="mdi:home-thermometer-outline",
),
# Filter time in tenths of an hour, counting UP since last filter
@@ -1551,12 +1606,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
@@ -26,7 +27,17 @@ DRYER_SETTINGS = Capability(
entities=(
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
SensorDesc(
key="dryer_type",
field="x.com.samsung.da.dryerType",
icon="mdi:tumble-dryer",
device_class="enum",
# Only 'Electricity' confirmed across shipped fixtures (#366); an
# unrecognized value still passes through raw via sensor.py's
# options property rather than breaking the entity.
options=("electricity",),
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
@@ -46,6 +57,13 @@ DRYER_SETTINGS = Capability(
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
#
# dryer_cycle_table_00 is a separate, older course-code family reported by
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
# selected each cycle on the appliance and read back the resulting raw
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
#
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
# options[] array -- see laundry.drum_clean_cycles_remaining/
@@ -84,7 +102,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):
@@ -62,17 +63,21 @@ def _just_finished(rep):
return rep.get("x.com.samsung.da.progress") == "Finish"
def _live_progress_code(rep):
"""progress/progress_percentage's sticky_bypass_fn: a concrete,
non-Finish progress code being reported right now -- e.g. a new
cycle's own real 'Wash'/'Spin' -- must win over a still-open hold from
the previous cycle immediately. Not keyed on `state` (unlike
_is_active): _just_finished's whole premise is that `state` can't be
trusted to still say 'active' while a fresh, real progress value is
already there, and the same applies to recognizing when it's moved on
to a new one -- including while paused, e.g. adding a sock mid-hold."""
def _new_cycle_running(rep):
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
early once a new cycle is genuinely running.
Gated on `state == 'active'`, unlike _just_finished's arm condition
above: issue #358's dryer resets `progress` to its course's first
stage ('Drying') in the same moment `state` goes idle, ~4s before
settling to 'None' -- confirmed by the reporter's machine_state
history, which flips to idle on the exact second progress reads
'Drying', in both captured cycles. A bypass keyed on the progress
code alone read that as a new cycle and republished it. Releasing
late costs nothing -- an unreleased hold still expires on its own --
so this side takes the stronger signal."""
v = rep.get("x.com.samsung.da.progress")
return v is not None and v not in ("None", "Finish")
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
def _remaining_seconds(raw):
@@ -190,26 +195,25 @@ OPERATIONAL_STATE = Capability(
# sticky_* (issue #345): once progress reads 'Finish', keep
# showing Finish/100 for a grace window even after machine_state
# reverts, rather than falling to Idle/0 the instant it does --
# see sensor.py's _apply_sticky. rep_fn below is otherwise
# unchanged; the hold is entirely a read-side, per-entity concern,
# deliberately not gated on machine_state (_just_finished's
# docstring explains why). sticky_live_fn reads the raw field the
# same ungated way, for sticky_bypass_fn's benefit: a real
# progress value reported while paused (e.g. adding a sock
# mid-cycle) must win over a stale hold even though rep_fn itself
# would show "Idle"/0 there.
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
# stays the only definition of a live value -- the hold decides
# only *whether* to freeze. A second, ungated one here is what
# let issue #358's post-Finish tail reach the entity.
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",
sticky_live_fn=lambda rep: _progress(rep.get("x.com.samsung.da.progress")),
sticky_bypass_fn=_live_progress_code,
# 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(
key="progress_percentage",
@@ -222,8 +226,7 @@ OPERATIONAL_STATE = Capability(
),
sticky_fn=_just_finished,
sticky_value_fn=lambda rep: 100,
sticky_live_fn=lambda rep: _int(rep.get("x.com.samsung.da.progressPercentage")) or 0,
sticky_bypass_fn=_live_progress_code,
sticky_bypass_fn=_new_cycle_running,
),
# Only show finish time while actively running -- firmware leaves a
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
@@ -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
@@ -58,6 +61,15 @@ from .laundry import (
# the owner or device metadata falls back to washer_cycle_fallback, which
# surfaces a personal-course name only -- no invented English label for an
# unrecognized standard code (PR #251 review).
#
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
# cycle on the appliance and reading back the raw code, the same method used
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
# instance) still renders that code raw rather than borrowing a Table_02
# label -- the two tables are unrelated code spaces despite a handful of
# overlapping hex values.
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
@@ -65,15 +65,15 @@ class SensorDesc(SamsungEntityDescription):
# device-side revisions -- not a general-purpose flag.
hysteresis: bool = False
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
# for the full contract (arm/value/live/bypass semantics,
# edge-triggering, why this never touches the coordinator cache).
# for the full contract (arm/value/bypass semantics, one window per
# bypass, why this never touches the coordinator cache).
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
# moment (defaults to rep_fn's own result); sticky_bypass_fn forces
# sticky_live_fn's result through and drops the hold; sticky_seconds
# bounds how long it can hold.
# moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
# hold and lets rep_fn's own live result through; sticky_seconds
# bounds how long it can hold. There is deliberately no hook for
# computing a live value differently from rep_fn -- see issue #358.
sticky_fn: Callable[[dict], bool] | None = None
sticky_value_fn: Callable[[dict], Any] | None = None
sticky_live_fn: Callable[[dict], Any] | None = None
sticky_bypass_fn: Callable[[dict], bool] | None = None
sticky_seconds: float = 300.0
+51 -37
View File
@@ -49,12 +49,12 @@ 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
self._sticky_armed = False
self._sticky_spent = False
@property
def native_unit_of_measurement(self):
@@ -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)
@@ -82,52 +99,49 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
cache, so write_fn, diagnostics, and the observe-mode sweep
comparison keep seeing real device data throughout.
Reads `sticky_fn` and friends against this href's own live rep,
not the already-computed `raw`, so they can be independent of
whatever rep_fn itself gates on -- notably `sticky_live_fn`, which
is *not* `raw`: `raw` is rep_fn's own (possibly differently gated)
result, e.g. progress's rep_fn shows "Idle" while paused, but a
real progress value reported while paused (adding a sock mid-
cycle) must still win over a stale hold, which means reading it
ungated here rather than through that gate.
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
than the already-computed `raw`, so they can key on fields rep_fn
has collapsed away -- but they never compute a value. `raw` and
the frozen `sticky_value` are the only things returned here, so a
held entity and a free-running one agree on what "live" means; a
hook that broke that rule caused issue #358.
Edge-triggered, not level-triggered: the window only (re)starts on
a fresh False->True transition of `sticky_fn`, and -- this is the
part level-triggering alone misses -- expiry is still checked on
every call even while `sticky_fn` keeps matching. Without the
latter, a firmware that leaves the underlying field stuck matching
forever (the same class of quirk `_completion_minutes` already
works around) would show the frozen value forever too, defeating
"bounded, not indefinite".
At most one window per `sticky_bypass_fn` cycle: arming marks the
hold spent, and only the bypass clears that. So a `sticky_fn` that
keeps matching (firmware leaving the field stuck -- the quirk
`_completion_minutes` works around) can't extend the window, and
one flapping in and out can't restart it either, before or after
expiry. Expiry alone doesn't re-open the door: without something
the calibre of "a new cycle is actually running" in between, a
second Finish is the same Finish, and re-arming on it would strobe
the entity between held and live once per window -- exactly the
repeated announcements #345 and #358 are about.
`sticky_bypass_fn`, when it matches, always passes
`sticky_live_fn`'s result through and drops any hold -- for a
condition where "not sticky right now" is ambiguous between "went
idle, honor the hold" and "genuinely live, different data" (e.g. a
new cycle's own real progress), which "consult the hold whenever
sticky_fn is False" alone can't tell apart.
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
sticky right now" is ambiguous between "went idle, honor the hold"
and "genuinely moved on to new data". It is both the early release
and the only re-arm, so it should demand positive evidence of that
move; when unsure, letting the window run out is the cheaper
mistake.
"""
assert desc.sticky_fn is not None # native_value only calls this when set
rep = self.coordinator.resource(self._bound.href)
now = time.monotonic()
matches = desc.sticky_fn(rep)
if matches:
if not self._sticky_armed:
if desc.sticky_fn(rep):
if not self._sticky_spent:
self._sticky_value = (
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
)
self._sticky_until = now + desc.sticky_seconds
self._sticky_armed = True
else:
self._sticky_armed = False
if desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
return desc.sticky_live_fn(rep) if desc.sticky_live_fn is not None else raw
self._sticky_spent = True
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
self._sticky_spent = False
return raw
if self._sticky_until is not None and now < self._sticky_until:
return self._sticky_value
return raw
holding = self._sticky_until is not None and now < self._sticky_until
return self._sticky_value if holding else raw
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
+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": {
@@ -324,6 +331,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Cyklus",
"state": {
"01": "Normální",
"9c": "Intenzivní",
"a5": "Ložní prádlo",
"9e": "Nežehlivé prádlo",
"9b": "Parní dezinfekce+",
"27": "Osvěžení",
"a0": "Provětrání",
"a4": "Časové sušení",
"a6": "Rychlé sušení",
"a3": "Sportovní oblečení",
"a2": "Jemné prádlo"
}
},
"dryer_cycle_table_03": {
"name": "Cyklus",
"state": {
@@ -604,6 +627,22 @@
"extra_hot": "Extra horká"
}
},
"washer_cycle_table_00": {
"name": "Cyklus",
"state": {
"01": "Normální",
"70": "Intenzivní",
"55": "Bílé prádlo",
"71": "Ložní prádlo",
"72": "Dezinfekce",
"77": "Nežehlivé prádlo",
"57": "Samočištění+",
"73": "Máchání + odstřeďování",
"74": "Sportovní oblečení",
"75": "Jemné prádlo",
"78": "Rychlé praní"
}
},
"washer_cycle_table_02": {
"name": "Cyklus",
"state": {
@@ -611,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",
@@ -677,6 +717,7 @@
"8f": "Intenzivní studená",
"96": "Méně mikrovláken",
"a0": "15min rychlé praní",
"b0": "Smíšená náplň",
"35": "Eko bavlna"
}
},
@@ -840,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"
},
@@ -887,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"
@@ -905,7 +958,10 @@
"name": "Doba sušení"
},
"dryer_type": {
"name": "Typ sušičky"
"name": "Typ sušičky",
"state": {
"electricity": "Elektřina"
}
},
"dust": {
"name": "Prach"
@@ -1034,7 +1090,22 @@
"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í",
"dryingwithdooropen": "Větrání",
"cooling": "Chlazení",
"predrain": "Vypouštění",
"prewash": "Předpírka"
}
},
"progress_percentage": {
"name": "Průběh v procentech"
@@ -1526,6 +1597,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": {
@@ -324,6 +331,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Programm",
"state": {
"01": "Normal",
"9c": "Intensiv",
"a5": "Bettwäsche",
"9e": "Pflegeleicht",
"9b": "Dampf-Hygiene+",
"27": "Auffrischen",
"a0": "Lüften",
"a4": "Zeittrocknen",
"a6": "Schnelltrocknen",
"a3": "Sportkleidung",
"a2": "Feinwäsche"
}
},
"dryer_cycle_table_03": {
"name": "Programm",
"state": {
@@ -567,6 +590,22 @@
"extra_hot": "Extra heiß"
}
},
"washer_cycle_table_00": {
"name": "Programm",
"state": {
"01": "Normal",
"70": "Intensiv",
"55": "Weißwäsche",
"71": "Bettwäsche",
"72": "Hygienespülung",
"77": "Pflegeleicht",
"57": "Selbstreinigung+",
"73": "Spülen + Schleudern",
"74": "Sportkleidung",
"75": "Feinwäsche",
"78": "Schnellwäsche"
}
},
"washer_cycle_table_02": {
"name": "Programm",
"state": {
@@ -574,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",
@@ -621,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",
@@ -840,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"
},
@@ -881,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"
@@ -899,7 +952,10 @@
"name": "Trockenzeit"
},
"dryer_type": {
"name": "Trocknertyp"
"name": "Trocknertyp",
"state": {
"electricity": "Strom"
}
},
"dust": {
"name": "Staub"
@@ -1028,7 +1084,22 @@
"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",
"dryingwithdooropen": "Lüften",
"cooling": "Abkühlen",
"predrain": "Abpumpen",
"prewash": "Vorwäsche"
}
},
"progress_percentage": {
"name": "Fortschritt in Prozent"
@@ -1526,6 +1597,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": {
@@ -324,6 +331,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"9c": "Heavy Duty",
"a5": "Bedding",
"9e": "Perm Press",
"9b": "Steam Sanitize+",
"27": "Refresh",
"a0": "Air Fluff",
"a4": "Time Dry",
"a6": "Quick Dry",
"a3": "Active Wear",
"a2": "Delicates"
}
},
"dryer_cycle_table_03": {
"name": "Cycle",
"state": {
@@ -604,6 +627,22 @@
"extra_hot": "Extra hot"
}
},
"washer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"70": "Heavy Duty",
"55": "Whites",
"71": "Bedding",
"72": "Sanitize",
"77": "Perm Press",
"57": "Self Clean+",
"73": "Rinse + Spin",
"74": "Active Wear",
"75": "Delicates",
"78": "Quick Wash"
}
},
"washer_cycle_table_02": {
"name": "Cycle",
"state": {
@@ -611,6 +650,7 @@
"04": "Quick Wash",
"06": "XXL Laundry",
"08": "Rinse+Spin",
"0a": "Towels",
"17": "Downloaded",
"1b": "Cotton",
"1c": "Eco 40-60",
@@ -677,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": {
@@ -840,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"
},
@@ -887,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"
@@ -905,7 +958,10 @@
"name": "Dry time"
},
"dryer_type": {
"name": "Dryer type"
"name": "Dryer type",
"state": {
"electricity": "Electricity"
}
},
"dust": {
"name": "Dust"
@@ -1034,7 +1090,22 @@
"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",
"dryingwithdooropen": "Venting",
"cooling": "Cooling",
"predrain": "Pre-drain",
"prewash": "Pre-wash"
}
},
"progress_percentage": {
"name": "Progress percent"
@@ -1526,6 +1597,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": {
@@ -517,6 +527,22 @@
"4": "Alarma 4"
}
},
"dryer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normal",
"9c": "Servicio intensivo",
"a5": "Ropa de cama",
"9e": "Planchado fácil",
"9b": "Desinfección por vapor+",
"27": "Renovar",
"a0": "Aireación",
"a4": "Secado por tiempo",
"a6": "Secado rápido",
"a3": "Ropa deportiva",
"a2": "Delicados"
}
},
"dryer_cycle_table_03": {
"name": "Ciclo",
"state": {
@@ -797,6 +823,22 @@
"extra_hot": "Muy caliente"
}
},
"washer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normal",
"70": "Servicio intensivo",
"55": "Blancos",
"71": "Ropa de cama",
"72": "Desinfección",
"77": "Planchado fácil",
"57": "Autolimpieza+",
"73": "Aclarar + Centrifugar",
"74": "Ropa deportiva",
"75": "Delicados",
"78": "Lavado rápido"
}
},
"washer_cycle_table_02": {
"name": "Ciclo",
"state": {
@@ -804,6 +846,7 @@
"04": "Lavado rápido",
"06": "Colada XXL",
"08": "Aclarar + Centrifugar",
"0a": "Toallas",
"17": "Descargado",
"1b": "Algodón",
"1c": "Eco 40-60",
@@ -852,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",
@@ -1030,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"
},
@@ -1077,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"
@@ -1095,7 +1151,10 @@
"name": "Tiempo de secado"
},
"dryer_type": {
"name": "Tipo de secadora"
"name": "Tipo de secadora",
"state": {
"electricity": "Electricidad"
}
},
"dust": {
"name": "Polvo"
@@ -1224,7 +1283,22 @@
"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",
"dryingwithdooropen": "Ventilación",
"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": {
@@ -324,6 +331,22 @@
"4": "Allarme 4"
}
},
"dryer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normale",
"9c": "Intenso",
"a5": "Biancheria da letto",
"9e": "Pronto da stirare",
"9b": "Igienizzante a vapore+",
"27": "Rinfresca",
"a0": "Arieggiatura",
"a4": "Asciugatura a tempo",
"a6": "Asciugatura rapida",
"a3": "Abbigliamento sportivo",
"a2": "Delicati"
}
},
"dryer_cycle_table_03": {
"name": "Ciclo",
"state": {
@@ -604,6 +627,22 @@
"extra_hot": "Extra caldo"
}
},
"washer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normale",
"70": "Intenso",
"55": "Bianchi",
"71": "Biancheria da letto",
"72": "Igienizzante",
"77": "Pronto da stirare",
"57": "Self Clean+",
"73": "Risciacquo+Centrifuga",
"74": "Abbigliamento sportivo",
"75": "Delicati",
"78": "Lavaggio rapido"
}
},
"washer_cycle_table_02": {
"name": "Ciclo",
"state": {
@@ -611,6 +650,7 @@
"04": "Lavaggio rapido",
"06": "Bucato XXL",
"08": "Risciacquo+Centrifuga",
"0a": "Asciugamani",
"17": "Scaricato",
"1b": "Cotone",
"1c": "Eco 40-60",
@@ -677,7 +717,8 @@
"79": "Solo centrifuga",
"88": "Cura animali",
"35": "Cotone E",
"a0": "Rapido 15'"
"a0": "Rapido 15'",
"b0": "Carico misto"
}
},
"washer_dry_level": {
@@ -840,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"
},
@@ -887,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"
@@ -905,7 +958,10 @@
"name": "Tempo di asciugatura"
},
"dryer_type": {
"name": "Tipo di asciugatrice"
"name": "Tipo di asciugatrice",
"state": {
"electricity": "Elettricità"
}
},
"dust": {
"name": "Polvere"
@@ -1034,7 +1090,22 @@
"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",
"dryingwithdooropen": "Ventilazione",
"cooling": "Raffreddamento",
"predrain": "Scarico preliminare",
"prewash": "Prelavaggio"
}
},
"progress_percentage": {
"name": "Avanzamento percentuale"
@@ -1526,6 +1597,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": {
@@ -324,6 +331,22 @@
"4": "알림음 4"
}
},
"dryer_cycle_table_00": {
"name": "코스",
"state": {
"01": "표준건조",
"9c": "강력건조",
"a5": "이불",
"9e": "구김방지",
"9b": "스팀살균+",
"27": "리프레시",
"a0": "송풍",
"a4": "시간건조",
"a6": "쾌속건조",
"a3": "운동복",
"a2": "섬세의류"
}
},
"dryer_cycle_table_03": {
"name": "코스",
"state": {
@@ -604,6 +627,22 @@
"extra_hot": "고온수"
}
},
"washer_cycle_table_00": {
"name": "코스",
"state": {
"01": "표준세탁",
"70": "강력세탁",
"55": "흰옷",
"71": "이불",
"72": "살균",
"77": "구김방지",
"57": "통세척+",
"73": "헹굼+탈수",
"74": "운동복",
"75": "섬세의류",
"78": "쾌속세탁"
}
},
"washer_cycle_table_02": {
"name": "코스",
"state": {
@@ -611,6 +650,7 @@
"04": "쾌속세탁",
"06": "XXL 세탁",
"08": "헹굼+탈수",
"0a": "타월",
"17": "다운로드 코스",
"1b": "면",
"1c": "에코 40-60",
@@ -659,6 +699,7 @@
"8f": "강력 냉수 세탁",
"96": "미세플라스틱저감",
"a0": "15분 쾌속세탁",
"b0": "혼합 세탁",
"69": "AI 맞춤세탁",
"6a": "울",
"6b": "데님",
@@ -840,6 +881,12 @@
"stick_status": {
"name": "스틱 청소기 상태"
},
"stick_operation_mode": {
"name": "스틱 작동 모드"
},
"stick_cleaning_status": {
"name": "스틱 청소 상태"
},
"uvc_operation_time": {
"name": "UV-C 작동 시간"
},
@@ -887,10 +934,16 @@
"name": "온도"
},
"diagnosis": {
"name": "진단"
"name": "진단",
"state": {
"ready": "준비됨"
}
},
"diagnosis_status": {
"name": "진단 상태"
"name": "진단 상태",
"state": {
"ready": "준비됨"
}
},
"drum_clean_cycles_remaining": {
"name": "통세척까지 남은 횟수"
@@ -905,7 +958,10 @@
"name": "건조 시간"
},
"dryer_type": {
"name": "건조기 유형"
"name": "건조기 유형",
"state": {
"electricity": "전기"
}
},
"dust": {
"name": "미세먼지"
@@ -1034,7 +1090,22 @@
"name": "탐침 온도계 현재 온도"
},
"progress": {
"name": "진행률"
"name": "진행률",
"state": {
"idle": "대기",
"weightsensing": "세탁물 감지",
"wash": "세탁",
"rinse": "헹굼",
"spin": "탈수",
"finish": "완료",
"steaming": "스팀",
"airwashing": "에어워시",
"drying": "건조",
"dryingwithdooropen": "환기",
"cooling": "냉각",
"predrain": "사전 배수",
"prewash": "애벌빨래"
}
},
"progress_percentage": {
"name": "진행률"
@@ -1526,6 +1597,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": {
@@ -324,6 +331,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"9c": "Intensief",
"a5": "Beddengoed",
"9e": "Strijkvrij",
"9b": "Stoomhygiëne+",
"27": "Opfrissen",
"a0": "Luchtdrogen",
"a4": "Tijdprogramma",
"a6": "Snel drogen",
"a3": "Sportkleding",
"a2": "Fijne was"
}
},
"dryer_cycle_table_03": {
"name": "Programma",
"state": {
@@ -604,6 +627,22 @@
"extra_hot": "Extra heet"
}
},
"washer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"70": "Intensief",
"55": "Witte was",
"71": "Beddengoed",
"72": "Hygiëne",
"77": "Strijkvrij",
"57": "Self Clean+",
"73": "Spoelen + centrifugeren",
"74": "Sportkleding",
"75": "Fijne was",
"78": "Snelle was"
}
},
"washer_cycle_table_02": {
"name": "Programma",
"state": {
@@ -611,6 +650,7 @@
"04": "Snelle was",
"06": "XXL was",
"08": "Spoelen+centrifugeren",
"0a": "Handdoeken",
"17": "Gedownload",
"1b": "Katoen",
"1c": "Eco 40-60",
@@ -677,6 +717,7 @@
"8f": "Intensief koud",
"96": "Minder microvezels",
"a0": "15' Snelle was",
"b0": "Gemengde was",
"35": "Eco katoen"
}
},
@@ -840,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"
},
@@ -887,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"
@@ -905,7 +958,10 @@
"name": "Droogtijd"
},
"dryer_type": {
"name": "Type droger"
"name": "Type droger",
"state": {
"electricity": "Elektriciteit"
}
},
"dust": {
"name": "Stof"
@@ -1034,7 +1090,22 @@
"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",
"dryingwithdooropen": "Ventileren",
"cooling": "Koelen",
"predrain": "Vooraf afpompen",
"prewash": "Voorwas"
}
},
"progress_percentage": {
"name": "Voortgangspercentage"
@@ -1526,6 +1597,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."
},
+75
View File
@@ -15,6 +15,12 @@ that follows them explains why the reset looked cloud-only for as long as it
did — a genuine trap worth knowing about before the next reset-adjacent
mystery on this board family.
Scope: all of the above applies to boards that carry the counter as a
`FilterTime_<N>` option token on `/mode/vs/0`. Not every AC does. See
"Boards with no `FilterTime` token" at the end for an `ARTIK051_PRAC_20K`
that keeps the counter in its own resource, rejects both the token and a
direct write, and has no local reset at all.
## What the reset actually is
A **command**, not a value write. Samsung's cloud models it as capability
@@ -92,3 +98,72 @@ on every unit on record).
The entity key stays `filter_time` rather than `filter_time_elapsed`:
renaming it would change every existing unit's `entity_id`/`unique_id` for a
wording improvement only.
## Boards with no `FilterTime` token (`ARTIK051_PRAC_20K`)
Negative result, measured 2026-08-11 on integration v0.21.0 / HA 2026.8.1,
against one head of a three-head multi-split (`OptionCode_35880`,
`ExtendOptionCode_199181`). **There is no local reset on this board**, by
either route. Reset appears to be panel-only.
This generation does not put the counter in `/mode/vs/0` at all. Its
options blob carries no `FilterTime`, no `FilterAlarmTime` and no
`FilterCleanAlarm`:
```json
["Sleep_0", "ArtificialWorking_Off", "ComfortAICooling_Off",
"AiTempChanged_Off", "AiTemp_240", "OutdoorTemp_77", "CoolCapa_25",
"WarmCapa_32", "Light_Off", "Volume_100", "OptionCode_35880",
"ExtendOptionCode_199181", "RacInfo_None", "UpdateAllow_NotAllowed",
"DurationOn_0", "WelcomeCoolingState_Off"]
```
The counter lives in its own resource instead, as a **percentage** of a
500-hour interval rather than tenths of an hour —
`/filter/airdustfilter/vs/0`:
```json
{
"x.com.samsung.da.filterUsage": "96",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
}
```
Three attempts, all against a live unit deliberately put in `fan_only`
first — writes to a powered-off head on this board are dropped silently
with no error, which would otherwise be indistinguishable from a rejected
write:
| Target | Payload | Result |
|---|---|---|
| `/mode/vs/0` | `{"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]}` | 4.00, options blob byte-identical |
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": "0"}` | 4.00 |
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": 0}` (integer) | 5.00 |
`filterUsage` stayed at `96` throughout, verified by a live re-read after
each write rather than by the integration's optimistic state.
The last two rows are the informative pair. They differ only in JSON type
and return *different* codes, which rules out both boring explanations: an
unresolved href or an unrecognised field name would fail identically. The
board parses the field, faults on the wrong type, and still refuses the
value when typed as the string its own rep uses. The resource is
**read-only**, not mis-addressed.
One trap worth stating plainly: `filterResetType:
["replaceable","washable"]` describes what the filter *is*, not a reset
command that exists. It reads like a hint that a reset write is available
somewhere. It is not.
Since the integration cannot perform the reset here, it can still observe
it. The counter only climbs in normal use, so a downward crossing is
unambiguous: a `numeric_state` trigger with `below: 10` on
`sensor.<name>_filter_usage`, stamping an `input_datetime`, keeps an
honest "last cleaned" date without pretending a reset entity exists. The
blind spot is a reset performed while HA is down or the entry is
unloaded — no state transition, so that stamp has to be set by hand.
@@ -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.
+187
View File
@@ -0,0 +1,187 @@
# Loading a config entry while the appliance is offline
Issue #295 asks for faster recovery when a powered-off appliance comes back,
instead of waiting out HA's `ConfigEntryNotReady` backoff. PR #303 tried to
get there by catching the first-refresh failure in `async_setup_entry` and
loading the entry anyway.
That doesn't work here, and the reason is worth writing down: this
integration has no static entity list. Every entity comes from discovery,
and discovery only happens inside a successful poll.
(The issue's "up to 15 minutes" is out of date, incidentally. Current HA
retries on `2 ** min(tries, 4) * 5` seconds — capped at 80s, not 900. The
backoff was never the worst part; a device card reading "Retrying setup" with
no entities behind it is.)
## What PR #303 produces today
Measured on the PR's branch — set up with `_poll_once` raising, then advance
the clock four summary intervals:
| | |
| --- | --- |
| entry state | `LOADED` |
| `coordinator.bound` | 0 |
| entities in the state machine | 0 |
| registry entries | 1 (the disabled connection-mode sensor) |
| coordinator listeners | 0 |
| `_unsub_refresh` | `None` |
| poll attempts over the next 4 intervals | **0** |
The entry loads and then never polls again. `DataUpdateCoordinator._async_refresh`
reschedules only `if not auth_failed and self._listeners and not
self.hass.is_stopping`; with no bound entities the only unconditional entity
is `LocalThingsConnectionModeSensor`, which is
`entity_registry_enabled_default = False` and so never added and never
subscribes. Nothing reloads the entry either. The device comes back online to
an entry that is permanently empty until a manual reload — strictly worse
than the backoff it replaces, which did recover on its own within 15 minutes.
## Why entities can't just be created offline
Four independent gates, all of which need live device data:
1. `bound` is only ever assigned in `_run_discovery` (`coordinator.py:1081`),
which runs on a poll's `resources` dict.
2. All ten platforms enumerate `coordinator.bound` exactly once, at forward
time (`sensor.py:34` and siblings). Nothing adds entities later — the
invariant is already documented at `coordinator.py:1283-1286`.
3. `_is_included` (`entity.py:39`) returns False whenever `last_resources`
has no rep for the href. Even a fully reconstructed `bound` filters to
nothing while `StateCache` is empty.
4. `LocalThingsEntity` is a bare `CoordinatorEntity` with no `available`
override, no `RestoreEntity`, and no `Store` anywhere in the component. An
entity that did exist offline would be `unavailable` with no state.
The issue cites ESPHome, Shelly, LIFX and WLED as precedent for setup that
never fails. Those integrations can do it because each one has a *persisted
device description* to build entities from — ESPHome keeps its entity list in
`.storage`, Shelly caches device info. The pattern is portable; the mechanism
underneath it is the part PR #303 is missing.
## How the implemented version works
Three pieces, plus a gating rule.
### 1. A persisted discovery snapshot
After each successful first cycle, `_save_snapshot` banks exactly the
`resources` dict that cycle handed `_run_discovery`, along with the
pre-narrowing subdevice candidate list and the `DeviceIdentity` read from
`/oic/*`.
Storing the poll input rather than a rendered entity list is the decision
that keeps this honest. `BoundEntity` holds live
`Capability`/`SamsungEntityDescription` objects and isn't serializable, so
the alternative was a parallel format plus a re-resolution path — a second
implementation of discovery that could drift from the real one. Replaying the
input through `_run_discovery` means the same code, the same registry
resolution, and no second source of truth.
Three things ride along because `_run_discovery` reads them off `self`
rather than out of `resources`, and getting them wrong would silently resolve
a *different* registry offline than online — which reconciliation below would
then see as a real change and reload on every restart:
- `_identity.device_types` routes `resolve_registry`.
- `self.subdevices` is the candidate list `discover_partitioned` narrows;
replaying against the already-narrowed list finds no siblings at all.
- `_identity.manufacturer`/`model` feed `device_info`.
It lives in `.storage` (`Store`, keyed on entry_id) rather than on the config
entry: it's device state, not configuration, and runs to tens of kilobytes.
`async_remove_entry` deletes it with the entry.
The write is awaited, not `async_delay_save`d. A deferred write outlives
whatever queued it: it lands after `async_remove_entry` has deleted the file
and recreates it orphaned, and a reload scheduled by the reconcile below
would read the pre-reload snapshot back off disk. It runs once per entry
load, so there's nothing worth deferring. A write that fails is logged and
swallowed — a board reporting something the JSON encoder rejects must not
break polling.
### 2. Reconcile on reconnect
The snapshot is a claim about a device we haven't talked to yet. When the
first live poll lands, `_reconcile_rehydrated` compares the live entity set
against the rehydrated one — as `(subdevice key, _key(bound))` pairs, which
is the unique_id identity — and calls `async_schedule_reload` if they differ.
Gate 2 above is why this has to be a reload rather than an in-place fixup.
It's what makes the feature safe against a firmware update, a sibling
subdevice that starts answering, or a different appliance at the same IP.
### 3. Keep polling with no listeners
`async_setup_entry` holds one listener for the entry's lifetime:
```python
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
```
Registered *before* the first refresh, so scheduling survives a refresh that
fails. This alone fixes the measured "never polls again" bug, and covers a
rehydrated set whose entities are all registry-disabled. Removing the last
listener unschedules the timer, and HA runs `async_on_unload` callbacks when
setup raises, so the setup-retry path doesn't leak a polling coordinator.
### Gating rule: only load offline when there's a snapshot
An entry that has never successfully polled has nothing to restore and keeps
raising `ConfigEntryNotReady`. This is what answers the objection in the PR
thread — with a snapshot we *do* have metadata to build a device from, and
without one HA's backoff is still the right behavior. It also leaves room for
the #168-style flows that need to interact with the device during setup: a
device that never completed setup still blocks.
It also means `async_remove_config_entry_device` is no longer reachable with
an empty `coordinator.subdevices`, so an offline load can't offer to delete a
real-but-unreachable subdevice.
### The coverage-gap Repair stays live-only
`_run_discovery(..., from_snapshot=True)` skips `_update_coverage_gap_issue`.
The Repair points the user at a diagnostics download, which is empty until
the appliance answers, and a device name that drifts between the snapshot and
the live poll would churn the issue for no reason.
Not a de-duplication measure — HA already handles that. `async_create_issue`
is keyed on `(domain, issue_id)`, `dataclasses.replace` in
`async_get_or_create` leaves `dismissed_version` alone, and the registry
reloads non-persistent issues with their dismissal intact, so one row per
entry survives restarts and an "Ignore" sticks.
## What this still won't do
Entities will be present and `unavailable` — not showing their last values.
Gate 4 means last-known values require either `RestoreEntity` per platform or
persisting `StateCache`, and both mean asserting state the integration cannot
verify: a washer unplugged for a week would read "Running". HA's convention
is that unreachable means unavailable, and the recorder keeps the history
either way, so long-term statistics and history graphs are unaffected by this
choice.
Worth being explicit about, because it is the gap between what PR #303
promises in the thread ("load their previously recorded states") and what any
correct version can deliver.
## Rejected: zeroconf
The issue's other suggestion — wire zeroconf so the device's own boot
announcement triggers a retry, which is the genuinely idiomatic HA answer —
is a non-starter as things stand: there is no `zeroconf` or `dhcp` key in
`manifest.json` and the config flow is user-driven only, so HA has no
discovery signal for this integration to hang a retry on. It would first need
a confirmed mDNS service on the appliance. Worth revisiting if one turns up;
it would make recovery near-instant instead of within one poll interval.
## Rejected: the cheap version
Keeping `ConfigEntryNotReady` and adding a probe that calls
`async_schedule_reload` on first success would have fixed the recovery *time*
in about twenty lines, with no persistence and no reconcile. It was rejected
because it leaves the device reading as broken for as long as the appliance
is off, which is the half of issue #295 that actually bites — an appliance
switched off at the wall is offline for days, not seconds, and a whole
integration that looks failed for that entire window is the complaint.
+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.8
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
+1
View File
@@ -20,6 +20,7 @@
"fine_dust",
"humidity",
"odor",
"outdoor_temperature",
"power_watts",
"super_fine_dust",
"tropical_night_mode"
+1
View File
@@ -25,6 +25,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"power_energy_kwh",
"power_watts",
"selfcheck_error",
@@ -18,6 +18,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
+1
View File
@@ -36,6 +36,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"periodic_air_sensing",
"periodic_sensing_skip_status",
"power_watts",
+1
View File
@@ -17,6 +17,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
@@ -23,6 +23,7 @@
"last_air_sensing_level",
"last_air_sensing_time",
"mute_once",
"outdoor_temperature",
"periodic_air_sensing",
"periodic_sensing_skip_status",
"selfcheck_error",
+1
View File
@@ -19,6 +19,7 @@
"energy_saved_kwh",
"firmware_update",
"mute_once",
"outdoor_temperature",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
@@ -18,6 +18,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
@@ -16,6 +16,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
@@ -20,6 +20,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
@@ -15,6 +15,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
+1
View File
@@ -19,6 +19,7 @@
"fine_dust",
"humidity",
"odor",
"outdoor_temperature",
"power_watts",
"super_fine_dust",
"tropical_night_mode"
+1
View File
@@ -16,6 +16,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"selfcheck_error",
"selfcheck_result",
+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."""
+74 -5
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,
@@ -70,7 +71,12 @@ async def test_summary_interval(hass: HomeAssistant, mock_entry, mock_coordinato
async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None:
"""ConfigEntryNotReady raised when poll fails even after reconnect."""
"""ConfigEntryNotReady raised when poll fails even after reconnect.
An entry with no stored discovery snapshot has never reached this device,
so there is nothing to load offline from (issue #295) -- it stays on HA's
backoff rather than loading empty.
"""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
@@ -531,6 +537,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 +822,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:
@@ -1761,10 +1829,11 @@ async def test_first_refresh_timeout_recovers_via_reconnect(
async def test_first_refresh_persistent_timeout_fails_setup(
hass: HomeAssistant, mock_entry
) -> None:
"""When the reconnect times out too, the first refresh must fail so HA
retries on its backoff -- not load an entity-less entry. The session it
left open is closed on the way out (`_poll_once` keeps it up on a
`TimeoutError`, and the source port is fixed per device)."""
"""With no snapshot to load from, a reconnect that times out too must
fail the first refresh so HA retries on its backoff -- not load an
entity-less entry. The session it left open is closed on the way out
(`_poll_once` keeps it up on a `TimeoutError`, and the source port is
fixed per device)."""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
patch(
+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
+43
View File
@@ -58,6 +58,49 @@ def test_apply_merges_partial_update_onto_prior_rep():
assert cached["x.com.samsung.da.supportedOptions"] == ["CV_FDR_WINE", "CV_FDR_MEAT"]
def test_apply_fully_replaces_alarms_href_instead_of_merging():
"""Regression test for issue #348: /alarms/vs/0's `items` array is a
complete snapshot of every currently-active alarm, not a partial field
update like /mode/vs/0 (issue #27). A washer's board reports a cleared
alarm by omitting `items` entirely -- a live read_resource GET showed
`{}` -- so merging that onto the prior rep (as every other href does)
left the stale ErrorCode_DC entry in the cache forever. This must
instead behave like a full replace: the empty rep wins outright."""
mgr = _manager()
active = {
"x.com.samsung.da.items": [
{"x.com.samsung.da.code": "ErrorCode_DC", "x.com.samsung.da.state": "Created"}
]
}
mgr.apply("/alarms/vs/0", active, source="poll")
assert mgr.cache.get("/alarms/vs/0") == active
cleared = mgr.apply("/alarms/vs/0", {}, source="poll")
assert cleared is True
assert mgr.cache.get("/alarms/vs/0") == {}
def test_apply_fully_replaces_alarms_href_for_subdevice_shapes():
"""The same full-replace behavior must hold for both hrefs
`Subdevice.to_actual` can produce: an indexed subdevice renumbers only
the trailing '0' (/alarms/vs/1), and a prefixed one prepends a UUID
(/<uuid>/alarms/vs/0) -- neither ever touches the 'alarms/vs' stem
itself (registry/subdevices.py)."""
for href in ("/alarms/vs/1", "/6c2dff6d-ee5c-dad1-6a5e-000000000001/alarms/vs/0"):
mgr = _manager()
mgr.apply(
href,
{"x.com.samsung.da.items": [{"x.com.samsung.da.code": "ErrorCode_UB"}]},
source="poll",
)
cleared = mgr.apply(href, {}, source="poll")
assert cleared is True
assert mgr.cache.get(href) == {}
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
+418
View File
@@ -0,0 +1,418 @@
"""Loading a config entry while the appliance is unreachable (issue #295).
The device's entity set only exists as the output of a live poll, so coming
up offline means replaying the last successful discovery from a stored
snapshot. These tests pin the four things that makes load-bearing: the
snapshot gets written, it produces the same entity set offline, the
coordinator keeps polling until the device answers, and a live discovery that
disagrees with the snapshot reloads the entry rather than silently keeping a
stale set.
"""
from __future__ import annotations
from contextlib import contextmanager
from datetime import timedelta
from unittest.mock import patch
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import async_fire_time_changed
from custom_components.localthings.const import DOMAIN, SUMMARY_INTERVAL_S
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.identity import DeviceIdentity
from .conftest import _load_fridge_resources as _load_fridge
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
@contextmanager
def _reachable(resources: dict, identity: DeviceIdentity | None = None):
"""A device that answers, optionally with an /oic/* identity -- which
`_connect_session` is what normally reads, so a test that patches it out
otherwise leaves `_identity` None."""
def _connect(self) -> None:
self._identity = identity
with (
patch(f"{_COORD}._connect_session", _connect),
patch(f"{_COORD}._poll_once", return_value=resources),
patch(f"{_COORD}._close_session"),
):
yield
@contextmanager
def _unreachable():
with (
patch(f"{_COORD}._connect_session"),
patch(f"{_COORD}._poll_once", side_effect=OSError("device offline")),
patch(f"{_COORD}._close_session"),
):
yield
def _store_key(entry) -> str:
return f"{DOMAIN}.{entry.entry_id}.discovery"
async def _tick(hass: HomeAssistant) -> None:
"""Advance past one summary interval so the coordinator polls again.
`wait_background_tasks` is load-bearing: DataUpdateCoordinator runs its
interval refresh as a background task, which a plain block_till_done
doesn't await -- the poll would still be in flight at the assertion.
"""
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=SUMMARY_INTERVAL_S + 1))
await hass.async_block_till_done(wait_background_tasks=True)
async def _setup_online_then_unload(hass: HomeAssistant, entry, resources: dict) -> set[str]:
"""Bring the entry up against a live device, bank the snapshot, and take
it back down. Returns the entity_ids that run produced."""
with _reachable(resources):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_ids = {s.entity_id for s in hass.states.async_all()}
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
return entity_ids
# ---------------------------------------------------------------------------
# Writing the snapshot
# ---------------------------------------------------------------------------
async def test_snapshot_written_after_first_discovery(
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
) -> None:
"""A successful first cycle banks what it handed _run_discovery."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
stored = hass_storage[_store_key(mock_entry)]["data"]
assert stored["resources"]
assert "/information/vs/0" in stored["resources"]
assert "subdevice_candidates" in stored
async def test_snapshot_not_written_when_device_never_answers(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""Nothing to bank, so nothing is -- this is what keeps the no-snapshot
gate meaningful on a brand-new entry."""
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert _store_key(mock_entry) not in hass_storage
async def test_snapshot_removed_when_entry_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
) -> None:
"""The store is keyed on entry_id, so re-adding the appliance mints a new
one -- the old file has to go with the entry that wrote it.
The clock is run on afterwards because a deferred write would land here:
with `async_delay_save` the removal was undone a few seconds later by the
save the last poll had queued, leaving the file orphaned for good.
"""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert _store_key(mock_entry) in hass_storage
await hass.config_entries.async_remove(mock_entry.entry_id)
await hass.async_block_till_done()
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
await hass.async_block_till_done(wait_background_tasks=True)
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
# ---------------------------------------------------------------------------
# Loading from it
# ---------------------------------------------------------------------------
async def test_offline_load_restores_the_same_entity_set(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""The whole point: a restart with the appliance powered off comes up on
the entity set the device last actually reported."""
resources = _load_fridge()
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
assert online_ids # guard: the online run must actually produce entities
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.LOADED
assert {s.entity_id for s in hass.states.async_all()} == online_ids
async def test_offline_entities_are_unavailable_not_stale(hass: HomeAssistant, mock_entry) -> None:
"""Restored entities must not render the snapshot's values -- the
appliance is unreachable, so `unavailable` is the honest state and the
live cache stays empty to enforce it."""
resources = _load_fridge()
await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
states = hass.states.async_all()
assert states
assert all(s.state == "unavailable" for s in states)
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.rehydrated
assert not coordinator.last_resources
async def test_offline_load_without_snapshot_still_fails(hass: HomeAssistant, mock_entry) -> None:
"""No snapshot means no device metadata to build anything from, so the
entry stays on HA's backoff rather than loading empty."""
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert not hass.states.async_all()
async def test_malformed_snapshot_falls_back_to_setup_retry(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""A stored row missing a field the current dataclass declares must fail
the same way an unreachable device does.
Anything escaping async_rehydrate reaches async_setup_entry, which only
handles ConfigEntryNotReady -- so the entry would land in SETUP_ERROR,
which HA never retries, with its DTLS session left open on the fixed
source port the next attempt binds.
"""
key = _store_key(mock_entry)
hass_storage[key] = {
"version": 1,
"minor_version": 1,
"key": key,
"data": {
"resources": _load_fridge(),
"subdevice_candidates": [{"key": "1"}], # no "kind"
},
}
with (
_unreachable(),
patch.object(LocalThingsCoordinator, "async_close", autospec=True) as close,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
close.assert_awaited_once()
async def test_corrupt_snapshot_falls_back_to_setup_retry(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""A snapshot whose resources no longer replay cleanly must not take the
entry down with it."""
key = _store_key(mock_entry)
hass_storage[key] = {
"version": 1,
"minor_version": 1,
"key": key,
"data": {"resources": {"/information/vs/0": "not-a-rep"}, "subdevice_candidates": []},
}
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
async def test_snapshot_restores_identity(hass: HomeAssistant, mock_entry) -> None:
"""`/oic/d`'s device types route the registry, so an offline load that
lost them could resolve a different one than the live poll did -- which
would show up as a spurious reconcile reload every restart."""
resources = _load_fridge()
identity = DeviceIdentity(
manufacturer="Samsung",
model="TEST-MODEL",
name="Fridge",
serial=None,
device_types=("oic.d.refrigerator",),
raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": []},
)
with _reachable(resources, identity):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_unload(mock_entry.entry_id)
await hass.async_block_till_done()
with _unreachable():
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]
assert coordinator._identity is not None
assert coordinator._identity.device_types == ("oic.d.refrigerator",)
# ---------------------------------------------------------------------------
# Recovery and reconciliation
# ---------------------------------------------------------------------------
async def test_offline_load_keeps_polling_and_recovers(hass: HomeAssistant, mock_entry) -> None:
"""The failure the PR this replaces actually shipped: with zero live
listeners the base coordinator stops rescheduling, and the entry never
polls again. Entities must go available on the next interval once the
appliance answers."""
resources = _load_fridge()
await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
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]
assert coordinator._unsub_refresh is not None # a poll is actually queued
assert not coordinator.last_update_success
with _reachable(resources):
await _tick(hass)
assert coordinator.last_update_success
assert any(s.state != "unavailable" for s in hass.states.async_all())
async def test_reconcile_reloads_when_live_discovery_differs(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""Platforms enumerate `bound` once, so a live set that disagrees with the
snapshot can only be adopted by bringing the entry back up."""
resources = _load_fridge()
with _reachable(resources):
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]
victim = coordinator.bound[0].href
await hass.config_entries.async_unload(mock_entry.entry_id)
await hass.async_block_till_done()
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
reduced = {href: rep for href, rep in resources.items() if href != victim}
with (
_reachable(reduced),
patch.object(hass.config_entries, "async_schedule_reload") as reload,
):
await _tick(hass)
reload.assert_called_once_with(mock_entry.entry_id)
# Banked before the reload is scheduled, so the entry that comes back up
# replays this discovery rather than the one it is replacing -- otherwise
# a device that goes quiet again mid-reload rehydrates the stale set and
# reconciles all over again.
assert victim not in hass_storage[_store_key(mock_entry)]["data"]["resources"]
async def test_reconcile_is_quiet_when_live_discovery_agrees(
hass: HomeAssistant, mock_entry
) -> None:
"""The common case -- same appliance, same firmware -- must not reload,
or every offline restart would cost a second setup cycle."""
resources = _load_fridge()
await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
with (
_reachable(resources),
patch.object(hass.config_entries, "async_schedule_reload") as reload,
):
await _tick(hass)
reload.assert_not_called()
def test_coverage_gap_repair_is_live_only(hass: HomeAssistant, mock_entry) -> None:
"""A coverage gap is a claim about what the device reports, so replaying
a snapshot must not raise the Repair -- it would restate last run's
conclusion while the diagnostics download it points at is still empty."""
gappy = {
"/information/vs/0": {
"x.com.samsung.da.modelNum": "TOTALLY_UNKNOWN_BOARD",
"x.com.samsung.da.serialNum": "TEST-SERIAL-0000",
},
"/nothing/maps/this/vs/0": {"someField": 1},
}
issue_id = f"device_gap_{mock_entry.entry_id}"
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(gappy, from_snapshot=True)
assert coordinator._unbound_hrefs # the gap is real, it just stays quiet
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
coordinator._run_discovery(gappy)
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
async def test_live_load_never_reconciles(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""An entry that came up against a live device has nothing to reconcile
against; the reload path must stay out of the normal startup entirely."""
with patch.object(hass.config_entries, "async_schedule_reload") as reload:
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]
assert not coordinator.rehydrated
reload.assert_not_called()
@pytest.mark.parametrize("failures", [1, 3])
async def test_offline_load_survives_repeated_poll_failures(
hass: HomeAssistant, mock_entry, failures: int
) -> None:
"""Recovery isn't one-shot: the entry keeps its entities and keeps
retrying across however many intervals the appliance stays dark."""
resources = _load_fridge()
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
for _ in range(failures):
await _tick(hass)
assert mock_entry.state is ConfigEntryState.LOADED
assert {s.entity_id for s in hass.states.async_all()} == online_ids
with _reachable(resources):
await _tick(hass)
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.last_update_success
@@ -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
@@ -36,6 +36,15 @@ class _FakeCoordinator:
def canonical_resources(self, subdevice):
return self.last_resources
# _is_included judges existence against the discovery view, which is the
# live cache for everything but an offline load (issue #295).
@property
def discovery_resources(self):
return self.last_resources
def discovery_canonical(self, subdevice):
return self.canonical_resources(subdevice)
def _resources():
return _load_device("airconditioner_ailp_fac")
+18 -5
View File
@@ -129,22 +129,35 @@ def test_token_entities_present_with_calibrated_values():
def test_token_entities_stay_off_newer_boards():
"""Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too,
while also exposing those settings as dedicated resources -- ungated, the
token entities would duplicate them (auto clean) or apply a scale
calibrated on another board generation (outdoor temperature)."""
"""Newer families carry Volume/Sleep/Autoclean tokens too, while also
exposing those settings as dedicated resources -- ungated, the token
entities would duplicate them."""
state = _state("airconditioner_tp1x_rac")
for key in (
"spi",
"auto_clean_legacy",
"air_monitoring",
"good_sleep",
"outdoor_temperature",
"filter_time",
):
assert key not in state, key
def test_outdoor_temperature_is_not_gated_to_legacy_boards():
"""issue #367: OutdoorTemp_ is not paired with any dedicated resource on
newer boards -- /temperatures/vs/0 carries indoor temperature only, no
outdoor equivalent exists anywhere in this fixture's 23 hrefs -- so unlike
auto_clean_legacy et al. above, gating it to is_legacy_board only dropped
a real reading. A 48h field capture correlated the token against
weather.forecast_home at r=0.92 across several non-legacy boards,
confirming it is live per-site data rather than a firmware constant."""
resources = _load_device("airconditioner_tp1x_rac")
assert is_legacy_board(resources) is False
bound, _ = _discover(resources)
state = flatten(bound, resources)
assert state["outdoor_temperature"] == 37.0 # OutdoorTemp_92, the fixture's own value
def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
"""issue #161: climate.py's _legacy_airflow() delegates to
capabilities/airconditioner.py's is_legacy_board() instead of
+16
View File
@@ -654,6 +654,22 @@ def test_lnx_rac_heatpump_no_unbound_hrefs():
assert unbound == []
def test_lnx_rac_heatpump_outdoor_temperature_stays_off_fahrenheit_boards():
"""issue #367's -55 offset was field-validated on Celsius-locale boards
only; this fixture's own /temperatures/vs/0 declares Fahrenheit despite
carrying an OutdoorTemp_ token, so the sensor stays off rather than
apply an unvalidated offset/unit to it (see _reports_celsius)."""
reg, resources = _ac_lnx_rac_heatpump()
temps_item = resources["/temperatures/vs/0"]["x.com.samsung.da.items"][0]
assert temps_item["x.com.samsung.da.unit"] == "Fahrenheit"
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
assert any(o.startswith("OutdoorTemp_") for o in options)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert "outdoor_temperature" not in state
def test_lnx_rac_heatpump_absence_power_saving_state():
reg, resources = _ac_lnx_rac_heatpump()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
+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"
+14
View File
@@ -89,6 +89,20 @@ def test_course_bound_to_shared_course_vs_0():
assert desc.rep_fn(rep) == "16"
def test_reported_table_00_course_codes_are_translated():
"""The reporter confirmed these codes on a DVE45R6300W/A3 by selecting
each cycle and reading back the raw course code (issue #357)."""
from custom_components.localthings.catalog import translated_states
desc = next(
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
)
table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
assert desc.translation_key(table_00) == "dryer_cycle_table_00"
confirmed = {"01", "9c", "a5", "9e", "9b", "27", "a0", "a4", "a6", "a3", "a2"}
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
def test_st_dryercourse_is_ignored():
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
is globally ignored -- the mirror of /st/washercourse/vs/0."""
+9
View File
@@ -29,6 +29,15 @@ class _FakeCoordinator:
# subdevices (issue #177).
return self.last_resources
# _is_included judges existence against the discovery view, which is the
# live cache for everything but an offline load (issue #295).
@property
def discovery_resources(self):
return self.last_resources
def discovery_canonical(self, subdevice):
return self.canonical_resources(subdevice)
def _coord(last_resources) -> LocalThingsCoordinator:
return cast(LocalThingsCoordinator, _FakeCoordinator(last_resources))
+1 -1
View File
@@ -369,7 +369,7 @@ class TestCycleSelectTableGating:
def test_untranslated_table_uses_generic_cycle_key(self):
"""An unknown table does not claim another board's state labels."""
desc = self._desc()
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
assert desc.translation_key(resources) == "cycle"
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
+49 -15
View File
@@ -3,9 +3,9 @@
from custom_components.localthings.registry.capabilities.operational import (
OPERATIONAL_STATE,
_just_finished,
_live_progress_code,
_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():
@@ -41,27 +41,61 @@ class TestJustFinished:
assert not _just_finished({"x.com.samsung.da.state": "Run"})
class TestLiveProgressCode:
"""`_live_progress_code` is progress/progress_percentage's
sticky_bypass_fn (issue #345) -- see sensor.py's _apply_sticky."""
class TestNewCycleRunning:
"""`_new_cycle_running` is progress/progress_percentage's
sticky_bypass_fn -- the early-release condition for the #345 hold.
See sensor.py's _apply_sticky."""
def test_true_for_a_concrete_non_finish_code(self):
assert _live_progress_code({"x.com.samsung.da.progress": "Wash"})
def test_true_for_a_concrete_non_finish_code_while_active(self):
assert _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Wash"}
)
def test_true_regardless_of_state(self):
"""Not gated on machine_state -- a new cycle's own real progress
must win over a held hold even while paused (e.g. adding a sock
mid-hold), not just while actively running."""
assert _live_progress_code(
def test_false_for_a_running_stage_reported_after_state_left_active(self):
"""Issue #358, the whole reason for the `state` gate: the reporting
dryer replays a running stage ('Drying', its first supportedProgress
entry) for a few seconds after Finish while winding down. That is
the finished cycle's tail, not a new cycle -- releasing the hold on
it is what produced 'Drying, Cooling, Finish, Drying, Idle'."""
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progress": "Drying"}
)
def test_false_while_paused(self):
"""Paused is not evidence a new cycle is running, and the tail
above can't be told apart from it. rep_fn shows 'Idle' whenever
state isn't active anyway, so there is no live value being
withheld here -- only a hold that expires on its own instead of
being released early."""
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Pause", "x.com.samsung.da.progress": "Wash"}
)
def test_false_for_finish(self):
assert not _live_progress_code({"x.com.samsung.da.progress": "Finish"})
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Finish"}
)
def test_false_when_absent_or_none(self):
assert not _live_progress_code({})
assert not _live_progress_code({"x.com.samsung.da.progress": "None"})
assert not _new_cycle_running({})
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "None"}
)
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:
+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
+143 -31
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,29 +156,141 @@ 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_paused_new_cycle_also_overrides_the_hold():
"""Not just an actively-running new cycle: adding a sock and pausing
mid-cycle must also show the real, current progress rather than a
stale hold from the previous cycle -- machine_state isn't 'active'
while paused, so a bypass keyed on that alone would miss this."""
sensor, coordinator = _sensor(_PROGRESS_DESC)
def test_a_running_stage_after_finish_does_not_break_the_hold():
"""Issue #358: the reporting dryer resets `progress` to its course's
first stage in the same moment `state` goes idle -- observed twice,
identically, as Cooling -> +60s Finish -> +24s 'Drying' -> +4s
settled, with the reporter's machine_state history flipping to idle on
the exact second progress reads 'Drying'. The hold must survive that,
so the cycle still reads Drying, Cooling, Finish, Idle rather than the
reported Drying, Cooling, Finish, Drying, Idle."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.2)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40")
assert sensor.native_value == "drying"
_replace(coordinator, state="Run", progress="Cooling", progressPercentage="95")
assert sensor.native_value == "cooling"
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready")
assert sensor.native_value == "Finish" # still held
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"
# ...then the device settles, still inside the window.
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "finish"
time.sleep(0.25)
assert sensor.native_value == "idle"
def test_progress_percentage_survives_the_same_tail():
"""#358's tail hits progress_percentage through the identical bypass;
it must stay pinned at 100 rather than being released back to a raw
mid-cycle figure."""
desc = replace(_PROGRESS_PERCENTAGE_DESC, sticky_seconds=0.2)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == 100
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="40")
assert sensor.native_value == 100
time.sleep(0.25)
assert sensor.native_value == 0
def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released():
"""'Paused' isn't positive evidence of a new cycle, and #358's tail is
indistinguishable from it. Nothing live is withheld by waiting --
rep_fn shows 'Idle' while paused with or without a hold -- so the
stale Finish just expires on schedule instead of being cut short."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "finish"
_replace(coordinator, state="Pause", progress="Wash")
assert sensor.native_value == "Wash"
assert sensor.native_value == "finish" # held out, not released
time.sleep(0.1)
assert sensor.native_value == "idle" # what a paused appliance always shows
_replace(coordinator, state="Run", progress="Wash")
assert sensor.native_value == "wash"
def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
"""Edge-triggering stops a *stuck* Finish from extending the hold; a
progress that flaps out of and back into Finish must not restart it
either, or the bound stops being a bound."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.3)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="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"
_replace(coordinator, state="Ready", progress="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"
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
"""Expiry doesn't re-open the door: with no new cycle in between, a
second Finish is the same Finish. Re-arming on it would strobe the
entity Finish -> Idle -> Finish once per window, re-firing exactly the
announcements #345 is about -- so it takes a bypass (a cycle actually
running) to make the hold available again.
Distinct from the flap test above: there the window is still open, and
the last read before expiry leaves the sticky condition *matching*.
Here it has already closed, and the flap ends on a non-matching read,
which is the state a spent-on-arm-only guard would let re-arm."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "finish"
time.sleep(0.1)
assert sensor.native_value == "idle"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "idle"
_replace(coordinator, state="Ready", progress="Finish")
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"
_replace(coordinator, state="Run", progress="Finish")
assert sensor.native_value == "finish"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "finish"
def test_hold_expires_after_sticky_seconds():
@@ -192,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():
@@ -213,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():
@@ -249,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
@@ -265,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
+9
View File
@@ -34,6 +34,15 @@ class _FakeCoordinator:
return canonical_view(subdevice, self.last_resources, self._subdevices)
# _is_included judges existence against the discovery view, which is the
# live cache for everything but an offline load (issue #295).
@property
def discovery_resources(self):
return self.last_resources
def discovery_canonical(self, subdevice):
return self.canonical_resources(subdevice)
@pytest.mark.parametrize("name", _FIXTURE_NAMES)
def test_key_is_unique_across_all_bound_entities(name):
+16 -5
View File
@@ -99,16 +99,18 @@ class TestWasherCourse:
def test_translation_key(self):
"""Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. Only a
verified table gets table-specific state translations."""
across board generations sharing /course/vs/0 -- a device reporting
an unrecognized table id must not borrow another board generation's
labels) -- see laundry.cycle_select. Only a verified table gets
table-specific state translations."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle")
assert callable(desc.translation_key)
table_02 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_02"}}
assert desc.translation_key(table_02) == "washer_cycle_table_02"
table_00 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
assert desc.translation_key(table_00) == "cycle"
assert desc.translation_key(table_00) == "washer_cycle_table_00"
unrecognized = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
assert desc.translation_key(unrecognized) == "cycle"
assert desc.translation_key({}) == "cycle"
def test_reads_raw_course_code_from_options_array(self):
@@ -146,6 +148,15 @@ class TestWasherCourse:
}
assert confirmed <= translated_states("select", "washer_cycle_table_02")
def test_reported_table_00_course_codes_are_translated(self):
"""The reporter confirmed these codes on a WF45R6300AW/US by
selecting each cycle and reading back the raw course code
(issue #357)."""
from custom_components.localthings.catalog import translated_states
confirmed = {"01", "70", "55", "71", "72", "77", "57", "73", "74", "75", "78"}
assert confirmed <= translated_states("select", "washer_cycle_table_00")
def test_missing_course_option_returns_none(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle")
assert desc.rep_fn is not None