Home Assistant calls turn_on regardless of current state, and applying a
scene re-asserts every captured state, so asserting the alarm on over a
rinse-only AddWashSet_1 rewrote it to _7 -- silently widening the moments
the user picked, with no state change on this switch to point at it. The
write is now refused when the mask is already non-zero.
Distinct from the off-then-on path documented in _add_wash_bit_switch,
where the appliance has no subset left to keep.
Also satisfies the ty check on the new tests: resolve(),
for_device_by_model() and exists_fn are all optional, asserted the way
the other capability tests do.
A DV6800N (DA_WM_A51_20_COMMON) reports the same /st/dryercourse/vs/0
courseTable 'Table_00' as issue #357's DVE45R6300W/A3 -- also
DA_WM_A51_20_COMMON -- and its reporter listed 14 selectable courses
read straight off the appliance's own menu, in the same order
/course/vs/0's supportedOptions enumerates them.
Initially treated this as a second, incompatible Table_00 code family
and added a device-model-keyed disambiguation layer to
laundry.cycle_select. That was an unproven assumption: the two
reporters' confirmed sets are mostly non-overlapping subsets (11 vs 14
codes), which is exactly what you'd expect from two models on a shared
board exposing different subsets of one course table via their own
/course/vs/0 supportedOptions -- not evidence of two different code
dictionaries. The one code both reporters confirmed, 'a5', means
Bedding on both, which is corroborating, not neutral. No confirmed
code conflicts between the two sets, so this folds #394's 14 codes
straight into the existing dryer_cycle_table_00 catalog entry
(mirrored to all shipped languages), same shape as #357's original
translations-only change.
Adds a scrubbed fixture + golden for the DV6800N dump and tests
confirming zero unbound hrefs and that its course codes resolve
through the shared, now-larger dryer_cycle_table_00 catalog.
on_notification discards on the DTLS reader thread. Snapshotting
_notified then assigning fallback_hrefs after releasing the lock
let a notify in that window land on the set object being replaced,
so a just-pushed href was classified silent until its next notify.
The master alarm switch wrote AddWashSet_7/_0 without consulting
_add_wash_mask, so a device reporting a wider mask (AddWashSet_15) or a
non-numeric one had it truncated to three bits -- the write that mask's
own docstring rules out, while the per-moment switches already refused it.
Also corrects the washer_ww6500 golden docstring: the fixture carries no
/oic/d and the test passes no device_types, so the device is typed solely
by the WW consumer prefix in its description. A51 is not a board token,
which makes that the fragile route worth naming.
Setup already starts `_run_subpolls` as a background task. Patching
`_poll_hrefs_blocking` then captured its unfiltered hot/warm batches
alongside the silent-only ones the test asked for.
on_notification discards from fallback_hrefs so a late first push
(after the 80% quorum snapshot) self-corrects instead of staying on
the 3s GET cadence. Empty subpoll slots skip the session lock.
Hoist airconditioner._has_sensor_type to common.has_sensor_type next to
sensor_item_value, add the issue #127 stub-rep carve-out, and match the
AC family's enabled_default=False on the purifier CO2 entity.
A switched-off washer or dryer fails in the DTLS handshake, not in a
poll: `_poll_once` opens the session itself, so `_connect_session` runs
to its 12s timeout with nothing to show. The poll path treated that like
any other poll failure and ran its reconnect -- close the session, pause,
poll again -- but there is no session to close and no association for the
device to clean up, so the retry was the identical handshake five seconds
later. That cost 29s of every 30s interval, and the same again on every
setup attempt for an entry with no snapshot to load from.
`_poll_once` now records which of the two failed, and the poll path skips
the retry when the handshake is what never completed. A session that
opened and then broke still reconnects within the cycle.
The log was the half the reporters saw: an ERROR every cycle (plus a
WARNING once three "reconnects" piled up) for a state this integration is
built to sit through, which issue #269's reporter read as the integration
having failed. An outage now reports once, DEBUG for the cycles after it,
and INFO when the device answers again.
Fixes#269
A live dump showed DrumCleanLog_'s newest entry moving every 30-90s on
its own, including well after a cycle had already finished -- it never
settles on a value worth showing. drum_clean_cycles_remaining, read from
separate WashingTimes_/DrumCleanProposal_ counters, is unaffected and
stays wired.
The washer/dryer readers this was originally built from (issues #9,
#258) aren't touched -- no report of the same churn there.
The progress sensor's catalog had no entry for the "Sanitizing" stage
Samsung dishwashers report, so it fell back to the raw device code
instead of a translated label. Add "sanitizing" to the progress
state table in every shipped locale.
Issue #92: try_enter_observe_mode treated every subscribed href as
push-covered once SUCCESS_FRACTION cleared, including ones that never
notified. Those then skipped _run_subpolls for the rest of the session.
Record subscribed-but-silent hrefs on fallback_hrefs (idle in observe
mode) and keep just that set on the hot/warm cadence.
Issue #387's TP1X_DA-AC-AIR-class board reports a CO2 item on
/sensors/vs/0. Same field/shape air_monitor.SENSORS already models
(carbon_dioxide / ppm). Gated with exists_fn so boards that don't
list the type (every current fixture) don't grow an empty entity.
The dishwasher's /course/vs/0 options[] array carries the same
WashingTimes_/DrumCleanProposal_/DrumCleanLog_ trio already read by
washer.py (issue #9) and dryer.py (issue #258), confirmed against a
live dump, but dishwasher.py never wired the shared
laundry.drum_clean_cycles_remaining/drum_clean_last_cleaned readers
in. Add the two sensors to CYCLE_OPTIONS the same way, plus tests and
an updated golden fixture.
CONTRIBUTING.md asks for one or two sentences over an essay, a pointer
rather than a re-derivation, and module docstrings that orient rather than
document the design. The identity change was written before that guidance
was read, and its comments narrate the reasoning at roughly twice the
length the conclusions need.
Cut to the conclusion and the evidence that makes it credible, keeping
every issue reference: _resolve_identity's docstring 41 lines -> 22 (now
shorter than the two longest already in that file), rekey_entry 23 -> 14,
its module docstring 21 -> 10, resolve_device_key 24 -> 16,
is_usable_device_id 15 -> 8, and the same treatment for the inline blocks
in _resolve_identity, the CONF_DEVICE_KEY note, the redaction rationale,
and the migration suite's module docstring.
Comments only; the suite and the thirteen-mutation sweep are unchanged.
CI runs `ty check custom_components tests`; the identity work was only
checked against `custom_components`, so eighteen diagnostics in the new
tests went unnoticed until the PR run.
Two shapes, both in the new files. `dev_reg.async_get` and
`ent_reg.async_get` return `... | None`, so chaining an attribute off them
is both a type error and, on the failure it exists to catch, an
AttributeError instead of the assertion that would say what went wrong --
replaced with `_device_identifiers`/`_entity_unique_id` helpers that assert
the row survived and return the field. And `_identity`'s `**kwargs`
dict-merge erased the field types it was constructing; spelling the three
optional fields out is clearer anyway.
No behaviour change; the full suite and the thirteen-mutation sweep still
pass.
Two real findings from review of the identity change.
A pre-v4 entry keeps its serial-keyed unique_id until its first live poll
adopts the UUID, which can be a long while for an appliance that is off
since the entry loads from its snapshot meanwhile. The config flow's UUID
check couldn't see such an entry, so re-adding the same appliance in that
window was waved through as a second entry -- and the two would collide the
moment the older one re-keyed, with rekey_entry resolving the collision by
deleting the duplicate rows, taking the original's entity_ids, history and
automations with them. The flow now also aborts on the legacy key, matched
together with the host so issue #381's two same-serial units at different
addresses stay separable, and gated on CONF_DEVICE_KEY being absent so the
check disappears once the entry has migrated.
Separately, the "nothing stored yet" branch adopted the polled identity
unconditionally, silently dropping the "same IP, different appliance" guard
_run_discovery has had since issue #236 -- and dropping it for precisely
the users who have been running longest. _resolve_identity now computes the
key an entry currently carries once and applies one corroboration rule to
it, so a pre-v4 entry defends itself exactly as a migrated one does.
Adoption still needs no corroboration where there is no identity claim to
defend: an entry keyed on its address (issues #83/#189) or with no stored
serial at all.
Two tests had to change with it, both because their setup was unfaithful
rather than because the behaviour regressed: the two-unit test now has its
devices report the shared serial they actually report, and the offline test
now models a placeholder-serial board, which is where an entry's key and a
snapshot's serial can genuinely disagree.
Three new tests cover the changed behaviour, and the mutation sweep is
extended to thirteen breakages -- including the two guards added here and
the host match that keeps the duplicate check from undoing #381's fix.
Also from review: rename three stale _resolve_key doc references to
_resolve_identity, and stop rebinding new_unique_id in rekey_entry.
The move onto the OCF device UUID rewrites the identity of registry rows
that a user's automations, dashboards, history and areas all hang off, and
it does so on the first live poll rather than inside async_migrate_entry.
That makes it the riskiest migration in the codebase and the one least
covered by its own tests, so it gets a dedicated suite organised around
what must not break rather than around the functions involved.
tests/localthings/test_identity_migration.py (19 tests) covers: the v3
upgrade end to end; the full v1 -> v4 walk an oldest install takes; a
host-keyed placeholder-serial board adopting a real identity; issue #381's
actual two-unit install migrating to separate identities; every
customization carried on an entity row (rename, icon, area, hidden_by) and
the device's own area; a composite appliance's subdevice identifiers and
via_device links; scoping to one config entry's rows; the key-boundary
match; upgrading while the appliance is off, and the deferred adoption
completing when it returns; restarting after the upgrade being a no-op;
and the three guards that defend an identity once it has moved.
tests/test_rekey_statistics_end_to_end.py drives a real in-memory recorder
to prove the claim the in-place rewrite exists to make: statistics are
filed under entity_id, so preserving the row preserves the history --
including on the one path that deletes a row rather than rewriting it.
It lives outside tests/localthings/ for the same reason the existing
statistics end-to-end test does (that package's autouse fixture starts HA
before recorder_mock can claim its database).
Every guarantee was checked by mutation: ten separate breakages of the
production code -- dropping the unique_id update, the subdevice prefix,
the entry scoping, the key boundary, the entity rewrite, both snapshot
guards, the no-demote rule, the rejected-serial rule, and recreating the
row under a new entity_id -- each fail the suite.
Two gaps this found and closed:
- ConfigFlow.VERSION had to move to 4. Home Assistant only calls
async_migrate_entry for entries behind the flow's version, so the v3 ->
v4 step silently never ran. Pinned with the reasoning written down.
- An offline load could re-key the registry from a stale snapshot, and
freeze that answer into CONF_DEVICE_KEY so the real UUID could never be
adopted afterwards. The guards existed; nothing proved they held.
The identity-migration tests move out of test_migration.py, which stays
about the migrations that finish inside async_migrate_entry.
Two Samsung air purifiers of the same model report the identical, well
formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's
unique_id, the device registry identifiers and every entity unique_id
were all minted from that string, the second unit was refused as already
configured, and would have collided entity-for-entity even if it hadn't
been.
This is the third firmware family to ship an unusable serialNum, after
`Nothing(SVC)` (#83) and the flash-unset sentinel (#189), and the first
one no heuristic can catch: the value is well formed, it's just shared.
`is_placeholder_serial` was a dead end.
So identity moves onto /oic/d's `di`, falling back to /oic/p's `pi`, then
the serial, then the host. `di` is what the protocol already uses to
address the endpoint -- if it were wrong or shared, OCF discovery and the
DTLS association wouldn't work at all -- and it's device-scoped, where
`pi` is platform-scoped and would be shared by a board hosting several
logical devices. Both units in #381 report a distinct `di`. A board that
answers neither resource lands exactly where it did before, so no
existing hardware regresses.
The re-key can't happen in async_migrate_entry: the UUID is only readable
from the device, and an entry can load entirely from its snapshot while
the appliance is off (#295). So v3 -> v4 only records the legacy key, and
the coordinator adopts the UUID on the first live poll, rewriting the
entity registry, the device registry (including subdevice identifiers)
and the entry's unique_id together. Rewriting rather than recreating is
what lets a user keep entity_ids, names, areas, statistics and every
automation that references them.
Three rules keep that adoption from misfiring:
- A poll that reads no UUID never demotes a UUID-keyed entry back onto
its serial, so one failed reconnect doesn't re-key every entity.
- A changed UUID is followed only when the serial still corroborates it
(a factory reset may regenerate `di`) or when the entry was keyed on
its IP, which was never an identity to defend.
- When the identity is rejected as a different appliance, the serial
isn't adopted either -- otherwise the intruder would gain exactly the
corroboration needed to win the next poll.
Also stop redacting `di`/`pi` from diagnostics. They're randomly assigned
per-unit UUIDs, not account data, and blanking them is what made the
first #381 diagnostics download unable to answer the only question it was
requested to answer. The owner-set device name stays redacted.
Fixes#381
- _on_cloud_courses_changed cleared the canonical-view cache but never
pushed state: select.py's current_option reads coordinator.data,
which only moves on async_set_updated_data, so a change here (the
new toggle, or apply_cloud_courses naming a program -- which has
called this same method since before the toggle existed) sat stale
in the UI until an unrelated poll or observe happened to run next.
Now calls _push_cache_snapshot() too.
- _refresh_cloud_course_issue now runs from __init__.py's
options-update listener on every entry save, not just a
cloud-course-specific one. Saving an unrelated option
(CONF_BYPASS_REMOTE_CONTROL, say) before this device's first poll,
or while it's rehydrated offline, read /course/vs/0 as empty --
indistinguishable from "nothing pending" -- and would delete a
Repair a real poll had every reason to raise. Now a no-op on an
empty rep, leaving whatever issue state already exists untouched
until a real poll can judge it.
- The "cloud_courses" menu's off-state note was a raw English string
built in config_flow.py and substituted via description_placeholders
into all 7 locales' descriptions -- unlike the SmartThings screen
names quoted elsewhere (deliberately English everywhere; that's a
third-party app's own label, not ours), this one named LocalThings'
own "Offer downloaded cycles"/"Device settings" labels, which are
translated per locale and should have matched. Replaced with a
permanent, state-independent sentence translated in the catalog
itself, in all 7 locales, instead of conditional Python-built text.
Also restores a word an earlier edit dropped from
async_step_cloud_courses's docstring ("can complete confidently").
Tests: two new regression tests, each confirmed to fail against the
pre-fix code before being fixed -- one drives coordinator.data through
a toggle via a fixture already sitting on a one-time cloud override, so
current_option actually depends on the cloud store instead of falling
back to the raw course code; the other simulates a second coordinator
against the same entry with an empty resource cache (a not-yet-polled
restart) and confirms an existing Repair survives an unrelated option
save. Full suite (1585 tests), ruff, and `ty check custom_components
tests` all pass.
Issue #364: several reporters got the "downloaded cycles not set up"
Repair despite never meaning to use the feature -- one device appears
to auto-populate a slot from a SmartThings-provided example. The two
reporters who did complete setup successfully both hit the same root
cause for their earlier failures: the SmartThings app has two
similarly-named screens ("Cycle", which lists everything including
local courses, and "Download cycles", the one that actually matters
here), and nothing in our instructions said to use the second one
specifically.
Global disable (CONF_CLOUD_COURSES_ENABLED, entry.options, default
on):
- New coordinator.cloud_courses_enabled property, mirroring
CONF_LEARN_MODES' shape -- off stops the Repair and stops offering
already-named programs as cycles, without discarding anything
already learned or named.
- Deliberately does NOT stop _observe_cloud_courses' passive recording:
guided/manual setup depend on live observation to detect a newly
selected program at all, and leaving it running means turning the
option back on immediately surfaces anything set up in the meantime
instead of asking the user to redo it. Documented on the const and
on the property.
- New __init__.py options-update listener calls a new
coordinator._on_cloud_courses_changed(), which both clears the
canonical-view cache (memoized, so a stale view would otherwise keep
answering with pre-toggle state -- caught by two failing tests
before this) and refreshes the Repair. Nothing else needed this
because every other option is read live on its own next use; cloud
courses is the only one with standing Repair/cache state to refresh
immediately rather than on the next unrelated change.
- Toggle exposed in Device settings as "Offer downloaded cycles",
alongside prose explaining why some devices show the Repair
unprompted.
Instructions, in every shipped locale (en/de/es/it/cs/nl/ko) --
otherwise a locale missing the new/changed strings would silently show
English or the old text, the same gap issue #376 already tests for:
- Every guided-setup screen, the manual edit form, and the Repair
itself now say explicitly: open the SmartThings app (not the
appliance), and tap "Download cycles" specifically -- a separate row
from "Cycle", further down the screen -- not the general cycle
picker. Also states plainly that the appliance doesn't need to be
nearby or running the cycle, just powered on and connected.
SmartThings' own screen names are kept in English in every locale
(verified only in the English app via the reporter's screenshots;
translating them without evidence of what Samsung's own localized
app shows would be a guess this codebase's translations otherwise
avoid).
- The Repair's description now also points at the new toggle for
anyone who doesn't want the feature at all.
- The "cloud_courses" menu screen shows a note when the option is
currently off, since guided/manual setup still work in that state
but nothing named there will appear as a selectable cycle until it's
turned back on.
Tests: coordinator-level tests cover the option defaulting on,
suppressing a new Repair, clearing an already-open one, hiding/
restoring the cycle-select entry as the option flips (which caught the
canonical-cache bug above), and that passive observation keeps running
regardless of the option. Options-flow tests cover the new field's
default and that it persists. Translation catalog tests
(test_every_language_mirrors_the_english_catalog et al.) cover every
locale's topology and placeholders for the changed/added strings.
Full suite (1583 tests), ruff, and `ty check custom_components tests`
(CI's exact invocation) all pass.
HA logs a removal warning (2027.8) every time this name is accessed on
releases that carry UnitOfDensity, attributed straight to this
integration since it's a plain module-level import. UnitOfDensity is
the replacement, but hacs.json's floor (2025.1.0) predates it existing
at all -- pytest-homeassistant-custom-component 0.13.316, the newest
available, still has no UnitOfDensity either, so this can't be a
static import on either branch without breaking support for part of
the version range.
Resolved with a runtime getattr instead: reads UnitOfDensity off the
homeassistant.const module if present and uses its
MICROGRAMS_PER_CUBIC_METER member, otherwise falls back to the plain
(un-deprecated, on those older releases) constant. The getattr
short-circuits before the deprecated name is ever touched on a release
new enough to have UnitOfDensity, so the warning stops firing there
without dropping support for anything still on the old one. Same
feature-detection shape _relabel_particulate_statistics already uses a
few lines down for new_unit_class.
Verified the resolution logic directly: against the installed HA
(2026.2.3, pre-UnitOfDensity) it resolves to the plain constant with no
warning; a simulated future homeassistant.const with UnitOfDensity
present resolves to it without ever touching the deprecated name (a
guard that raises on that access never fires).
Full suite (1573 tests), ruff, and `ty check custom_components tests`
(CI's exact invocation) all pass.
06's Korean text ('이불') is identical to the confirmed Bedding codes
24/6f, and Bedding reads better than the guessed 'XXL Laundry' wording
issue #342 originally gave it. Applied across all 7 locale catalogs
(matching each locale's own already-translated Bedding text, not a
fresh translation) and folded into issue #376's WF21T6500KV test as a
21st confirmed code instead of a flagged exclusion.
test_confirmed_washer_table_02_missing_course_names (#342) updated to
match; its docstring now notes 06's wording was later corrected by
#376 rather than pinning the old value as if still current.
Issue #376 reported Korean UI labels for 21 washer (Table_02) and 18
dryer (Table_03) codes that had no translation and were rendering as
raw hex in the UI, from a WF21T6500KV washer (DA_WM_A51_20_COMMON) and
DV19T8745BV dryer (DA_WM_TP1_21_COMMON).
Cross-checked every reported code against translations/ko.json before
translating anything: several share their exact Korean text with a
code the catalog already has a confirmed label for (washer '19'/'AI
맞춤세탁' matches '2b'/'69'; dryer '3a'/'살균건조' matches '21'; dryer
'3c'/'피트니스' even matches washer '2f', a cross-table reuse; etc.) --
those reuse the established label instead of a fresh translation. The
rest (Wool/Lingerie, Boil Wash, Soft Bubble, Padding Care, and others
with no catalog precedent) are new translations of the reporter's
Korean text.
One code is deliberately NOT applied: washer '06' ('이불', Bedding per
this report) conflicts with 'XXL Laundry', already locked in by
test_confirmed_washer_table_02_missing_course_names (issue #342). Two
reports of the same nominal Table_02 disagreeing on one code is a real
discrepancy, not a wording question -- left alone pending the reporter
(or another Table_02 owner) confirming which device's '06' is actually
wrong, same caution as the existing '24'/'33' transposition history
(issue #343).
Added to all 7 locale catalogs (en/de/es/it/cs/nl/ko), not just
English: HA falls back to English for any key a locale is missing, so
translations/en.json alone would still pass
test_every_language_mirrors_the_english_catalog's topology check while
leaving every other locale showing English text for these codes.
Tests: two new tests lock in the English labels and, for every reused
code, that every locale's label actually matches its anchor code (not
just English) -- the same gap issue #343 fell through, since the
topology test alone can't catch a locale-specific mistranslation.
Full suite (1575 tests), ruff, and ty all pass.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
#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.
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.
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.
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.
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.
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.
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).
* 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>
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.
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.
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.
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.
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.
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.
`_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.
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.
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.
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.
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.
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.
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.
0.21.0 was already bumped by #334 but never released; 0.22.0 double-bumped
past it. This release covers everything merged since v0.20.0 and should
just be 0.21.0.
SwitchDeviceClass only ever supported 'outlet'/'switch', not 'lock'.
switch.py passes desc.device_class straight to SwitchDeviceClass(...),
so any board with these hrefs raised ValueError during switch platform
setup and lost every switch entity for the device, not just the lock
ones (issue #349, TP2X_WATERPURIFIER_20K).
KIDS_LOCK_GENERIC/_VS_FALLBACK dodged this same bug (issues #181/#183)
by moving to a read-only BinarySensorDesc, but the water-purifier and
cooktop locks are genuinely writable, so they stay SwitchDesc and just
drop the invalid device_class (with an mdi:lock icon standing in for
the one entity_category=config gave them for free).
Added a registry-wide test that instantiates SwitchDeviceClass for
every SwitchDesc.device_class across every by_type registry, so a
future capability can't reintroduce the same crash.
The one that could run the wrong program: guided setup accepted a name
another program already had. The duplicate check only compared names within
the form it was handed, and guided setup submits one program at a time, so
it never saw the others. Two programs sharing a label resolve to whichever
option comes first, so picking the second would have run the first one's
payload -- the exact failure the check exists to prevent, working correctly
in the bulk form and blind in the guided one. The taken set now includes
every other named program, excluding the slot being edited so confirming an
unchanged name doesn't reject itself.
The rest:
- The timeout screen's copy interpolates the same counters as the other two
but was shown without placeholders, so it rendered literal braces.
- The probe reports whatever payload is loaded, while observe() declines one
whose slot the device doesn't advertise. Guided setup could reach the name
form for such a slot, take a name, and silently discard it -- there is no
record to hang it on and no payload to replay. It now waits instead.
- The prefilled Download course came from the raw candidate list while the
dropdown filters to courses the appliance still offers, so a stale
candidate prefilled a value the selector rejects and the form failed
validation on something the user never chose.
A write schedules a debounced refresh, which polled through a fake session
that only implements post(), crashed on the missing get(), and left its timer
running past the end of the test. CI's lingering-timer check caught it; it
passed locally only by timing luck.
Stubbed the same way test_coordinator_send_command's fixture already does,
which is what this test should have copied to begin with.
The Download-course candidate came from "Course_ read while a non-sentinel
one-time payload is loaded". On the first rep after any restart that is
indistinguishable from a payload left over from a previous run, so an
appliance holding cloud payloads while sitting on an ordinary course
proposed that ordinary course as the Download one. Accepting the prefill
would then make selecting a downloaded program start, say, a cotton wash.
Only a transition actually watched counts now. "Never observed" is a
distinct state from "observed, nothing loaded" -- absent-then-loaded is a
genuine selection and still counts -- so a restored store deliberately
re-enters the unobserved state, since a restart cannot tell the two apart.
Payloads are still learned from that first rep either way; which programs
exist is device fact regardless of when they were loaded. It is only the
inference about which course means Download that needs the timing.
Both corpus dumps taken off the Download course show the appliance clearing
its one-time token to the FFFF sentinel, so this may never fire on these
boards. That is a reason to expect them to behave, not to depend on it.
A program is only offerable once it has both a name and the Download course
code that goes in the Course_ token. Guided setup collected names and never
asked about the course, so a user could walk all nine programs, watch every
name save, and end up with nothing in the cycle list.
Worse, it actively cleared the course. _apply_cloud_course_names read
download_course out of the submitted form; the guided name form has no such
field, so it passed None and apply_cloud_courses stored that. Naming a
program therefore removed every previously-named program from the list.
Traced on the fixture: 87 -> name one -> None -> nothing offerable.
Two changes. apply_cloud_courses now defaults download_course to "leave it
alone" rather than None, so silence can't be mistaken for a clear, and the
guided path forwards the field only when its form actually carried it.
And the first guided name form now asks for the course, prefilled from what
was just observed, dropping the field once confirmed. Asking there rather
than up front is deliberate: it is the first moment there is evidence to
prefill, since the user has just loaded a program and the course showing
alongside it is the Download one. That makes the walk stand on its own,
which is the whole point of offering it as the primary path.
The bulk form's course dropdown now shares the guided one's builder.
Translations for the guided-setup strings are in for cs/de/es/it/ko/nl,
matching the vocabulary the earlier pass established. The new field on the
name form reuses each locale's existing label from the bulk form rather than
adding an untranslated string.
A nine-program walk is hard to keep your place in. The counter alone doesn't
say what you've already done, and the programs still to do can't be listed --
they're unnamed by definition, which is the whole premise. So "named so far"
is the only orientation available, and it now appears on all three guided
screens.
It doubles as duplicate avoidance on the naming form: a repeated name is
rejected, so seeing the others while typing beats being bounced afterwards.
Listed in the appliance's own advertised order rather than the order they
were named -- a stable order either way, and not numbered: whether it matches
the dial is plausible but unverified, and implying it would be worse than
saying nothing.
Naming downloaded programs from a list of hex slot ids was the weak part of
this feature: it asked about programs in the abstract, long after the user
had touched the appliance, and the per-slot fields rendered as raw keys
because Home Assistant can't translate dynamic ones.
Guided setup asks in the moment instead. It waits on a progress step while
the user selects a program on the appliance, then asks for that one's name --
so the field is a single static key, and "which one is this?" is answered by
the user having just turned the dial to it. The prompt also shows the
appliance's own reported remaining time, which differs per program and is
device-reported rather than decoded.
Two things it has to get right:
- It waits for a *transition*, not a state. After naming a program the
appliance is still sitting on it, so a loop keyed on "a known slot is
loaded" would re-offer the same one forever. Each round baselines on
whatever is loaded when it starts.
- Re-selecting an already-named program is not an error -- it is how someone
checks their work -- so it gets the existing name pre-filled and the
counter deliberately does not move, rather than a rejection.
Names persist as they are entered rather than batching to the end of the
flow, which makes closing the dialog a clean "save and exit" with nothing
pending to lose, and makes the flow resumable: reopening picks up from the
store. async_remove cancels an in-flight round, so walking away actually
stops the probing instead of holding the session lock every few seconds
until the timeout.
/course/vs/0 is cold-tier, so passively a selection can take a whole poll
interval to appear. async_probe_cloud_courses live-reads it through the
normal apply path, keeping learning and persistence in one place.
The bulk form stays, under its own step, as the way to rename things later --
which guided setup is bad at.
New strings ship in English in every catalog and need translating.
Its bytes are not payload slots everywhere. On the DW5000C dishwasher all
four (8E 8D 8F 02) are course codes in that device's own course list, three
already translated -- Plastic, Pots and pans, Baby Care. There the token
marks which ordinary courses came from the cloud; they select with a plain
Course_ write and need no payload, which is consistent with it carrying no
payload token at all. It also has a DownloadCourseList_ token the washers
lack. On both washers the slots share zero overlap with the course list and
a payload is required to select one.
So the "this is not washer-only" claim was wrong, and gating on
advertised_slots offered that dishwasher's owner a naming flow for programs
that already work and are already named. The Repairs card was spared only
because the payload gate added earlier happens to catch it.
cloud_slots() subtracts the device's own course list, which separates the two
readings without guessing at families: what remains is slots that cannot be
selected any other way, which is what this module is for. Everything
user-facing now gates on that -- the options menu entry, the naming flow, the
Repairs count. The dishwasher gets nothing, both washers are unchanged.
Found by reading the dishwasher fixture's options array while answering a
question about it, which is also why diagnostics now reports advertised and
cloud slots separately: the difference between them is the whole distinction.
The eight new strings shipped as English placeholders in every non-English
catalog. Translated for cs/de/es/it/ko/nl.
Where a locale's course table already names the Download course itself, that
existing term is reused rather than a fresh coinage -- Korean's 다운로드 코스
is the catalog's own translation of course code 17, so the options flow now
says what the appliance display says. German and Dutch had no equally
distinctive existing term and build on the adjective already used for the
downloaded state.
Counts are not pluralized. The strings have no plural support and the
placeholders are raw numbers, so Czech, Italian and Dutch use the plural form
regardless of count -- the same simplification the rest of these catalogs
already make.
Trimming the names out of the dump last commit was the wrong call. Half of
what goes wrong with this feature is a configuration question -- which
programs got named, which Download course was confirmed, whether a payload
was ever captured for a slot the device advertises -- and none of that is
answerable from the payloads alone. A report saying "my download cycle isn't
showing up" is exactly the case that needs it.
The names are still the user's own words, so this block stays the one place
they appear; they reach a dump only because its owner chose to download and
share it. `resources` is unaffected either way -- it goes on reporting
exactly what the appliance said, via device_resources().
Four parallel reviews (reuse, simplification, efficiency, altitude). The two
that change behavior:
- observe() could report "changed" on every poll forever, rewriting the
config entry each time. If both tokens name the same slot with different
payloads -- a downloaded program with its settings tweaked for one run is
exactly that shape -- each pass wrote the default's blob then the one-shot's
over it, so neither was ever already stored. On the SD-card installs this
integration runs on, sustained entry rewrites are the one cost here that
bites. The end state is stable, so "changed" is now start-vs-end, not
per-assignment.
- The write path copied every tracked href to read one rep, walking past the
accessor added to avoid exactly that. New entity_rep() does the merge for a
single href; cycle_write drops the resources parameter it never used.
Structure:
- device_resources() is a second accessor giving the pure device view, used
by diagnostics and the debug read service. That deletes strip_synthetic,
the _SYNTHETIC_KEY_PREFIX convention and the redact filter added last
commit: "a dump is what the device said" is now which method you call
rather than something every future exporter has to remember.
- apply_cloud_courses() is the single mutation path. The flow was reaching
past the coordinator into the store and relying on a later call to persist
and invalidate for it; nine names are also now one entry write, not nine.
- option_value/hex_pairs move to capabilities/common.py. The duplicate's
stated reason -- that the coordinator shouldn't import from
registry.capabilities -- was simply false; it already does, and so does
learned.py. The real constraint is narrower: laundry.py imports
cloudcourse, so the reverse would be a cycle.
Dropped rather than kept:
- The cloud-vs-translated-local-course name check, and catalog.
translated_state_labels with it. The catalog this process can read is
English while the dropdown is localized in the frontend, so it rejected
"Cotton" for a German user seeing "Baumwolle" and missed the real collision
when they typed "Baumwolle" -- wrong in both directions outside one locale,
against an outcome option ordering already makes deterministic. The checks
that survive compare strings that are the same in every locale: the user's
own names, and the device's personal-course labels.
- stored(), clear()/forget_cloud_courses(), blob(), download_course() -- no
production callers. stored() was a template artifact whose docstring
described a caller that cannot exist here.
Diagnostics gains a cloud_courses block, which the store was missing next to
learned_modes -- payloads and which slots are named, but not the names
themselves, since those are the user's words and dumps get pasted publicly.
Kept against one reviewer's advice: option_tokens (two others called
generalizing option_write the right direction) and select._display's
uncatalogued branch, which names a condition the old fallback-is-None proxy
only got right by accident. Deferred: making the store per-subdevice. It is
MAIN-only today and no device seen advertises cloud programs elsewhere; the
limitation is now documented where it is made.
Also drops appliance-specific wording from the new user-facing strings. The
setup step said "your washer" and told people to "turn the dial", which is
wrong for the DW5000C dishwasher that advertises the same tokens.
The two that could have caused a wrong wash cycle:
- The Download-course candidate was counted on every poll that saw a loaded
one-time payload, not on the polls where one was actually loaded. Since a
stale token is never evicted, it keeps being reported through however long
the appliance then sits on some ordinary course -- so "most frequent"
ranked by dwell time. Reproduced: one poll on Course_87 then 200 on
Course_1B suggests 1B, and accepting the suggestion makes picking a
download program start a Cotton wash. Now only a change of the payload
counts, which is the moment the device is known to accept a program.
- The Download-course dropdown had custom_value=True, contradicting its own
comment, so a typed-in code went into the Course_ token of a real write
unchecked. Off now, plus a server-side check against the device's own
course list where the value is stored.
Two that quietly broke things beyond this feature:
- cycle_select now always supplies a display_fn (to label cloud programs),
which defeated select._display's "no state table and no fallback -> return
raw" exit. Every dryer, dishwasher and air dresser on an unrecognized
course table would have had its options and state reshaped from '0E' to
'0 E', breaking automations and recorder history. The exit now keys off
whether anything actually named the value, not whether a fallback existed.
- The synthetic cloud field reached diagnostics, which reads
canonical_resources -- publishing user-typed program names in a dump
people paste into issues, directly against the comment claiming it never
could. Dropped at the redaction boundary, with a matching strip for the
debug read service, which wants device state unredacted but shouldn't
present our bookkeeping as something the appliance said.
And two smaller ones:
- The repair fired on any device advertising slots, so the DW5000C -- four
advertised, none ever loaded -- got a permanent warning nothing the owner
did in Home Assistant could clear. It now waits until a payload has been
seen, which is the only evidence that household uses downloaded programs.
- The name-collision check read only the translation catalog, missing the
device's own personal-course labels, which the select renders identically.
A survey of every laundry diagnostics dump attached to an issue turned up 14
devices, 4 of which carry cloud-course tokens. Two were already known; the
two new ones are both useful, and one contradicts something the
investigation write-up asserted.
A DW5000C dishwasher (issues #113/#123) advertises four downloaded programs
and carries no payload token for any of them. That is a shape the corpus
didn't have: the feature is not washer-only (DA_DW, not DA_WM), and a device
can name programs whose payloads have never been observed. The existing code
already handles it correctly -- nothing learnable, nothing offered, gap still
counted for the Repairs issue -- so this adds the fixture, golden, and tests
that keep it that way.
A second WW5000C (issues #259/#343, firmware _B048) holds the same saved
program as the first one's captured "Towels", and the two payloads differ at
exactly one byte: byte 3, 04 against 06. Everything else -- id, slot, all
four varying tag values, the whole tail -- is identical. So byte 3 is neither
a per-board constant nor a property of the program, and the doc's claim that
it is always 04 on this board was wrong.
That is also the strongest argument yet for learning payloads per device: a
catalog keyed on program id would have shipped one unit's byte 3 to the
other. Nothing changes in the implementation as a result -- it never had a
catalog -- but the reasoning is now backed by evidence rather than caution.
Also recorded: both WW5000C units advertise the byte-identical slot list
despite different firmware, so the program set looks factory- or
region-assigned rather than user-curated; and a sentinel's byte 2 equals the
selected course on one dump but not the other, so it stays unused.
Byte-aligning the WA55A7700AV's 16-byte payload against the WW5000C's
20-byte one: identical header, and the first four tag/value pairs are the
same tags in the same order at the same offsets -- the part that carries
per-program data has one shape on both boards. The whole width difference
is two trailing pairs the WA55 doesn't carry, and on the WW5000C that
trailing section is byte-identical across all nine programs, so it isn't
program data at all.
Doesn't change the conclusion -- the four shared tags carry non-overlapping
value ranges between the boards, so the encoding is still board-specific and
blobs are still replayed whole. Also records why the WA55's /washer/vs/0
readings can't be used to confirm a decode: that unit is on a local course,
not its cloud course.
A washer whose course table includes "Download"/"Downloaded" runs whichever
program the SmartThings cloud last pushed down. Those programs are now
selectable from the ordinary cycle select, so a downloaded Jeans or Sports
cycle can be started without giving the appliance internet access.
The device turns out to enumerate them itself. `CloudExtraCourse_` on
/course/vs/0 lists one byte per downloaded program, and byte 2 of a
program's payload is exactly that slot id -- verified against all nine
programs on the reporter's WW5000C and against the WA55A7700AV dump already
in the corpus. So nothing here is hardcoded: the appliance says which
programs exist, the payloads are learned by watching what it reports, and
the names come from the user.
That last part is unavoidable rather than a shortcut. A payload is only
visible while its program is loaded, and the appliance never reports a name
for one. So cloudcourse.py persists what has been seen (same rationale as
learned.py's mode store), a Repairs issue tells the owner how many programs
are still unaccounted for, and an options-flow step collects the names. A
program appears in the cycle select only once it is both learned and named.
Selecting one issues the only two-token options write in the codebase --
the course token has to switch to Download in the same write, or the
appliance accepts the program token and silently ignores it (confirmed on
hardware). The Download course code is learned by observation but never
applied until the user confirms it: tokens in this array are replaced by
prefix and never evicted, so a stale program token can appear alongside an
unrelated course, and acting on that would start the wrong wash cycle. For
the same reason a stale token is never reported as the running program.
Also of note:
- There is no single "Download" course code. The WW5000C uses 87, the
WA55A7700AV uses 17 -- same Table_02. Any per-table lookup would have
been wrong on one of the only two devices available to check.
- Payloads are replayed byte-for-byte and never decomposed or rebuilt.
Bytes 5/7/9 do decode to temperature/rinse/spin on the WW5000C, 9 for 9,
and produce nonsense on the WA55A7700AV -- so that decode is written up
in docs/investigations/download-cycle.md and not shipped, and the
read-only sensors it would have enabled were dropped.
- The store reaches the registry as a namespaced synthetic field merged
onto /course/vs/0's rep at read time, so exists_fn/rep_fn/options/write_fn
all see it through their existing signatures. It never enters the state
cache, so it can't be polled over, written to the device, or land in a
diagnostics dump.
- A name that would render identically to another cycle in the same
dropdown is rejected in the flow: the select maps a chosen label back to
a raw value by matching display text.
Non-English catalogs carry the new strings in English for now; they need
real translations.
Fixes#345. progress and progress_percentage were gated on machine_state
alone, falling straight to "Idle"/0 the instant state left 'active' --
but a washer can flip state away from 'active' within the same poll
interval progress reaches 'Finish' (more reliably than the same-family
dryer, per the report), so an automation watching for a real 'Finish'
value could go a whole cycle without ever observing one.
Implemented read-side, per-entity (sensor.py's new _apply_sticky),
generalizing the existing _hysteresis_value/_apply_hysteresis pattern
finish_time already uses, rather than writing a synthetic override back
into the coordinator's cache. That cache is this integration's record of
what the device actually said, and is read by several unrelated
consumers -- write_fn, validate_fn, diagnostics, the observe-mode sweep
comparison against /device/0, _completion_minutes' own stale-remainingTime
workaround -- all of which would otherwise see fabricated state.
A first pass gated the hold's arm condition on state=='active' AND
progress=='Finish' occurring in the same rep, mirroring _is_active. A
second review caught that this could make the whole fix a no-op on the
one device #345 reports it for: if state has already reset by the time
progress is ever observed at 'Finish' -- exactly what #345 describes --
the arm condition never fires. _just_finished now arms on progress==
'Finish' alone. That reopens the staleness risk the state check existed
to guard against (a progress field stuck at 'Finish' forever would then
arm forever too), so _apply_sticky is edge-triggered: only a fresh
False->True transition (re)starts the window, and expiry is still
checked on every call even while the condition keeps matching -- a
stuck value still won't hold past sticky_seconds.
Dropping the state requirement also exposed a second gap: the bypass
that lets a new cycle's own real progress override a stale hold was
keyed on machine_state=='active', so it missed a new cycle immediately
paused (e.g. adding a sock) -- machine_state isn't 'active' while
paused. It's keyed on a live, non-Finish progress code instead
(_live_progress_code), independent of state, same reasoning as
_just_finished. And since the bypass needs the *real* live value, not
whatever rep_fn's own (differently gated) result says, SensorDesc grew
sticky_live_fn alongside sticky_value_fn: rep_fn's progress gate still
shows "Idle" while paused, but the real progress value read ungated
must win over the hold regardless.
registry/entities.py: SensorDesc gains sticky_fn/sticky_value_fn/
sticky_live_fn/sticky_bypass_fn/sticky_seconds -- see sensor.py's
_apply_sticky docstring for the full contract.
registry/capabilities/operational.py: progress/progress_percentage's
rep_fn is unchanged; they gain the sticky_* wiring above. machine_state
and the Running binary sensor are untouched -- still gated on real-time
state (cycle_active now shares _is_active's rep_fn directly rather than
a duplicate inline copy), so they never claim the appliance is still
running once it isn't.
async_send_command handed write_fn/validate_fn the raw cache snapshot
(real, on-the-wire hrefs), not a subdevice-scoped view. Every other
consumer of a full resources dict (exists_fn, rep_fn, is_legacy_board,
...) reads through coordinator.canonical_resources() specifically to
avoid this; write_fn/validate_fn didn't, so on a composite AC (issue
#177) _temperature_step's resources.get(HREF_TEMP_CONTROL) saw the
master's /temperature/control/vs/0 instead of the subdevice's own
/temperature/control/vs/1, silently rounding a subdevice's 0.5-degree
write to a whole degree. The remote-control gate stays on the raw
snapshot -- /remotectrl/* is a shared, MAIN-only resource a
subdevice's owned-hrefs-only canonical view would drop entirely.
climate.py's target_temperature_step duplicated this same
read-in-order logic; pointed it at airconditioner._temperature_step
so the read and write paths can't drift again.
Also, from the same review:
- _quantize_temperature rejects non-finite floats (nan/inf survive
float() but raise out of round()/division, escaping write_fn's
documented None-on-bad-payload contract).
- Deduplicated the quantize-and-check block shared by the
temperature_ocf/temperature branches of _climate_write.
- Added the /temperatures/vs/0 items[]-fallback test that was
previously unreachable (every increment-carrying fixture also has
/temperature/control/vs/0, which _temperature_step checks first).
- Added a coordinator-level test seeding an indexed subdevice with its
own step, distinct from the master's, covering the fix above.
- The Towels/Bedding regression test now checks all 7 locale catalogs,
not just English -- the bug is a code-mapping error, and
test_every_language_mirrors_the_english_catalog only checks key
topology, not values.
ClimateDesc.write_fn is typed as WriteFn (Callable[[Any, dict], ...]),
which only covers the (payload, rep) shape every other capability's
write_fn honors -- calling it through that alias with the climate-only
href/resources args, without first narrowing away the | None, failed
ty two ways: the missing "is not None" check and the extra positional
args past WriteFn's declared arity. Call _climate_write directly
instead, same as test_coordinator_send_command.py and
test_airconditioner_artik051_krac.py already do.
Issue #343: DA_WM_TP1_21_COMMON's washer_cycle_table_02 had course
codes 24 and 33 transposed -- selecting "Towels" in HA ran the
washer's Bedding cycle and vice versa (confirmed against the
reporter's diagnostics dump: Course_24 selected, courseTable
Table_02). Swapped both codes' labels back in line with the 69/6A-
79/88 family's own Bedding/Towels pair (6f/70), across every locale
catalog.
Issue #342: added the four course codes the reporter's editCourseList
carried with no catalog entry -- 06 (XXL Laundry), 08 (Rinse+Spin),
and a0 (15' Quick Wash) were missing outright; 74 (Drum Clean) turned
out to already be translated by the time this landed.
The download-course request in the same issue (selecting which
program a "Download" cycle fetches) is left for a follow-up -- still
waiting on a confirmed local write path before building anything on
top of the OneTimeCloudCourse/CloudCourse fields.
Samsung local AC temperature writes always rounded to the nearest
whole degree, dropping half-degree setpoints on boards that advertise
a 0.5 step (CAC and TP1X FAC). Squashed from moridew's PR #276 with
the review fixes applied:
- The /temperatures/vs/0 fallback never matched: its increment lives
inside the resource's items[] array, not at the top level (same
shape _temps_vs_item() already unwraps for current/unit). The
original fix only ever worked through /temperature/control/vs/0.
- With no increment advertised anywhere (e.g. ARTIK051), writes went
out unrounded instead of falling back to whole degrees the way
climate.py's target_temperature_step already does.
- A non-numeric payload now rejects the write (returns None) instead
of posting {"temperature": null} -- coordinator.py's
async_send_command already drops a write_fn result of None.
- int/float normalization now happens once, in _quantize_temperature,
instead of being duplicated (and skipped) per branch; a round(...,
2) guards against float division noise (e.g. 21.7 / 0.1).
Tests rebuilt against real fixture resources (airconditioner_cac,
airconditioner_artik051_krac_18k) instead of a fabricated flat
resource shape no device produces.
My local ty runs only covered custom_components, not tests -- CI runs
'ty check custom_components tests', which this branch had been failing
since the version-bump commit. All 13 diagnostics were the same two
established idioms this test suite already uses elsewhere, just missing
here:
- desc.write_fn/options_field are SelectDesc-only fields, unresolved on
the SamsungEntityDescription base a bare 'next(e for e in ... if
e.key == ...)' infers -- needs 'and isinstance(e, SelectDesc)' in the
filter, same as test_fridge_capabilities.py's existing selects.
- rep_fn/match_fn are typed Optional even after narrowing to a concrete
descriptor/capability, so calling one needs an explicit
'assert x.rep_fn is not None' first, same as
test_common_capabilities.py's POWER_VS_FALLBACK.match_fn precedent.
No behavior change -- test bodies are identical, just type-checkable.
DEODOR_FILTER reused AIR_FILTER.entities verbatim, so both capabilities
produced identically-keyed entities (air_filter_usage/air_filter_status)
despite living at different hrefs. adapter.flatten()'s key derivation has
no href component, so a unit reporting both /filter/airdustfilter/vs/0
and /filter/deodorfilter/vs/0 would silently clobber one filter's reading
with the other's -- the exact collision AIR_FILTER's own 'air_' prefix
was chosen to avoid against WATER_FILTER's filter_usage/filter_status.
Gives DEODOR_FILTER its own deodor_filter_usage/deodor_filter_status keys
(status still shares the filter_status translation_key, same as AIR_FILTER
already does). Updated the winecellar fixture's golden and test, and added
deodor_filter_usage to all seven translation catalogs.
AUTO_DOOR_SINGLE/KIMCHI/WINECELLAR were identical one-line no-entity
Capability declarations differing only by href. Replaced with
AUTO_DOOR_VARIANT, a pattern cap keyed on href_prefix='/autodoor/' and
gated by match_fn (presence of ado.openOptions) rather than the prefix
alone, so it only claims the variant-declaration hrefs and not
/autodoor/timer/vs/0 -- which doesn't matter in practice anyway, since
that href's own exact-href AUTO_DOOR_TIMER cap always wins first.
Registry-scoped (refrigerator.py's own pattern_capabilities list), not
global ignored.py -- the unknown-device-type fallback that motivates
ignored.py's 'exact hrefs only' rule never reaches this registry, so the
same constraint doesn't apply. A fourth fridge sub-type reporting this
feature at a new href now needs no code change to stay covered.
This board (NE63T8751SG/AA-class) reports no /information/vs/0 at all --
the modelNum-based routing fallback has nothing to read -- so it fell
back to 'unknown' and lost the whole range registry (oven mode/setpoint/
door/connected, cooktop monitoring). /oic/d does carry oic.d.range,
though, so this is a routing fix, not a new capability: adds 'oic.d.range'
to _OIC_TYPE_TO_KEY.
The second oven cavity is a genuine Pattern A indexed subdevice at
/device/1 (issue #177's mechanism) -- once routing resolves the master to
the range registry, the same registry already applies to the subdevice's
canonical view and every href on both binds with zero gaps.
_discover_full gains an optional device_types param (default (), every
other fixture unaffected) so a fixture that can only route via /oic/d can
exercise the same subdevice-aware pipeline the other composite fixtures
already do.
Three new dumps from one household's TP1X_REF_21K fleet (regular
single-door, kimchi, wine cellar) exposed the Auto Door Open feature's
timer and voice/sound feedback toggles, plus wine-cellar-specific
coverage: a deodorizing filter at its own href, a multi-compartment
pantry select, and a table-revision info resource.
- STATUS_LOCK gains auto_door_voice_control/auto_door_sound_control,
gated on each field's own presence.
- New AUTO_DOOR_TIMER (a discrete-options select, same shape as the
DEFINITE_TEMPERATURE_COOLER/FREEZER pattern) and three no-entity
AUTO_DOOR_SINGLE/KIMCHI/WINECELLAR coverage hrefs -- every dump seen
reports exactly one openOptions value with no paired current/desired
field to choose against.
- New DEODOR_FILTER (reuses AIR_FILTER's entities at a different href),
WINECELLAR_PANTRY_ZONE, and WINECELLAR_INFO.
- by_type: oic.d.krefrigerator and x.com.st.d.winecellar routed to the
refrigerator registry via /oic/d, alongside the existing modelNum-based
routing.
- kimchi_zone_mode's translation catalog gains three supportMode codes
(bare storage_fridge/storage_freezer without the _normal suffix, and
the apparently-placeholder newmode_kimchi_0000) surfaced by the kimchi
fixture, across all seven languages.
Three new scrubbed fixtures + goldens + tests, one per variant.
filterUsage is already a 0-100 percentage on every confirmed family,
including ARTIK051_PRAC: filterStatus flips to 'wash' at
filterUsage == '100' regardless of filterCapacity (60/224/500 across
other fixtures), which only holds if filterUsage is already a percent.
filter_usage_percent() divided by filterCapacity again, reading a
filter due for washing as 20% fresh.
air_filter_usage_hours had the mirror problem: it read filterUsage
directly as an hour count with device_class=duration, when the field
is a percent. It's now derived from the percentage and filterCapacity
(new filter_usage_hours() in common.py) instead.
_display_option read self._bound.desc.display_fn without narrowing
desc's type first, unlike every other method in this class -- desc is
typed as the base SamsungEntityDescription, which has no display_fn
(only SelectDesc does). Cast it, matching the rest of the class.
washer_cycle_fallback no longer wraps an unrecognized code in an
'Unknown (0xNN)' label -- that baked untranslatable English into a
component built to be fully translatable. It now only ever surfaces a
device-provided personal-course name; an unrecognized standard code
displays as its raw value, same as before PR #251.
Also translates nl.json's '69'/'88' washer labels left in English (same
review), and makes de.json's own 'smart' states consistent with the
'Intelligente Lüftung' translation already used for smartventilation.
PR #251 and PR #275 predate this repo's ruff-format adoption on those
files; running the formatter (single->double quotes, line wrapping,
trailing-comma cleanup) keeps the merged code consistent with the rest
of the codebase. No behavior change.
- Add German (de) translation catalog (PR #312, by @edenhaus)
- Backfill cs/nl with the washer/dishwasher/dryer course codes PR #275
added to en.json (85, 0c, 0d, 26, 2a, 35) so every shipped language
still mirrors the English catalog key-for-key
- Backfill German with every catalog key added to main since PR #312
was opened: the PR #251/#275 course-code additions, plus AC/fan
preset states, kimchi zone mode, edge/indicator lighting, energy
saving mode, the learned-modes options flow, and newer exception
messages
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
The href alone was not a sufficient key. /mode/convenient/vs/0 is
declared by three family registries with three meanings: a real preset
resource on the AC, explicitly unmodeled on the dehumidifier (no live
current-value field), empty on the air purifier. Matching on the href
globally meant a dehumidifier reporting a mode there would learn it,
persist it, and show it in diagnostics for a resource nothing offers.
The coordinator now narrows LEARNABLE to the hrefs a climate entity is
actually bound to, at discovery -- which also retires the per-rep
subdevice walk, since those hrefs are already actual.
With that, LEARNABLE is a plain frozenset of hrefs and the per-href
LearnRule goes away: its two fields were the same module constants for
its only entry. observe() now returns the codes it learned rather than a
bool the caller re-reads the store to interpret, so the log names what
was new instead of everything ever learned.
learned.py also takes ownership of the entry key and persisted shape --
the options flow was the second module that knew both, and the shape has
already changed once.
Comment trims throughout, per CONTRIBUTING: the LEARNABLE entry no
longer recounts how many reporters there were, and three copies of the
same test-stub comment are gone.
Flatten the store to {href: [codes]}. One href carries one LEARNABLE
rule, so keying the codes by the rule's supported field too let the
write side (rule.supported_field) and both read sides (the module-level
SUPPORTED_FIELD) disagree the moment a rule used a different field --
codes learned and persisted, then never offered.
The options flow's reset step read the persisted value raw in the
entry-not-loaded branch, so malformed data aborted the one screen that
can clear it; route it through LearnedModes like every other reader.
For the same reason forget_learned_modes() now persists whenever the
entry carries a record, not only when the in-memory store had one: a
record _coerce rejected at startup exists only on the entry.
Some firmware reports a current mode that is missing from the same
resource's supportedModes. An ARTIK051 air conditioner sits in Quiet
while advertising only [Off, Sleep, Speed, Nano, NanoSleep], so HA
showed preset_mode: quiet and then refused to select it. A second
reporter has three identical units where only the two sharing an
outdoor unit hide it, which rules out a real capability difference.
learned.py remembers any such code and the coordinator persists it on
the config entry, so a mode the device only names while it is active
survives a restart. climate._supported unions it into the resource's
own list, which fixes the read and the write together --
async_set_preset_mode reverse-resolves the device code from that same
list.
Learning is allowlisted per canonical href rather than global. Across
the fixture corpus 17 dumps already report a current mode that is not
in supportedModes: an oven idling in NoOperation, a fridge's
/mode/vs/0 carrying capability tokens like WATERFILTER_DISABLE. Those
are not selectable options, and remembering one permanently would put
an option in the UI that the device can only reject. Only
/mode/convenient/vs/0 is learnable today.
On by default, with a per-device option that stops offering and
learning at once, and a reset step in the options flow for a code that
turns out to be bogus. Diagnostics report what was learned separately
from `resources`, which stays exactly what the device said.
Holding _session_lock for a whole write sequence buys certainty about what
the appliance saw and when, but blocks every poll and entity write for the
sequence's full length -- up to 10 x 30s. Which of those matters more
depends on what is being probed, so it is now hold_session_lock on
async_raw_write_sequence and a field on the service, defaulting to the
holding behavior that shipped.
Off, the lock is taken per write and released across the settle waits, so
entities keep updating through a long sequence. Exactly one of the two
context managers is ever the real lock -- asyncio.Lock isn't reentrant.
Tests assert the lock's actual state during the settle wait in both modes,
rather than just that the flag is accepted.
Hassfest rejects a filtered device target outright ("Services do not
support device filters on target, use a device selector instead"), and an
unfiltered one would offer every device in the installation. Both services
now take device_id as a required field with a device selector scoped to
this integration -- the shape fully_kiosk, guardian and unifi already use.
No schema change needed: cv.TARGET_SERVICE_FIELDS already accepts
device_id, so the options-flow panel's target= call keeps working.
Also trims the comments added with the review fixes back to the one or two
sentences CONTRIBUTING asks for.
- services.py: normalize an href before handing it to Subdevice.to_actual.
That transform is textual and rewrites only a trailing '0' segment, so
'/mode/vs/0/' passed through it untouched and normalized downstream to
the master's '/mode/vs/0' -- landing the write on the wrong oven cavity
while still answering 2.04, with nothing in the response to give it
away. Same order now on the read path.
- services.py: key `verified` off those same normalized canonicals. It was
built from un-normalized to_actual output against the coordinator's
normalized hrefs, so a non-canonical input missed the lookup and handed
back actual hrefs where the documented contract promises canonical ones.
- coordinator.py: report `held: None` when the verify re-read itself
didn't come back. A non-2.05 yields an empty rep, against which every
payload comparison is False, so a 4.04 or dropped read was reported as
`held: false` -- indistinguishable from the board reverting the write,
which is the one distinction verify_after exists to draw.
- coordinator.py: on a mid-sequence failure, say how many writes landed
and which, and still kick the refresh. Raising bare threw that away, and
the appliance is left holding a partial sequence.
Also documents why `settle` waits inside the session lock while
verify_after's wait deliberately doesn't: a poll landing between two
writes is exactly what the sequence exists to rule out, and the caps
bound the worst case at 10 x 30s.
The README's worked example and services.yaml's field example both wrote
`x.com.samsung.da.mode: "Bake"` to /mode/vs/0 -- singular, and a bare
string. That resource takes `modes` as an array (issue #300's own dump
shows `["NoOperation"]`), so both examples were a shape the device would
have ignored, in the one place a user is most likely to copy from. The
README's other two steps were invented the same way; replaced with the
mode -> state: Run sequence issue #300 is actually trying to prove out.
Also adds a short note that payloads go out verbatim, so field names and
types have to match what the resource really uses, pointing at
read_resource with no href as the way to check first -- and aligns the
two new Repo layout rows with the column their neighbors use.
The options-flow "Debug write" panel could only ever do one write to one
href per pass -- not enough for the issue #300 wall oven, whose board
discards settings writes while idle and only keeps them once a cycle is
already running. Finding what starts a cycle needs an ordered sequence of
writes across resources, with real settle delays between them, and a way
to check afterward whether anything actually held.
- coordinator.py: async_raw_write_sequence owns a whole ordered sequence
under one _session_lock hold (so a poll can't interleave mid-sequence),
with per-step settle and an optional delayed verify_after re-read done
outside the lock. async_raw_write is now a one-item wrapper over it, so
tests/test_coordinator_raw_write.py keeps passing unmodified. Also adds
async_raw_read, a live GET bypassing the cache -- staleness is exactly
what makes revert-testing unreliable.
- services.py (new): the two HA services. Device-target resolution scans
loaded coordinators' MAIN/subdevice identifiers and requires exactly one
match, so an area/label target can't silently fan a raw write out across
several appliances. Canonical->actual href translation happens here, not
in the coordinator, which stays subdevice-agnostic.
- services.yaml (new): selectors/descriptions for both services, inline
per HA's custom-integration support -- keeps translations/en.json's
mirror test (test_translations.py) green without touching all 6
languages for a services block. New exception keys (write caps, device
target resolution) still went into translations/*.json's existing
exceptions section, mirrored across all 6 languages.
- __init__.py: adds async_setup to register the services once, process-wide.
- config_flow.py: the debug panel's async_step_debug_edit now calls
write_resource instead of coord.async_raw_write directly, so there is
exactly one code path that performs a raw write.
- README.md: new Part 5 documenting both services, with a worked
write_resource example; points the capability-gap section at them.
tests/test_services.py (new): sequencing/ordering, settle timing, changed
vs. held (the reverted case is issue #300's own symptom), exactly-one-
device resolution, subdevice href translation, validation caps, and the
options-flow panel end to end through the service.
Same feature name as the WindFree already modeled via climate.py's preset
system on regular AC boards, but a genuinely different wire mechanism --
this device's fields live on their own dedicated hrefs with no evidenced
coupling to hvac_mode, unlike the Comode_Nano token's real gating rules on
legacy boards. Recorded in-line so this doesn't come up as a 'why isn't
this a preset' question again without the answer already being there.
PR #316 (fork stale by several months, most of its ~2200-line diff was drift
against main rather than real changes) proposed device support for the
Samsung System Fresh Air Ventilator (ACA-KR-TP2-21-AN9000). Extracted what
holds up, adapted to this project's conventions, and left out what doesn't:
Extracted:
- ventilation_mode select on CLIMATE's own href, gated via
_is_ventilation_mode_device so it can only ever bind on a device whose
entire supportedModes set is Purification/Ventilation/SmartVentilation --
verified against every real AC fixture in the corpus to confirm it can't
false-positive on an actual air conditioner's climate card.
- WINDFREE / WINDSLEEP switches on their own dedicated hrefs.
- A CO2 sensor on AIR_QUALITY, matching air_monitor.SENSORS' already-bound
device_class='carbon_dioxide'/unit='ppm' descriptor for the same field
shape rather than guessing fresh.
- HEPA_FILTER / DEVICE_ACTIVE reuse from air_purifier.py.
- Removing /airlevelcheck/vs/0 from _AC_IGNORED and binding
air_purifier.AIR_LEVEL_CHECK in its place: the PR's claim that this
project's old "scheduler plumbing" description was wrong turned out to
be independently verifiable against two of our own existing fixtures
(airconditioner_cac and airconditioner_tp1x_da_ac_rac_01011 both already
carry real, populated periodicSensingActivationState/autoExeState
values), so this benefits existing users, not just the one new device.
Left out:
- Unit/device_class ('ug/m3', pm10/pm25/pm1) on the existing dust/
fine_dust/super_fine_dust sensors, sourced from an unverified third-party
screenshot description. air_monitor.py already has an explicit, reasoned
rejection of this exact mapping for the exact same three fields:
Samsung's PM10/PM2.5 convention doesn't confirm where a third tier or a
PM1 reading fits, and a wrong guess mislabels the reading forever.
- A standalone common.POWER switch -- contradicts this registry's own
documented design (power is deliberately the climate entity's job) and
would affect every AC user, not just this device.
- Promoting wind/swing to independent selects for every AC user -- a UX
opinion, not a coverage necessity, and out of scope for this device's
own support.
- A model-name diagnostic sensor -- /information/vs/0 is already covered
via the global ignore list, so this wasn't closing an actual gap.
No raw diagnostics dump for this model was ever attached to PR #316, so
there's no fixture for it here (fabricating one would violate this
project's fixture-integrity rule) -- see
tests/test_airconditioner_ventilation_windfree.py's module docstring.
resolve()/for_device_by_model() return DeviceRegistry | None; four new
test files used reg.capabilities/reg.pattern_capabilities without
narrowing away None first. Add the same 'assert reg is not None' idiom
test_dehumidifier_tp1x_dhm01001_capabilities.py already uses.
Verified against a clean venv running the exact CI commands (ruff format
--check, ruff check, ty check, pytest) rather than trusting a stale local
venv that had picked up a mismatched python3.11/3.13 site-packages split.
- Fix a real bug: airconditioner.SOUND_MODE had no exists_fn, so on
boards (issue #319's FAC) that never report a live 'mode' value,
entity.py's default field-presence gate silently kept the select from
ever registering in HA -- while adapter.flatten() (what the golden/tests
read) has no such gate, so the tests passed while documenting behavior
the opposite of what shipped. Gate on supportedModes' presence instead.
- Add airconditioner.MDS_ABSENCE_CLEAN for the CAC-class board's
/mds/absenceclean/vs/0 -- byte-identical shape to issue #319's
/csi/absenceclean/vs/0, confirmed rather than guessed, closing one more
of that board's documented coverage-gap hrefs.
- Add missing translation state labels (all 6 languages) for
edge_lighting_mode/edge_lighting_color/indicator_light_mode's raw device
codes, so they render as real words instead of a raw '3000K' -> '3000 K'
fallback.
- Fix an orphaned comment above SOUND_MODE that actually described the
unrelated DISPLAY reuse, and correct two inaccurate rationale comments:
the sound/voice ignore reason claimed a distinction from SOUND_MODE that
this same dump contradicts, and the /csi/* ignore block's 'same
reasoning as air_purifier.COVERAGE' precedent only actually covers 1 of
its 5 hrefs.
- Correct the false 'no board-token match' claim in the FAC test file and
golden-regression docstring -- 'FAC' is a real _BOARD_TOKEN_TO_KEY entry
(for_device_by_model alone already resolves this board); add a test
that actually exercises that path, which nothing previously did despite
the docstring's claim.
- Drop a tautological burner-slot test that only re-asserted what the
golden regression test already covers via the same code path.
Six System A/C cassette units on the same board test_airconditioner_cac.py
already documented as having an incomplete coverage gap gave real dump
evidence for two of its remaining unbound hrefs:
- /edgelighting/vs/0: an accent-light strip with on/off, a Smart/High/Low
mode, and a Kelvin color-temperature select (3000K/4000K/6500K), all read
from the device's own live supported-value lists.
- /light/stateful/vs/0: a second, distinct light resource with its own
on/off and Smart/Low/High mode -- not to be confused with EDGE_LIGHTING
or DISPLAY_LIGHT's ambient mood light.
convenientMode/operatingOption on /edgelighting/vs/0 stay unexposed: present
on every dump but no evidence of what either actually controls.
Only three hrefs remain in test_airconditioner_cac.py's documented gap now
(absence-clean, sound-optimization, smart-sensing-cooling).
/diagnosis/vs/0 was the dump's only unbound href, now covered via
dishwasher.DIAGNOSIS (same shape already reused by airconditioner.py).
This steam-oven-class board's /mode/vs/0 options[] carries no UpperLamp_
token at all, unlike the NV7000BS-class board LAMP was proven against --
LAMP had no exists_fn, so it registered anyway, always read Off, and any
write to it was a no-op the device had no reason to honor. Gives it the
same options-token exists_fn gate issue #183 already added to
fast_preheat/natural_steam/energy_saving/cooktop_on_alert.
/alarms/vs/0 and /kidslock/vs/0 were the dump's two unbound hrefs -- both
are the exact shapes common.UNIVERSAL already models elsewhere
(common.ALARMS, common.KIDS_LOCK_VS_FALLBACK), picked individually rather
than pulling in all of UNIVERSAL to match this registry's existing
hand-picked-common style.
The six-vs-three burner count the reporter originally asked about is
expected behavior (the board's own /mode/vs/0 options genuinely advertise
six OperationState slots on hardware with three physical burners, with no
per-device signal to tell real slots from phantom ones) -- already
explained on the issue; this commit is scoped to the coverage warning.
/filter/airdustfilter/vs/0 was the dump's only unbound href -- this board's
internal deodorizing filter, same filterUsage/filterStatus field pair as
common.WATER_FILTER, but filterUsage here is already a 0-100 percentage
with no filterCapacity to divide by (confirmed by filterStatus=="wash" at
filterUsage=="100"). Uses air_-prefixed keys so a fridge with both a
water and an air filter gets two distinct entities.
This board routes purely via /oic/d's oic.d.airconditioner type (no
board-token match) and reports several resources the sibling
TP1X_DA-AC-CAC-01001 board (issue #191) left as a documented gap:
- /display/vs/0, /settings/sound/output/vs/0, /settings/sound/volume/vs/0
now reuse air_purifier.py's identical-shape capabilities instead of
duplicating them.
- /settings/sound/mode/vs/0 gets a new airconditioner.SOUND_MODE reading
the live supportedModes field, sharing laundry.py's existing
voice/tone/mute translation catalog since the value vocabulary matches.
- /csi/absenceclean/vs/0 and /csi/energysaving/vs/0 are new, genuinely
useful controls (absence auto-clean toggle, energy-saving mode select
plus its state/operatingStatus diagnostics).
- /dnd/autosleep/vs/0, /outdoorsharing/vs/0, /lifestyle/survey/vs/0,
/settings/sound/voice/vs/0 and /csi/information/vs/0 are ignored as
plumbing/unconfirmed data with no user-actionable state.
Also fixes a latent bug in air_purifier.SOUND_VOLUME: boards that report
minLevel/resolution but no maxLevel (this one) would have produced a
min=0/max=0 number entity instead of self-gating off.
Updates test_airconditioner_cac.py's documented coverage gap now that
sound_mode/sound_output/sound_volume are covered there too.
Review question on #304: a bare Comode_Off written over Comode_Sleep/Sleep_4
read back as Comode_Off/Sleep_0 at +8s and +38s, so the preset path cannot
leave a stale duration for the next nano selection to read as a running timer.
One map is not enough to judge by: with only OptionCode present, every
eoc-gated rule reads None, and None means the board does not publish the map
rather than that the feature is absent. artik051_dongle_fac_18k is exactly that
board and lost WindFree in every mode. Requiring both also keeps these bit
positions inside the family they were documented for -- the FAC and CAC dumps
carry only the older map, with values small enough that RAC positions read as
zeros.
Also from review: an unknown HVAC mode falls back the same way, Comfort is
spelled like the identical Speed rule, the unreachable AIComfort branch is
gone, the Cool code comes from the unit's own supportedModes, DlightCool gains
its catalog entry, and the Single User claim is dropped -- the app's own Single
User command sends Comode_Smart, so there is no distinct token to write.
coordinator is explicitly typed as LocalThingsCoordinator here, so ty
correctly sees _session's declared type (DtlsCoapSession | None) and
flags assigning a FakeObserveSession to it. The fixture's own
_connect_session replacement does the same swap without tripping ty,
but only because its self parameter is unannotated -- ty has nothing to
check the assignment against there. Deliberate here (this is the whole
point of the test: substitute a stand-in session), so silenced rather
than restructured; ty's --add-ignore confirmed the comment syntax
(ty: ignore[...], not the mypy-style type: ignore[...] used elsewhere
in this suite, which ty doesn't appear to honor for this rule).
A second review of the observe-mode phase split (previous commit) found
two real regressions it introduced, both in the same failure family it
was built to close:
- async_send_command's failed-retry branch closed the session, then
raised without downgrading observe mode -- the downgrade only ran on
the retry's success path. A retry that also fails still leaves the
session dead, so mode was left claiming "Push" on a session that no
longer exists, same as the bug this whole fix targets. Moved the
downgrade to run right after the close, unconditionally on how the
retry goes.
- _attempt_observe_mode's stale-session abandon (the identity-check
branch added in the previous commit) didn't flag a resubscribe. A
session swap discovered there means a fresh, never-tried session now
exists, but _last_observe_attempt_ts was already stamped for the
now-abandoned attempt -- so that new session sat unsubscribed for up
to _RECOVERY_RETRY_S (600s) instead of being retried on the next
cycle. Now sets _resubscribe_due, same as the two reconnect paths do.
Also closes two test-coverage gaps the same review surfaced by mutation
testing: no test asserted the lock actually holds during the subscribe
burst (only that it's released for the wait), and no test distinguished
the max() in _maybe_retry_observe_mode's throttle from using
_last_observe_attempt_ts alone -- both mutations left the full suite
green. Added one test for each, plus extended two existing tests for the
bug fixes above; all four confirmed via mutation testing (revert the
fix, watch the new/extended test fail; restore it, watch it pass).
One finding from the same review is intentionally left open: async_close
is the one self._session writer that doesn't take _session_lock, so a
close racing _attempt_observe_mode isn't covered by today's identity
check. This is pre-existing (the lock didn't cover any of
_attempt_observe_mode before this branch's earlier commits either), not
a regression from this branch's work, and is a shutdown/unload-path
question rather than the write-vs-observe-mode race this branch set out
to fix.
The command-retry fix (issue #294) added a self._close_session() call to
async_send_command that isn't synchronized against _attempt_observe_mode,
which reads self._session and subscribes to it without holding
_session_lock. A write's retry racing an in-flight subscribe attempt
could tear down the session mid-subscribe -- or worse, land the close
*after* the attempt's grace wait already succeeded, letting it commit
observe mode against a session that's already gone: mode claims "Push"
forever, with nothing left to notice the underlying socket is dead.
Split ObserveManager.try_enter_observe_mode into four pieces
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
abandon_observe_attempt), keeping try_enter_observe_mode as a thin
wrapper so its direct callers in test_observe.py are unaffected.
_attempt_observe_mode now holds _session_lock only for the subscribe
burst (each send is fire-and-forget, not a network round trip) and
re-checks self._session is sess under the lock right before committing
-- sess keeps the old session object alive, so identity can't be
recycled onto a new one, which is what makes the check sufficient
without a separate generation counter. The wait itself stays lock-free,
so a command write is never blocked behind it.
Two more bugs the same investigation turned up, fixed in the same pass
since they're direct consequences of the design above:
- async_send_command's own successful reconnect didn't downgrade observe
mode the way the poll path's reconnect already does, leaving the same
stale-commit problem reachable with zero concurrency at all -- just a
write's retry succeeding while mode was observe. Replaced the poll
path's local just_downgraded_from_observe with an instance flag both
reconnect sites set, so either one triggers an immediate resubscribe.
- _maybe_retry_observe_mode's 600s throttle gated solely on
last_mode_change_ts, which _set_mode only stamps on an actual
transition -- a device that never succeeds at observe mode leaves that
timestamp stuck at construction time, so the throttle opens once and
never closes again, re-attempting on every single poll cycle instead
of every 600s. Now gates on the more recent of that timestamp and a
new _last_observe_attempt_ts, stamped on every attempt regardless of
outcome.
An Opus review of the three prior commits on this branch (PR #306)
turned up two real defects and a documentation/test gap, all fixed
here:
- async_send_command's retry (issue #294) armed the write-settle
window before the retry existed, so the reconnect pause plus a
second PUT could eat into the time meant for the confirming poll,
reviving the revert-then-reapply symptom the window was sized to
prevent (issue #9). Re-arm it after a successful retry lands.
- test_send_command_reconnects_and_retries_after_socket_closed relied
on the observe-session fixture's no-op _close_session, so
self._session never actually went None and _do_put's reconnect
guard was never exercised -- the test passed even with that guard
deleted. Now overrides _close_session/_connect_session to actually
drop and rebuild the session, and asserts the reconnect happened.
- async_send_command's docstring still said "Fire-and-forget", which
stopped being true the moment it started retrying and raising.
Also trimmed the three comment blocks the review flagged as
reproducing their commit messages verbatim, per CONTRIBUTING.md's
comment-style rules.
One review finding is not addressed here and needs a decision: the
new _close_session() call in the command-retry path isn't
synchronized against _attempt_observe_mode, which touches the session
without _session_lock. A write's reconnect can race an in-flight
observe-mode subscribe attempt and tear down the session it's using.
Fixing it properly means broadening lock scope around observe-mode
entry, which risks blocking a write behind an up to ~15s subscribe
grace period -- a tradeoff not made unilaterally here.
A second finding (dropping the old .strip()'s per-line whitespace
handling in _normalize_pem) did not reproduce against a real
certificate/key, only against the test suite's placeholder PEM body,
so it's left as-is.
async_send_command's _do_put caught any exception, logged it, and
returned -- no reconnect, no retry, no error the user could see. A
command landing on a session Samsung's firmware closed between polls
(the same 'known device behavior' _async_update_data already
reconnects around) was silently lost, with nothing to do about it but
a manual reload of the device (issue #294).
Mirror the poll path's own recovery: on failure, close the dead
session, pause, and retry the PUT once against a freshly reconnected
one. If that also fails, raise a HomeAssistantError instead of just
logging, so the user gets a visible error rather than a command that
quietly did nothing. The retry runs under the same session lock the
poll path uses, so a write landing mid-reconnect can't race a
concurrent poll cycle rebuilding the same session.
When a poll fails and the immediate reconnect retry fails too,
_async_update_data returned the last-known snapshot as a degraded
success (issue #254) without ever touching observe mode. That's fine
for the data itself, but the connection-mode sensor reads straight
from self._observe.mode, and only the *successful* reconnect branch
ever changed it -- so a device that drops off the network entirely
(air-gapped, powered off, Wi-Fi down) left that sensor reporting
"Push" forever, hours after the session was actually dead (issue
#287).
Downgrade to poll mode on the failure branch too, without attempting
an immediate resubscribe: the reconnect that would normally justify
one just proved there's no live session to subscribe on. Recovery
still happens on its own once the device is reachable again, via the
existing poll-mode retry timer (_maybe_retry_observe_mode).
A PEM pasted from a text editor can carry bytes cryptography's parser
refuses outright: a UTF-8 BOM some Windows editors silently prepend,
CRLF line endings, and a stray blank line a paste can introduce
between the header/body/footer. None of those are meaningful in PEM,
but any of them surfaces as an opaque InvalidHeader with no hint of
what's wrong -- which is why the same certificate pasted from
Command Prompt's `type` (no BOM, no stray blank lines) loads fine
while the same file opened in an editor and copied doesn't (issue
#291).
Normalize at the point the pasted blob is first captured, not just
before minting the leaf cert: the same string is stored in the config
entry and reused to re-mint the leaf on a future reconfigure, so a
raw copy would keep failing every time it's read back, not just on
the first attempt.
The fixed list of six was offered in every mode on every legacy board. The
appliance publishes what it has as two bit maps in /mode/vs/0's options, and its
own app gates each comfort mode on a bit plus the current mode; this transcribes
that logic. WindFree also needs the mode written before it in Auto, which is
measured rather than assumed.
Sleep_<n> written on its own is answered 2.04 Changed and then discarded, so
the Number wrote nothing at all. Nano wind shares the same Comode_ slot, which
is why writing the nano preset over a running timer silently changed its
duration, and why the two sleep codes the board reports had to become presets:
a preset_mode outside preset_modes is not a state HA allows.
The Sleep_ token was published as if its value were hours. It is not: the
appliance's own app pairs a duration picker with the values it puts on the wire,
one to one, and the pairing is half hours.
0:00 0:30 1:00 1:30 2:00 2:30 3:00 4:00 5:00 ... 12:00
0 1 2 3 4 5 6 8 10 ... 24
So the entity capped at 12 hours actually set six, every value asked for was
halved on the appliance, and twelve hours -- the app's own maximum, stated in its
help text -- could not be reached at all. The reading is halved and the write
doubled, and the step drops to 0.5 because that is the resolution the picker
offers.
Half-hour steps are what the app offers below three hours; above that it offers
whole hours only, so a half hour up there is untested rather than known-bad. A
Number cannot change step part-way, and turning this into a Select of the app's
sixteen values would change the entity's domain on every unit that already has
one, so the step stays 0.5 throughout and the comment says why.
The descriptor's own comment used to admit the upper bound was a guess ("only 0
has been observed on hardware"). The guess of 12 was right; the unit it was
expressed in was not.
test_every_language_mirrors_the_english_catalog is right to fail on this: a key
present only in English falls back silently at runtime, which reads as a
half-finished translation rather than a missing one.
Also fills in the same key for ko.json, which the original fix predates:
Korean's translation catalog was added after this branch was cut and never
picked up filter_time_reset either.
FilterCleanAlarm_Clear, through the same single-token options merge as every
other setting on /mode/vs/0. Measured on an ARTIK051_KRAC_18K: 2.04 Changed and
FilterTime_95 (9 h 30 min) -> FilterTime_0, still zero on a fresh DTLS session
and on every poll after; none of the other 17 tokens moved and the alarm
entries stayed Deleted.
The counter has had no reset until now, and the descriptor said so: two earlier
rounds against live hardware failed, and the conclusion drawn from them was
that the reset had to be cloud-only. That conclusion was wrong, and the way it
was reached is the interesting part -- it came from diffing every resource the
appliance reports before and after pressing reset in Samsung's app, which
showed only the counter zeroing and the alarm clearing. A trigger token cannot
show up in such a diff, because a trigger is never stored. The appliance's own
app sends this token and skips the write when the counter is already zero.
Both failures stay in the comment, because they say what this is not: writing
FilterTime_0 (the value is not writable -- 5595 -> 5595 after 69 s, 1925 ->
1925 after 65 s, two units, opposite power states), and POSTing the cloud
capability's command name to /actions/vs/0 (real name, wrong transport).
Gated on the FilterTime_ token, so it appears only where there is a counter to
reset; newer boards report filter usage through their own resource and would
need a different mechanism.
ko.json (PR #283) was written against an older en.json and never picked up
the ten keys two later PRs added: the AI Purify sensing entities
(switch.periodic_air_sensing, switch.periodic_sensing_skip_status,
number.sensing_interval, select.sensing_mode + its three states,
time.sensing_skip_start/end) and button.auto_clean_stop. Merging main
into this branch surfaced the gap via
test_every_language_mirrors_the_english_catalog.
Translated the missing entries, matching the terminology and phrasing
ko.json already uses for adjacent keys (e.g. periodic_air_sensing's
existing binary_sensor entry, auto_clean's "자동 청소"), and kept "AI
Purify" as the untranslated brand name the same way cs/es/it/nl do.
ko.json's topology now matches en.json's exactly; full suite (1213
tests), ruff, and ty all pass.
CONTRIBUTING.md gains a "Code comments" section: comment the why not
the what, keep it to a sentence or two with a pointer to the load-bearing
evidence, don't re-derive a sibling's already-documented reasoning, and
move failed-attempt investigation logs out of inline comments.
Applied that policy across the codebase: condensed sprawling module
docstrings, per-entity essays, and multi-paragraph rationale blocks down
to their load-bearing conclusions, while preserving the actual "why"
(issue numbers, calibration evidence, gotchas, don't-guess rationale).
No functional code changed — verified via diff review, ruff, ty, and the
full pytest suite (1211 passed).
One inline investigation log (the AC filter-reset "tried and failed"
notes) moved to docs/investigations/ac-filter-reset.md rather than being
deleted, per the new guideline on where that kind of record belongs.
Three tokens describe the drying cycle these boards run after cooling, and the
switch only covered the first. AutocleanProgress_ is how far a running cycle
has got, and StopAutoClean_ is a channel for ending one early -- its presence
is what says the appliance accepts that at all, which is how the appliance's
own app gates its stop button. Both tokens are reported by the ARTIK051_KRAC_18K
that issue #136 was about, and by every KRAC fixture here.
The percentage scale is the app's own: it renders the token into a
`<progress max="100">` with a "{{value}}%" label beside it. An idle unit reports
1 rather than 0 -- the same floor the laundry firmware's progressPercentage sits
at when Ready -- so 0-vs-1 is not a reliable "is it running" test, and the
button is deliberately not gated on it.
The sensor shares AUTO_CLEAN's catalog entry the way auto_clean_legacy already
shares the switch's: same figure, different board generation, distinct key so
nothing collides if a board ever reported both.
Stacked on #289 (this branch is cut from it) -- rebase or merge that first.
The frozenset was a parallel structure for a per-row fact, and the comment
above the tuple already described it as a fourth column -- so the comment
promised the right shape and the code did something else. Fixed to the shape
the comment described: _AIR_QUALITY_SENSORS carries state_class per row and
the comprehension unpacks it, with _RECORDED_AIR_QUALITY and its duplicated
rationale block deleted.
air_monitor imports the same rows and now unpacks four, but discards the
fourth. That board (issue #210) has stamped all five readings as
`measurement` since it was added; consuming the column would silently drop
long-term statistics for Odor and CleanLevel on shipped devices, which is a
behaviour change this branch has no evidence to make. The grade/concentration
split stays scoped to the air purifier.
test_shared_sensor_tuple_keeps_its_three_column_shape asserted the premise
this replaces -- that widening the tuple breaks air_monitor's import -- so it
is replaced rather than renumbered: one test that the rows carry their own
state_class, and one that air_monitor still imports and still stamps all five.
Dropping native_min to 0 fixed the read range and quietly opened a write:
native_min governs what the user can enter, not just what renders, so 0
became enterable and would have gone out as periodicSensingInterval "0".
Nothing establishes what that does to this board -- both fixtures report 600,
the app's smallest choice is 10 min, and 60 s is the lowest value confirmed
accepted. The two precedents leaned on differ in exactly the way that matters:
oven.cook_time and operational.delay_start_hours sit at a zero floor under a
value where 0 is a real setting ("no timer", "no delay").
One minute is also the resolution this board reports results at.
lastSensingTime lands on an exact minute on every sample from the AVT-WW-TP1
and A-VTWW-TP2 boards -- both fixtures, plus eleven consecutive live readings
-- where the TP1X/AC/hood boards report arbitrary seconds. A sub-minute
interval is unobservable here whether or not the board honours it.
So native_min goes to 1 rather than 0, and the read rounds up instead of to
nearest so a sub-minute reading renders as 1 rather than falling below the
entity's own floor. The write still refuses anything under a minute -- a None
return, the silent no-op range_hood._lamp_level_write uses for a level the
device didn't advertise -- since native_min only guards the UI path, not a
service call.
Review feedback on #268. The largest change is that the sensing-mode select
no longer folds two device fields into one control.
periodicSensingActivationState and autoExeState are independent knobs, and the
appliance presents them that way -- its own UI has an on/off for AI Purify
separately from the three mode choices. Folding them lost two things: a
configured action was invisible while the feature was off, and no select
option could toggle the feature without also overwriting the action. The
switch was not the duplicate it looked like.
So the switch now owns periodicSensingActivationState alone, and the select
owns autoExeState alone. That resolves the hardcoded-options finding at the
source rather than working around it: the select reads supportedAutoExeState
via options_field -- the same shape SOUND_MODE already uses for
supportedModes -- instead of carrying a typed-in tuple, so a board advertising
a fourth action is accepted on both the options list and the write path.
_sensing_mode, _sensing_mode_write and _SENSING_MODE_BODIES are all gone with
the fold.
Option slugs are now the advertised values lowercased (off / airpurify /
alarm) rather than invented names. The catalog carries the labels, so the two
'off's stay distinguishable in the UI: the switch's means the unit isn't
sampling, the select's means it samples and doesn't act on the reading -- what
the app calls "sensing only".
Also from the review:
* _interval_minutes checks `is None` so a reported 0 stays 0, and native_min
drops to 0 since sub-30s values round there. oven.cook_time and
operational's delay hours are the precedent -- both convert a device time
value and floor at zero. The Number-rather-than-Select choice is now
stated in the write helper: the app offers three fixed intervals, but this
resource advertises no supported-values or range field (supportedAutoExeState
sits right beside it, so the board does advertise constraints where it has
them) and it accepted 60 s, six times finer than the app's smallest choice.
* _skip_time_write no longer splices a malformed half back onto the wire.
The read side already refuses one it can't parse; the write side now
zeroes it to match.
* air_sensing_state and last_air_sensing_level lose enabled_default=False,
matching range_hood.AIR_LEVEL_CHECK. Hiding two of three read-only keys
while claiming key parity with that capability -- and leaving the third
visible -- had no justification behind it.
* The catalog-parity test drops periodic_air_sensing from its key set: that
key is a SwitchDesc here and a BinarySensorDesc on the hood, so the two
live in different platform catalogs and are worded differently. The claim
now covers only the three read-only sensor keys, where it holds.
* Tests route through the descriptors (_desc(key).write_fn / .value_fn)
rather than module-private helpers, matching test_air_monitor_capabilities.
startSensingOnce stays unbound, now explicitly rather than by omission -- the
module comment records it as deferred. It looks like a one-shot "sense now"
button, but this board acknowledges writes it discards, and nothing has
confirmed the side effect yet.
Goldens are untouched: the key set is unchanged, only sensing_mode's value
moves from the folded slug to the raw autoExeState.
/airlevelcheck/vs/0 has been covered as "periodic air-quality sensing
scheduler plumbing" since the registry gained a coverage stub for it. Two
AVT-WW-TP1-23-AXX500 dumps (issues #84 and #190) show it is not plumbing: it
drives the feature the SmartThings app calls AI Purify, where the unit wakes
on a timer, samples the air, and optionally acts on the result. Every field
is named, none are opaque, and two of them are already user-set on the
reported units.
The select's three on-states are the app's own options rather than an
invented grouping -- it offers exactly "Sensing only" (sample, take no
action), "Auto clean" (purify while the air reads bad, stop once it
improves) and "Get notified" (raise a SmartThings notification). Labels were
transcribed from the Korean app and rendered in English; the auto-stop half
of "Auto clean" is the app's own description and is not otherwise visible in
the dump, which reports only the selected autoExeState. The remaining entity
names follow their raw fields rather than inventing a concept -- the skip
window is "sensing skip", after periodicSensingSkipStatus/Time.
Three of this registry's four board families report the resource with the
same field names -- TP1X_DA-AC-AIR (#130), A-VTWW-TP2 (#151) and AVT-WW-TP1
(#84, #190). Only ARTIK051_TVTL (#56) has no such href, and its golden is
unchanged. Bound unconditionally rather than behind a match_fn; the one field
that genuinely varies (periodicSensingInterval, absent on the #130 board) is
gated per-entity, so that board gets eight entities instead of nine rather
than a broken one.
range_hood.AIR_LEVEL_CHECK already models this same href, and its read-only
keys are reused verbatim here so both families share one catalog entry. It is
deliberately not imported: the hood exposes periodic_air_sensing as a
read-only BinarySensorDesc and this board needs a writable SwitchDesc on that
key, so reusing the hood's capability would migrate every hood user's entity
to a different platform.
Every write was exercised on AVT-WW-TP1-23-AXX500 hardware. This board hands
out 2.04 for writes it silently discards (see HEPA_FILTER's filter-reset
note), so an echo proves nothing -- each was judged by whether the value
survived a reconnect, which forces a new DTLS session, fresh discovery and a
fresh observe of the href, leaving no cached state to read back:
* sensing_mode's combined two-field PUT lands both fields, both ways:
sensing_only -> auto_purify raises autoExeState with activation still On,
and back again lowers it.
* The sensing-skip switch holds Off -> On and back.
* The half-preserving time writes hold: from 13:00-23:00, writing
start=07:30 then end=22:00 left the device on '07302200' -- each write
kept the half it wasn't given.
* periodic_air_sensing and sensing_interval: writing 60 s drove an observed
~60 s sensing cycle.
* The read side of the skip window is separately cross-confirmed on two
units: #84's sits at the inert '00000000', #190's carries a real
'03002300' (03:00-23:00), which is what pins the HHMMHHMM split.
* The other two families get the writes on field-shape grounds -- the same
basis on which they already share MODE, HEPA_FILTER and the air-quality
sensors.
range_hood._timestamp moves to common.epoch_to_utc so both callers share it,
matching how filter_usage_percent was shared. No behaviour change.
Every existing entity is untouched: the three golden updates are purely
additive, no renames, no unit or device_class changes.
_serial_from_unique_id took the entry's unique_id at face value. That is
right for an entry whose unique_id holds a real serial, but the unique_id
records what the config flow believed when it ran, not what the registry
holds now -- and for two firmware families those are different things.
Entries added before the placeholder rules landed (issues #83/#189) were
keyed on the placeholder itself: `localthings_Nothing(SVC)` for the
ARTIK051_DONGLE_REF dongles, `localthings_FFFFFFFFFFFFFFF` for the
DA_WM_A51_20_COMMON laundry boards. The coordinator has been resolving
those same boards to the host ever since, so their devices and entities
are host-keyed today. Migration read the placeholder back off the
unique_id, decided the host-keyed rows were the stale ones, and rewrote
them onto the placeholder -- reintroducing exactly the collision those
issues exist to prevent, since every unit of the family reports the same
placeholder and would go back to sharing entity unique_ids.
Run the recovered string through resolve_serial, which is the whole point
of that helper being shared. The old `host:port` special case stays: it's
a config-flow-history artifact rather than a device-reported serial, so
resolve_serial can't recognize it.
The repair pass had a second, narrower way to lose data. Removing a device
takes its entities with it (entity_registry.async_device_modified), and
the removal branch ran after the entity pass -- so an entity that had just
been re-keyed rather than removed, because its serial-keyed key was free,
was destroyed a few lines later along with the entity_id, name and area
the rewrite existed to preserve. Move surviving entities onto the device
they now belong to before removing the duplicate.
Reachable when the serial-keyed device exists but a given entity's
serial-keyed key doesn't -- e.g. the user deleted the visible duplicate by
hand, which is the first thing anyone hitting #236 tries.
Also fold the modelNum `<model>|<board>` split into resolve_model beside
resolve_serial. The config flow and _run_discovery each had their own copy
under a comment promising they matched; a device that renames itself on
the first poll is what a drift there looks like.
The Czech catalog was the only file in the repo still using CRLF, which
made every edit to it show up as a whole-file rewrite in diffs and hid the
one line that actually changed.
Content is byte-identical apart from the line endings, and the file now
matches the exact json.dumps(indent=2, ensure_ascii=False) formatting the
other four catalogs already use.
Adding a device had one message for nearly every way it could fail: "Cannot
connect to the device. Verify the IP address is reachable and the CA
credentials are correct." That covers an IP with nothing on it, an
appliance on cloud-only firmware, a device still holding the session from
the last attempt, a device that answered and rejected our certificate, and
Home Assistant having no internet to reach Samsung's cloud. Only one of
those is fixed by checking the IP and the CA credentials, and the message
gave no way to tell which one you had.
The probe already gathers enough to tell them apart, so classify it:
- cert_rejected the appliance sent a certificate alert. The CA
credentials aren't the AC14K_M CA it trusts, or they
don't pair. Far and away the most common real setup
mistake, and previously indistinguishable from a typo
in the IP address.
- handshake_failed a fatal alert unrelated to the certificate (protocol or
cipher mismatch) -- no amount of fiddling with CA
credentials will fix it.
- handshake_timeout the ClientHello probe proved a DTLS server is right
there, but the handshake never finished. Usually the
appliance is still holding the association from a
previous attempt; it clears on its own in about a
minute.
- ports_closed ICMP port-unreachable on the whole range: something is
at that address and it isn't exposing a local API.
Cloud-only firmware (TCP 8888 only) lands here.
- no_dtls_server some ports open|filtered, none speaking DTLS -- likely
another device on that IP.
- no_response nothing came back at all.
- cloud_unreachable couldn't reach Samsung's cloud gateway for the UUID.
An internet problem on HA's side, not the appliance's.
- unexpected_response authenticated fine, then returned something we can't
read. Neither connectivity nor credentials.
Certificate alerts are read back out of the error text OpenSSL puts in
DtlsCoapSession's ConnectionError, not by re-probing. The library's
diagnostic probe would report the alert authoritatively, but it drives the
handshake far enough to commit association state on the device -- and an
orphaned association is exactly what makes the *next* attempt time out
(RFC 6347 4.2.8), which is a bad trade on a path the user is about to
retry.
Telling ports_closed from no_response needs the UDP sweep to separate a
refusal from an unreachable. Both leave a port "not live", but ECONNREFUSED
is a *response* -- the host is there -- while EHOSTUNREACH/ENETUNREACH mean
the datagram never left. A wrong IP on the local subnet never answers ARP
and fails every send that way, so treating the two alike would have told
those users their appliance was on cloud-only firmware. The sweep now
returns live/refused/unreachable separately, and the preferred-port rescue
moved out of it into _sweep_ports: the rescue is a candidate-selection
decision, and folding it into the sweep's verdict destroyed the evidence
the message is built from.
Every failure carries the error key that fits it, so the flow maps
exceptions instead of guessing, and logs the specifics (alert name, per-port
outcome, response code) at warning level -- the messages that mention the
log now have something to point at.
Certificate re-minting for a reused leaf is also narrower and more correct
as a result: it now triggers on CertRejected specifically, rather than on
"every attempt raised ConnectionError and a port was confirmed".
All five translation catalogs carry the eight new messages. The non-English
ones are my own work rather than a native speaker's; corrections welcome.
Two problems, one setup path.
Port detection (issue #211): the config flow found the DTLS port by
elimination -- a 1-byte UDP probe can't tell a silent port from a real
DTLS server, so every port it couldn't rule out got a full certificate
handshake, and every false positive cost the whole 12s HANDSHAKE_TIMEOUT_S
before the next was tried. Adding an appliance took 30-40s.
smartthings-local 0.1.2 ships a stateless ClientHello probe that settles
this positively: a real DTLS server answers with a HelloVerifyRequest in
~1 RTT, and per RFC 6347 4.2.1 it does so without allocating association
state, so the probe leaves nothing behind on the appliance. The whole
49152-49160 range is probed at once and exactly one confirmed port is
given a certificate handshake. Fanning out is safe here in a way racing
real handshakes is not -- each probe is bounded by a 3s budget, so the
pool costs one probe's wall clock rather than the sum of the range, with
no losing threads left running behind us.
The UDP sweep stays as the fallback for when the probe confirms nothing:
it errs in the opposite direction (it reports everything it can't rule
out), so it still surfaces a device on a path that eats our ClientHello,
and it keeps its issue #192 preferred-port rescue.
Port detection now runs first and needs no credentials, so an unreachable
host fails before any round trip to Samsung's cloud. And a second
appliance reuses the existing entry's leaf cert rather than re-minting --
every device accepts the same one -- which makes adding one independent
of Samsung-cloud reachability. A confirmed-live device rejecting the
reused leaf (the UUID does rotate) re-mints and retries once, so reuse
stays self-correcting; a timeout doesn't, since a fresh cert can't fix
nothing answering.
Identity (issue #236): the coordinator seeded device_serial with the
configured host and only replaced it after the first successful poll. But
device_serial mints *permanent* registry keys -- entity unique_ids and
device identifiers -- so anything registering before that poll returned
was written into the registry keyed on the IP address forever. The
connection-mode sensor is added unconditionally rather than from `bound`,
so it was the reliable victim: when the serial-keyed identity appeared
moments later HA created a second device and entity, and the IP-keyed
pair was orphaned. Deleting them didn't help; the next restart that lost
the race recreated them.
The probe already learns the identity, so store it on the config entry --
serial, model, manufacturer, device type. The coordinator seeds
device_serial and its DeviceInfo from those at construction, so keys are
correct from the first entity that registers even if the first poll is
slow or fails outright. There is no placeholder left to correct.
Discovery now treats the registered identity as authoritative rather than
re-keying a device that already has registry entries; it adopts and
persists the polled identity only for an entry that has none, and warns
if a different appliance answers on the same IP.
Entry version 1 -> 2 recovers the serial from the entry's unique_id (the
flow has always keyed it on the probe's serial) and repairs what the old
registration orphaned: IP-keyed devices and entities are rewritten in
place where the serial-keyed key is free -- keeping entity_id, name, area
and every automation referencing them -- and removed where both exist,
since the IP-keyed one has been dead since the restart that made it.
Placeholder-serial boards (issues #83/#189) were keyed two ways at once,
`host:port` on the entry and `host` in the registry; migration collapses
the entry onto the registry's form. One resolve_serial() now serves both
sides, so they can't drift apart again.
The remaining step in the desired pipeline -- probe for subdevices, then
register devices, then populate entities -- already holds:
_enumerate_subdevices_blocking runs before _run_discovery, which runs
before platforms are forwarded. Duplicating it in the config flow would
mean re-running Pattern B's per-href fallback probe, which is the
opposite of what issue #211 is about.
for_device_by_model returns DeviceRegistry | None; accessing .capabilities
directly off the inline call result left the None case unnarrowed. Switched
to the same reg/resources-tuple helper pattern every other by-model test
file in this suite already uses, which ty resolves cleanly.
Two tests added while triaging #244/#226 just re-asserted a translation
string against the catalog entry that had been written moments earlier
(dryer_cycle_table_03's '51'/'53'/'4e', dishwasher_cycle's '83'/'86').
Neither exercises any code path -- they pass by construction and only
break when someone later edits the label text for wording, not when the
actual code/value mapping regresses. tests/test_translations.py already
holds the invariants that matter for catalog data.
Documents the anti-pattern in the adding-device-support skill so future
translation-only fixes don't reach for this pattern again.
'83' and '86' were swapped in the dishwasher_cycle catalog. Both the
original DW9000F-class fixture this table was built from and the issue
#226 reporter's board share the identical DeviceType_0812, and the
original fixture's own editCourseList puts the two codes back to back
(positions 4-5) -- a plausible adjacent-pair transcription slip. The
reporter's live confirmation (selecting 'Normal' ran the physical Express
60 program and vice versa) settles which way: '86' is Express 60, '83' is
Normal.
The energy-sensor part of the same issue was already resolved per the
issue thread (the device genuinely doesn't report usage, so the sensor's
removal was correct) -- not touched here.
The reporter's fridge/freezer combo reports the issue #186 discrete
definite-setpoint pattern on both compartments, but only the cooler half
was modeled -- /temperature/definite/freezer/vs/0 was unbound. Adds
DEFINITE_TEMPERATURE_FREEZER, identical shape to the existing cooler
capability (same fields, just negative supportedList values).
HOMECARE_WIZARD_V2 appears in /mode/vs/0's supportedModes on TP2X_RAC_20K
units but is a capability/option flag echoed from
/configuration/vs/0's airconOptionList, not a selectable HVAC mode -- the
unit's current mode never reports it. Added to a new
_NON_HVAC_OPTION_CODES set that's dropped silently, so hvac_mode/hvac_modes
stop tripping the issue #93 unmapped-mode warning for it on every start.
Also fixes _warn_unmapped logging "None: device mode ..." during setup's
first discovery pass, before the entity is added to hass and entity_id is
assigned -- falls back to unique_id (set eagerly in __init__), so multiple
same-type devices are distinguishable in the log.
Codes 51 (Eco Cotton), 53 (AI Dry+), and 4e (Self Dry) were confirmed by
the reporter selecting each program on the physical appliance and reading
back the resulting raw course code, same table (Table_03) as the existing
issue #80 confirmations.
Also fixes an import-sort lint error left over in by_type/dehumidifier.py
and a stale comment in dryer.py claiming codes 21/4c were still
unidentified when the catalog already had them.
Dryers report the same DrumCleanProposal_/WashingTimes_/DrumCleanLog_
options[] tokens washer.py already models for issue #9, so
drum_clean_cycles_remaining/drum_clean_last_cleaned move to laundry.py and
get bound on dryer's /course/vs/0 too.
DrumCleanLog_ on the reporter's dump is a '|'-joined history of every past
clean rather than washer's single bare timestamp -- the shared helper now
takes the last (most recent) entry, which turns out to also fix a latent
bug on four existing washer fixtures whose own DrumCleanLog_ was already
multi-entry and silently failing to parse into drum_clean_last_cleaned.
No heat-exchanger-clean tracking was found in either dump #258 supplied;
noted in dryer.py so a future report knows this was checked.
TP1X_FAC_TIME_23K reports three previously unbound hrefs: a UV-C
sterilization LED and a ventilation-reminder alarm (both plain On/Off
toggles), and a second PM1-rated dust filter with no live usage/status
fields on this particular dump.
The PM1 filter capability gates each entity on its own field's presence
rather than a blanket ignore, since the TP1X_DA-AC-CAC-01001_0000 cassette
AC (issue #191) reports the same href with full live data -- this also
closes two of that device's ten documented coverage gaps (UV LED and the
PM1 filter) as a side effect.
The previous commit's translation update rewrote this file with LF
endings; every other language file in the catalog already uses LF, but
this one was CRLF before that change.
The TP1X_DA_AC_DHM_01001_0000 revision (model AY70H18100GTD) additionally
reports /display/vs/0 (same shape as air_purifier's screen toggle, reused
directly) and /watertank/lighting/vs/0 (on/off, color, and brightness for
the tank's ambient light, plus a diagnostic alarm-status flag). Both issues
submitted the identical dump, so one fix covers both reports.
Also adds x.com.st.d.dehumidifier to the /oic/d device-type routing table
now that a dump confirms it.
dust / fine_dust / super_fine_dust show live values fine but Home Assistant
keeps no long-term statistics for them, so once recorder's purge window passes
(10 days by default) the history is gone and they can't back a long-range
air-quality graph.
HA only writes long-term statistics for sensors that declare a state_class,
and AIR_QUALITY's descriptors set none -- the entities come up carrying just
an icon. The values were never the problem: common.sensor_item_value already
returns int. Three sensors in this same module (filter_progress,
fan_speed_level, hepa_filter_usage) already declare one, so this reads as an
oversight rather than a decision.
Only the three particulate readings are stamped. They fall monotonically with
particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
(issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
which is concentration behaviour, and averaging it over time is meaningful.
Odor and CleanLevel read 0-2 on every fixture and look like graded indices,
where the mean of a grade isn't obviously meaningful, so they are left alone
rather than guessed into statistics.
Worth flagging for the review: air_monitor.SENSORS already stamps all five of
these, and its module docstring describes that as "matching
air_purifier.AIR_QUALITY's existing precedent" -- a precedent this module did
not actually set. Extending to all five here is a one-line change if
consistency is preferred over the grade/concentration split.
The state_class is carried in a separate key set rather than a fourth tuple
column because air_monitor.py imports _AIR_QUALITY_SENSORS and unpacks it as a
triple; widening it breaks that module's import outright. Two of the new tests
guard exactly that coupling.
No device_class or unit is asserted: pm1/pm25/pm10 with µg/m³ would claim the
reading is a mass concentration, which no dump states. That is a separate call
from making the series recordable at all.
Metadata only -- no key, name, value or unit changes, so no entity changes
identity and every golden is untouched. Statistics start accumulating from the
upgrade onward; existing short-term history is unaffected.
requirements-dev.txt intentionally leaves homeassistant/cryptography
unpinned (always test against latest), so ty's view of their stubs can
drift between runs. SensorEntity._attr_state_class now requires
SensorStateClass rather than a bare str (same fix already applied to
_attr_device_class); NameAttribute.value is generic over str | bytes,
so narrow it before handing it to re.search.
main advanced past this branch (PR #263, entity-less-after-restart fix)
with unformatted changes to coordinator.py's tests; re-running ruff
format picks those up. Merge commit itself had no conflicts.
New "lint" job in validate.yml runs ruff format --check, ruff check,
and ty check against custom_components/ and tests/ on the same
push/PR/schedule triggers as the existing hassfest/hacs/pytest jobs.
Completes the isinstance/cast narrowing + Optional-field assert pattern
across the last batch of test files. custom_components and tests are
now both fully clean under ruff check, ruff format --check, and ty check.
Same isinstance/cast narrowing and Optional-field assert pattern as the
prior commits, covering the airconditioner, fridge, washer, operational,
subdevices, sensor_hysteresis, laundry, select_options, identity and
entities test files.
Continues narrowing SamsungEntityDescription accesses to the correct
subclass and asserting Optional write_fn/match_fn/exists_fn fields are
set before calling them, per the pattern established in the previous
commit.
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.
Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).
Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
No behavior change to the fix itself; cleanup only.
Production:
- Trim the narrative that was told three times over (coordinator
docstring, test docstring, inline comment) down to one telling in the
docstring, where someone tempted to remove the gate will be standing.
- Guard the second degraded return in _async_update_data on
self._discovered too. That arm is currently unreachable before
discovery only because every _observe.apply() call site happens to be
gated on post-discovery state -- a non-local accident across four call
sites. Stating the precondition where it is relied on makes it the same
explicit rule _defer_reconnect_for now applies.
Tests, 7 -> 4 with better discrimination:
- test_session_closed_when_first_refresh_fails asserted _close_session
was called, which the reconnect path already does on its own -- so it
passed with the fix removed. Merged into the persistent-timeout test
and re-pointed at async_close, which only setup calls.
- Dropped the __new__-built unit test: it set one attribute on an
otherwise uninitialized instance, so it asserted the gate's position in
the function rather than any behavior, and would have errored rather
than failed if reordered.
- Folded the timeout-budget test into the recovery test it was a
byte-for-byte copy of, and replaced both hand-rolled call counters with
the side_effect=[exc, resources] idiom already used in this file.
Each of the three production changes is now independently covered:
removing any one of them alone fails the suite.
A failed DTLS handshake on the very first poll was being swallowed, so
the config entry loaded with no entities at all and stayed that way until
the user reloaded that device by hand (issue #254).
_poll_once() connects when there is no session yet, so connect()'s
handshake timeout reaches _async_update_data as a TimeoutError -- the
same type a slow blockwise transfer raises mid-session.
_defer_reconnect_for() only knew the mid-session meaning and deferred it,
making _async_update_data return flatten([], {}) == {} instead of
raising. DataUpdateCoordinator counts any non-raising return as success,
so async_config_entry_first_refresh saw a healthy first refresh and
skipped ConfigEntryNotReady, and setup forwarded the platforms with
`bound` still empty. Platforms enumerate `bound` once and have no dynamic
add-listener, so a later cycle repopulating it added nothing: every
restored entity sat unavailable until a manual reload.
Gate the deferral on self._discovered. Before the first discovery a poll
failure now takes the normal path -- one reconnect attempt, then
UpdateFailed -> ConfigEntryNotReady -- so HA retries on its own backoff
until the handshake goes through.
Two related fixes in the same failure path:
- Close the DTLS session on EVENT_HOMEASSISTANT_STOP, not only on entry
unload. HA does not unload entries on a Core restart, so async_close()
never ran and the previous run's association was left orphaned on the
appliance -- which is what makes the next run's handshake time out in
the first place. The fixed source port still covers the unclean-exit
case where no close_notify can be sent.
- Close the session when first refresh fails. _poll_once deliberately
leaves it open on a TimeoutError, so a failed setup abandoned a bound
UDP socket on a port that is fixed per device by design, and each HA
retry bound another socket to that same port.
test_every_language_mirrors_the_english_catalog was failing on main
for es (PR #246) independent of this branch. Beyond the 32 keys en.json
gained since #246 merged (the AC/fan preset_mode and fan_mode state
blocks, and the new EHS/zone/auto-clean keys), the file had accumulated
several pre-existing bugs that also broke topology parity:
- climate.airconditioner and fan.air_purifier_fan carried a stray
"name" key that doesn't exist in en.json's catalog for either (both
entities are unnamed in code); removed, and their real
state_attributes blocks added.
- select.buzzer_sound and select.finish_sound were keyed by literal
on-wire device codes (Volume_Off/Low/Med/High, Finish Sound_1/2/3)
instead of en.json's actual off/on states -- dead translations, never
resolved at runtime. finish_sound's values were also unrelated song
titles, not sound-toggle labels. Replaced both with real off/on
entries.
- select.dryer_cycle_table_03 had codes 1c/1d/1e (Shirts/Towels/Outdoor)
rotated by one slot, so a Shirts cycle displayed "Toallas"; realigned
to the correct codes and added the 2 missing ones (2b, 4c).
- select.washer_cycle_table_02 carried 4 stray codes (06/08/74/A0) not
present in en.json's table at all, duplicating already-correct
translations under codes this device never reports; removed.
tests/test_translations.py now passes for every language, and the full
suite is green (1121 passed).
Fills in the remaining entity names/states and fixes one broken
placeholder in the existing translation (issues.device_gap.description
used {nome_dispositivo} where the string is formatted with
{device_name}, which would have rendered the literal placeholder in
the UI instead of the device name).
Samsung-marketed cycle/feature names (WindFree, AI Wash/Comfort/Energy
Mode, Smart Control/Dry, Storm Wash+, Self Clean+, Drum Clean+, Frozen
Pizza+, Good Sleep, Super Speed) are left in English, matching how
nl.json treats the same set -- WindFree and AI Dry each get their
qualifier translated (WindFree sonno, Asciugatura AI) while the brand
word stays put, the same split nl.json makes.
Translates the custom integration's config/options/issues/exceptions
strings and the washing machine entity labels (select/binary_sensor
entries for cycle, spin speed, wash temperature, detergent/softener,
child lock, and related sensors).
Both the /subdevices/vs/0 subdeviceIdList (Pattern B) and an /oic/res
link's UUID prefix (Pattern C) can name the same physical subdevice --
TP2X_FAC_BORA_21K, the Pattern B reporter's own board, does. Filtering
the two candidate lists against each other with a plain set difference
missed this when the two sources disagree on the UUID's case, letting
the same subdevice get probed and materialized twice under two
different keys.
Move the guard into _probe_prefixed itself, keyed on a
case-normalized id, so neither pattern can add a candidate the other
already claimed regardless of casing.
AWM-WW-AID-26-ONEBODY (washer+dryer combo) reports numofsubdevice='2' on
/multidevice/vs/0 but carries no /subdevices/vs/0 (no subdeviceIdList --
Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's). The
washer subdevice's UUID appears only as the path prefix of the
x.com.samsung.da.multidevice link in /oic/res; GET /<uuid>/device/0
answers the washer's own full Collection batch (model ..._WF80H vs the
master's ..._DV80H27H).
Treat every UUID path prefix seen in /oic/res as a prefixed-subdevice
candidate (minus ones subdeviceIdList already named), probed with the
same tolerated-404 seed RETRIEVE as Pattern B -- the shared body is
factored into _probe_prefixed. discover_partitioned's entity-level
liveness gate still decides materialization, so a UUID link with no live
sibling behind it contributes nothing.
Fixture is a live capture from the reporting board (serials/MACs/di
scrubbed); tests cover discovery, probe hygiene, washer-side entity
binding, and that the master's own entity set is unchanged.
Adds a device registry for the TP1X_DA_AC_EHS board family: separate
zone1 (space heating/cooling) and dhw (domestic hot water) loops.
zone1 is exposed as power switch + mode select + current/target
temperature sensor/number -- it's a leaving-water-temperature
setpoint, not a thermostat, so no HA platform fits it better. dhw
gets a composite water_heater entity, using the same
primary-resource-plus-sibling-reads shape climate.py already uses
for the AC (PR #247 review feedback: "Having water heaters
automatically leverage the right platform would be pretty cool!").
The unit's away mode is a device-wide switch, not the water_heater
AWAY_MODE feature -- /option/outgoing/vs/0 has no dhw-scoped sibling
and covers zone1 too, so presenting it on the DHW card would
misstate its scope.
Operation modes (Eco/Std/Force/Power) map onto HA's own standard
water_heater states, the same mapping HA core's smartthings
integration uses for this exact Samsung capability over the cloud
API. Device codes are matched case-insensitively on the read side.
The DHW entity takes a catalog name ("Hot water") rather than the
bare device name -- unlike the AC's climate card, it is one loop of
a two-loop device.
Both temperature ranges fall back as a pair: a resource reporting a
minimum but no maximum yields no range at all rather than mixing a
device bound with an invented default, matching climate._range().
Increment fallbacks test for None instead of using `or`, so a
genuine 0 survives.
set_temperature honours the optional operation_mode HA's
water_heater service schema forwards, setting the mode (and powering
the loop on) before the setpoint, the same way climate's
set_temperature handles hvac_mode.
Entity names are translated into Czech and Dutch following each
file's existing terminology conventions.
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
(firmware AEH-WW-TP1-22-AE6000_17260402); golden-regression fixture
and full test coverage included.
`/option/autoclean/vs/0` carries three fields and only `settingStatus` was read.
That one says the feature is enabled, which it is whether or not the unit is
drying right now, so nothing reported an actual cycle.
settingStatus: On <- the existing auto_clean switch
status: Stop supportedStatus: [Start, Stop]
progress: 0
Adds a binary sensor for `status` and a percentage sensor for `progress`.
Measured on a TP1X_DA-AC-CAC-01001, sampling the resource every eight seconds
across a cycle: `Start` with progress 98 while it ran, then `Stop` with progress 0
the moment it finished. The percentage matches the figure the appliance shows on
its own display, checked against 55% mid-run.
Golden state keys updated for the 18 fixtures that bind AUTO_CLEAN. The change is
additive — no key was removed from any of them.
Full suite passes (1070 tests, Python 3.13 via requirements-dev.txt).
Complete Spanish translation for LocalThings: 257 entity names across
all platforms + full UI strings (config flow, options, issues, exceptions).
Transparency: AI-assisted (Hermes Agent), reviewed and verified by the
owner against the official Samsung SmartThings app on real hardware
(washer DA_WM_TP1_21_COMMON). Translation files only, no code changes.
Washer cycles verified one-by-one against the official app; 4 cycle codes
missing from the catalog were added from real hardware (08, 74, 06, A0).
Washer options verified: volume, finish alarm, bubble soak.
Measured on nine Samsung appliances on Korean-market firmware, every one of
which populates /oic/d with a concrete type:
4x oic.d.airconditioner AJ023CN1UBC1 system A/C, "Samsung System A/C"
3x oic.d.refrigerator "[refrigerator] Samsung"
1x oic.d.cooktop TP1X_DA-KS-COOKTOP, "Samsung Cooktop"
1x x.com.st.d.hood AHD-WW-TP1-22-COMMON, "Samsung Hood"
Every one agreed with what for_device_by_model already concluded from the board
token, so this is corroboration rather than a correction.
`x.com.st.d.hood` was the one type with a registry to point at and no row, so
this adds it.
`oic.d.cooktop` is left out on purpose, with a comment saying why: the induction
above reports it, but `cooktop` and `induction_cooktop` are unrelated registries
that happen to share the English word, and the OCF type cannot tell them apart.
Mapping it to either key would misroute the other, and since resolve() consults
this table first it would override a COOKTOP/CT board token that had it right.
Same shape of argument as the existing oic.d.robotcleaner note.
One incidental data point on the docstrings' "only ever helps a minority of
dumps": that may understate it. Nine out of nine here answer /oic/d with a usable
type, across four families. Not enough hardware to generalise from, but enough
that it looks less like a rare bonus than the comments assume.
Verified: the full suite passes (1024 tests, Python 3.13 via
requirements-dev.txt).
The Czech translation landed in commit d0c68fb (PR #233) and was
immediately out of date with en.json: subsequent commits added new
entity/option keys that didn't get backfilled. cs.json fell behind
in three buckets, all caught by test_every_language_mirrors_the_english_catalog:
- 6 dryer cycle codes added to en/nl by commit 873ed56 (PR #237):
'17' Super Speed, '21' Hygiene Care, '22' Silent Dry, '29' AI Dry,
'2b' Self Tub Dry, '4c' Air Refresh. cs.json's dryer_cycle_table_03
still had the pre-PR-#237 set.
- 'finish_time_hysteresis_minutes' option added to en/nl by commit
d905620 (PR #239). Same omission in cs.json.
- 8 entities added to en.json by the issue-triage batch now in main
(PR #224): binary_sensor.battery_charging, binary_sensor.child_lock,
sensor.air_quality_standard, sensor.battery, sensor.co2,
switch.dnd, time.dnd_end, time.dnd_start. cs.json had none of them.
nl.json was in sync, so the gap was specific to cs. Keys placed in
their natural alphabetical position within each section.
common:
- Make KIDS_LOCK_GENERIC also a read-only BinarySensorDesc (device_class='lock'),
flipping value_fn to not bool(v) so /kidslock/0 value=False and
/kidslock/vs/0 kidsLock='Ready' render with the same polarity ('On'
means open/unlocked per HA's lock device class). The old SwitchDesc
form never honored device_class='lock' -- HA's switch platform only
accepts 'outlet'/'switch' -- so the surface was a plain switch whose
'On' meaning drifted across boards. Tests updated.
air_monitor:
- Add state_class='measurement' to dust/fine_dust/super_fine_dust so
the readings feed HA long-term statistics (co2 already had it).
- Import _AIR_QUALITY_SENSORS from air_purifier instead of duplicating
it byte-for-byte; update common.sensor_item_value's docstring to
mention the third caller.
by_type/__init__: drop trailing whitespace on the new 'ASM' line.
translations/en.json + nl.json: move the new 'dnd' switch entry to its
correct alphabetical position (after display_light, before fast_preheat).
SKILL.md: add an explicit read-side rule to §5's educated-guesses
section -- guessed unit/device_class/state_class on a SensorDesc
silently mislabels readings forever with no 4.xx to catch it (unlike
guessed writes, which the device rejects). The prior air_monitor
docstring cited this carve-out as if it existed; now it does.
tests/water_purifier (issue #196): change the ailite fixture's
favorite.defaultTemperature from '85' to '50' so the test actually
reproduces the reported bug -- '50' is in showList only, so a
descriptor reading from supportedList would fail the assertion that
the current default is in its options list.
Bypass-remote-control already existed but was undocumented; finish_time
hysteresis is new. Both live under the same Configure > Device settings
menu, so cover them together as Part 4 rather than leaving a reader to
discover them by opening the options flow.
The gate holds finish_time at its last reported value until a new one
differs by at least a configured number of minutes, regardless of how
long that difference has been building up -- a magnitude-based deadband,
not a time-based debounce (which would wait for the value to stay put for
N minutes before accepting it). debounce invited the wrong mental model
for anyone reading the option name or the code later, so rename it
throughout before the option name ships: CONF_FINISH_TIME_DEBOUNCE_MINUTES
-> CONF_FINISH_TIME_HYSTERESIS_MINUTES, SensorDesc.debounce -> hysteresis,
LocalThingsSensor._debounce/_debounced_value -> _apply_hysteresis/
_hysteresis_value, and the options-flow data key/translations to match.
Minute-rounding stopped identical values from re-logging, but finish_time
still updates on nearly every poll because now() + remaining is a
continuously-drifting value between the device's own remaining-time
revisions, and washers/dryers/dishwashers commonly revise that estimate
by a minute or two mid-cycle anyway -- both are real, small changes that
individually don't matter but each cost a recorder/logbook entry.
Add a per-device Options Flow setting (finish_time_debounce_minutes,
default 3) and a SensorDesc(debounce=True) opt-in. LocalThingsSensor now
caches the last value it actually reported and only adopts a new one once
it differs by at least the configured threshold, a cycle starts (no prior
cache), or a cycle ends (new value is None) -- 0 disables it entirely,
restoring today's behavior.
Also pass config_entry explicitly into DataUpdateCoordinator's
super().__init__() -- self.config_entry previously relied on an
undocumented ContextVar fallback that upstream has flagged as removed in
HA 2026.8, which the new debounce lookup needed to not be built on top of.
_finish_time added datetime.now(timezone.utc) (fresh seconds/
microseconds every call) to the device's remaining-time duration, so the
returned timestamp differed at the sub-minute level on nearly every poll
even when remainingTime itself hadn't changed. The recorder logged a new
history/logbook entry each time, while the UI rounds the display down to
the minute, making repeated polls look like duplicate identical entries.
Round the result down to the minute so the entity only changes state
when the estimate actually shifts.
CI caught it: 8 pre-existing AC fixtures also carry genuine SmartCoolClean_/
ProgressSmartClean_ tokens in /mode/vs/0 (lnx_rac_heatpump, ara_ww_tp1_22,
windfree_oscillation, tp1x_rac_01001, fac_bora, fac_bora_2in1,
fac_bora_205_flat, cac), so odor_controller_active/odor_controller_progress
now correctly bind there too -- their golden state_keys lists just hadn't
been regenerated. Verified against the actual downloaded CI job log.
- airconditioner.py: odor_controller_active binary_sensor + odor_controller_progress
sensor, read from the /mode/vs/0 SmartCoolClean_/ProgressSmartClean_ option tokens
(SmartThings cloud custom.airConditionerOdorController capability). Read-only --
no confirmed write command.
- Reporter's TP1X_DA-AC-RAC-01001_0000 dump: fan speed and WindFree preset already
bind via the existing composite climate entity, no code change needed there.
- en.json/nl.json: new entity names for the two additions.
- cs.json: new, full Czech translation catalog (mirrors en.json topology 1:1).
- New fixture + golden + tests locking in the odor-controller behavior.
Adds the Select the official app offers next to the filter reminder --
180/300/500/700 hours -- which this board generation keeps as a FilterAlarmTime_
token in /mode/vs/0's options[] rather than on a /filter/* resource.
Confirmed on hardware: stepping through all four radio positions in the app
moved that one token and nothing else across all 19 resources, so the token
carries the hour count verbatim; and a local write of 500 to a unit sitting on
700 was accepted and kept, surviving a restart. The cloud exposes none of this,
so it is only available locally. Gated with the counter it belongs to, so
boards carrying a real /filter/airdustfilter/vs/0 threshold keep using that one
-- the gate is covered by a test that injects the token into a newer board's
dump, since no non-legacy fixture carries it and the assertion would otherwise
pass for the wrong reason.
Also settles what filter_time measures: it counts UP -- running time
accumulated since the last filter reset, not time remaining -- which an earlier
revision of that comment explicitly left open. Three independent things agree:
the token rising while the unit runs, FilterAlarmTime_ being the threshold it
is measured against, and /alarms/vs/0's filter entry tracking the counter
across two units on one site (a live unsuffixed 'FilterAlarm'/'Created' at
FilterTime_5595 against the 'FilterAlarm_OFF'/'Deleted' placeholder at 1915).
The alarm clearing by itself the moment the counter dropped under the threshold
is the causal half of that, not just correlation.
Resetting the counter is NOT solved and no reset entity is added. The
descriptor records what was tried, and what the failures do and do not prove,
so the next attempt starts from evidence instead of from scratch. Short
version: the reset is a *command* (custom.dustFilter/resetDustFilter), not a
value write, which is why nothing that writes the counter works; I could not
work out how to drive that command locally. /actions/vs/0 is the obvious local
command channel but publishes no schema, and I did not enumerate guessed action
names against a live appliance.
Written with Claude (AI), on the author's own hardware; every result quoted
above is measured on the device rather than inferred.
Replace the fixed time.sleep(GRACE_PERIOD_S) in try_enter_observe_mode
with a threading.Condition.wait_for(predicate, timeout=grace_period_s)
that returns as soon as success_fraction of subscribed hrefs have
notified. Production data (fridge TP2X_REF_20K): all 13 hrefs notify
within ~0.34s of subscribe, so every successful first-refresh / observe
retry was waiting ~14.6s of dead time. AC retry cycles showed the same
16s-fetches-that-transition pattern.
No behavior change on the fallback path (fraction not reached → wait
the full ceiling → MODE_POLL, identical to today). GRACE_PERIOD_S and
SUCCESS_FRACTION unchanged. No smartthings_local library change.
New device family: a standalone, battery-powered air-quality sensor
puck (ASM-KR-TP1-22-* board) with no controllable-appliance state at
all -- no /power/*, only /energy/battery/vs/0. Routes via both /oic/d
('x.com.st.d.airqualitysensor', confirmed against the real dump) and
the 'ASM' modelNum board token as a fallback.
/sensors/vs/0 reuses air_purifier.AIR_QUALITY's existing {type, value}
items-list decode (common.sensor_item_value) for dust/fine_dust/
super_fine_dust/odor/clean_level, sharing those capabilities' catalog
entries, and adds a CO2 reading those families don't report. The
particulate sensors deliberately get no pm10/pm25/pm1 device_class:
the values read as physically consistent (coarser >= finer) but
Samsung's own two-tier dust naming doesn't confirm where this board's
three-tier split actually maps, and mislabeling a read-side unit is a
standing, not one-shot, kind of wrong -- exposed as plain named
sensors instead. Humidity, battery/charging, and the informational air
quality standard are otherwise straightforward field reads.
/dnd/vs/0 (do-not-disturb window) is a flagged educated guess: the
write contract mirrors the read side's own string/time-format shape
(the safest kind of guess) but has no idle-vs-active dump to confirm
it end-to-end, so it's called out as such in code and will need a
reporter to verify on real hardware. /keepnormalstate/vs/0 and
/sensordatasinks/vs/0 are genuinely opaque (single unexplained value,
no supported-values list) and are ignored rather than guessed at.
Locked in with a scrubbed fixture, golden, and capability tests.
The old "don't guess" rule banned shipping any write whose contract
wasn't already confirmed end-to-end, even when the dump gave strong
supporting evidence (a supported-values/range field, an idle-vs-active
dump diff, a pattern already confirmed on a sibling board). That's
overly conservative: a CoAP write against an invalid value gets
rejected rather than acted on, so the worst case for a wrong guess is
a no-op, not a damaged appliance -- and this project already ships
flagged guesses and asks reporters to confirm them on real hardware
routinely (issues #196, #181).
Replace it with guidance to make educated guesses and ship them, but
mark them explicitly as unconfirmed (in code comments and in a direct
ask to the reporter) rather than silently presenting a guess as a
confirmed contract. Still forbids inventing a write or entity with no
supporting evidence at all, and calls out that the "worst case is
rejected" safety margin covers invalid values, not wrong-but-valid
units/semantics.
favorite_hotwater_temperature's options_field read
favorite.supportedList, which is only the four fixed presets. The
SmartThings app also lets the user add one custom value to their own
display list via its "temperatures to display" editor (bounded by
/setting/waterpurifier/vs/0's hotwaterRange), and that value shows up
in favorite.showList but never in supportedList. A unit whose current
default was that custom value rendered as HA's "unknown" state, since
current_option wasn't among the (too-narrow) options list.
showList is a superset of supportedList that always includes whatever
the current default actually is. /setting/waterpurifier/vs/0's own
hot_water_temperature select is untouched -- its write contract for
values off the old preset list still isn't confirmed, so it stays
gated off on boards that don't report supportedHotTemperatures.
KIDS_LOCK_VS_FALLBACK's write_fn wrote 'Enable', a value no dump in the
fixture corpus (washers, dryers, dishwashers, ovens, ranges, microwaves,
air purifiers, air dressers -- everything that lacks the OCF-standard
/kidslock/0) has ever reported back; every one reports 'Ready' or 'Run'.
It was never a confirmed write contract.
#181's reporter tested directly: writing the *correct* value ('Run')
still returns 4.05, and the SmartThings app itself has no control for
kids lock either -- the resource is genuinely read-only on that
hardware, not just wrong-valued. #183's reporter hit the identical
symptom (toggle does nothing) on a different device family reporting
the same Run/Ready vocabulary.
Convert the entity to a read-only BinarySensorDesc. binary_sensor's
'lock' device class is inverted from the old switch reading (On means
open/unlocked), so value_fn flips accordingly -- callers reading the
flattened 'child_lock' state key need to account for the new polarity.
KIDS_LOCK_GENERIC (the OCF-standard /kidslock/0 boolean) is untouched;
nothing suggests that one is broken.
DA_DF_TP1_21_COMMON reports a plain {setBuzzerSound, supportedFinishSound}
buzzersound resource -- the same shape laundry.BUZZER_SOUND already
handles for washers/dryers -- but it was unbound because the air_dresser
registry never included that capability. Add it, and lock the board in
with a scrubbed fixture, golden, and capability tests.
The reporter's actual complaint (course cycles showing as raw codes) is
a labelling gap, not a coverage one: this board's course table has no
code->name mapping anywhere in the dump, same as issue #162's board, so
there's nothing to bind here -- it needs a reporter to identify the
codes before they can be named in translations.
_is_placeholder_serial only caught the ARTIK051_DONGLE_REF family's
'Nothing(SVC)' sentinel. The DA_WM_A51_20_COMMON (ARTIK051) laundry
board family reports a different one instead -- every character the
same repeated hex digit -- which passed through as a real, non-unique
serial. Two different physical units (a washer and a dryer) both
reporting the literal serialNum 'FFFFFFFFFFFFFFF' collided on the
config-entry unique_id, so the second device's config flow aborted as
already configured. Widen the check (both the config_flow.py and
coordinator.py copies) to also catch that sentinel shape.
_run_subpolls is a self-limiting background loop the coordinator already
cancels and recreates every refresh cycle, but scheduling it with
async_create_task ties it into HA's startup/shutdown task tracking
anyway. A subpoll cycle in flight (up to ~27s) then delays both.
async_create_background_task is HA's supported API for exactly this
case -- a task the integration owns and manages the lifecycle of.
One commit per issue/logical change, and every commit's author and
committer must be the human accountable for the work -- never a tool,
bot, or AI agent identity, and no AI co-author trailers. AGENTS.md points
any AI coding agent working here back to CONTRIBUTING.md as the
authoritative source for this.
The "issue #N" / "OCF spec" trailing comments and the header paragraph
explaining them added noise without adding anything the value side of
the table (a real _REGISTRY_BY_KEY key) doesn't already guarantee. Update
the skill's guidance to match.
The skill still described oneUiVersion-era two-stage detection and said
"nothing routes on /oic/d yet" -- both stale now that resolve() checks
device_types first. Rewrite the routing section around the new three-stage
order, add a dedicated "Adding an /oic/d device type" section documenting
the right endpoints (/oic/d, /oic/p, /oic/res) and the issue-confirmed vs
OCF-spec provenance convention, and add explicit checklist reminders (in
the routing section and in "Lock it in") to check and fill in
_OIC_TYPE_TO_KEY whenever a dump carries a type.
Extend _OIC_TYPE_TO_KEY with three more device categories the OCF Smart
Home Device Specification defines with the same 'oic.d.<category>' shape
as the already-confirmed entries, ahead of seeing them in an actual dump.
Deliberately leave out the rest of a broader compiled oic.d/x.com.st.d
list (lights, switches, sensors, locks, cameras, TVs, generic energy
meters, oic.d.robotcleaner, ...) -- none of those map to a registry this
integration has, and robotcleaner in particular names a different product
than the vacuum_station clean-station registry.
/oic/d's `rt` names the device's own OCF device type, which beats
parsing board part numbers whenever a dump populates it. Add
for_device_by_oic_type() and an _OIC_TYPE_TO_KEY table (airconditioner,
dryer, refrigerator, washer, plus SmartThings' x.com.st.d.stickcleaner
and x.com.st.d.steamcloset extensions), and consult it first in
resolve(), ahead of modelNum/description and the resource-signature
fallback.
Thread the master's device_types from read_identity() through
coordinator.py's discovery pass and config_flow's connection probe;
subdevices keep resolving from their own /information/vs/0 (or the
master's registry as a fallback) since they have no /oic/d of their
own read today.
Issue #214: a single-split ARTIK051_KRAC_18K showed up in HA as two air
conditioners. Its /device/1 answers the same unused-slot shape the Pattern A
reporter's /device/2 does -- every operational rep empty {} -- but also
reports a populated /energy/consumption/vs/1. Running discovery against the
reporter's own quoted subdevice block reproduces their diagnostics exactly
(21 bound entities, the same six hrefs), and of those 21 the only primary
entity with a non-None value is energy_kwh: a lifetime kWh total was the
sole thing passing discover_partitioned's liveness gate and materializing
the phantom.
A single-split AC has one compressor and one energy meter, so a
whole-appliance running total appearing under a second index is the
appliance's own bookkeeping, not evidence that hardware is installed at that
slot. Exclude cumulative meters (HA's total/total_increasing state classes,
plus the energy/water/gas device classes for the descriptors that
deliberately declare no state class) from the gate. The gate never applies
to MAIN, and across the whole fixture corpus every device has at least one
non-meter live primary, so no existing device's entities change -- verified
by the golden for the new fixture being identical to the plain KRAC one.
Also implement async_remove_config_entry_device. A subdevice's HA device
outlives the discovery that created it, so a phantom materialized by an
earlier release stays in the registry with no way to delete it from the UI
-- which is the state the second reporter on that issue is in, with a
refrigerator whose diagnostics now report no subdevices at all. Devices the
entry currently provides still refuse removal. No automatic pruning:
enumeration is one-shot and a real sibling can miss a poll (issue #205 on
the reference hardware), so auto-removal would discard a live subdevice's
name, area and automations on a transient miss.
The new fixture's /device/1 seed is the reporter's verbatim capture; its
master half comes from the corpus's other KRAC unit, with the deviations
spelled out in seeds_note.
Claude-Session: https://claude.ai/code/session_01HzUMLnSWBT64o4BQkVEzXp
Extended the earlier de-identification (jhkwon19) to the other issue #177
reporter (HJcom), who was named in ~20 spots across coordinator.py,
diagnostics.py, subdevices.py, identity.py, capabilities/airconditioner.py,
a fixture's seeds_note, and several test docstrings/function names. Swapped
all of it for "the reporter"/"the Pattern A reporter", matching the
convention already established for the other reporter.
Added a rule to the adding-device-support skill (end of "Lock it in"):
don't put a reporter's name or GitHub username in code comments,
docstrings, seeds_note, or test/function names -- that prose ships and
sits in git history indefinitely, unlike an issue thread or a
release-notes thank-you. Use "the reporter" / "issue #NNN's reporter" /
a pattern label instead.
Comments/docstrings across subdevices.py, coordinator.py, the two fac_bora
fixtures, and their tests named the issue #177/#205 reporter directly.
Swapped to "the reporter"/"the Pattern B reporter" throughout -- no
behavior or test-assertion changes, string content only. HJcom (the
Pattern A reporter) is unaffected.
- coordinator: _poll_subdevice_flat_hrefs called sess.pace() outside its
try block and re-read self._session instead of using the caller's
already-None-checked reference -- a session closed mid-poll (async_close()
doesn't hold _session_lock) could crash the whole poll cycle instead of
just dropping that one sibling. Now takes sess from the caller and guards
pace() the same as get().
- coordinator: skip flat hrefs already covered by the hot/warm sub-poll
tiers -- those are refreshed every few seconds by _run_subpolls already,
so re-fetching them again on the once-per-summary-poll flat pass only adds
GETs, not freshness. Matters because, unlike the Collection path (always
one GET), a flat subdevice's summary-poll cost scales with its href count.
- subdevices.py module docstring: corrected an overclaim inherited from PR
#199 that GET /<uuid>/device/0 had been "confirmed live" on jhkwon19's
unit. Only an individual /information/vs/0 read was ever actually
confirmed; the Collection endpoint itself has never been observed to
answer on any known unit, which is exactly what issue #205 exposes.
- SKILL.md: fixed the numofsubdevice cross-check formula to match what
coordinator.py actually compares (len(materialized) + 1, not
len(subdevices) + len(subdevices_skipped)).
- Documented, not yet guarded against: a firmware that echoes state back
under any unrecognized prefix instead of 4.04ing could pass the flat probe
and the liveness gate, materializing a phantom duplicate of the master.
Every board seen so far genuinely 4.04s on paths it doesn't own.
- Added test coverage the review flagged as missing: a materialized (not
just skipped) flat subdevice re-polling end-to-end through to
canonical_resources, the hot/warm skip itself, zero-master-hrefs and
two-UUID no-cross-contamination edge cases, and a golden file for the
#205 fixture (SKILL.md's "fixture + golden + test" discipline).
Issue #205 shows the UUID-prefixed pattern's own reference device
(TP2X_FAC_BORA_21K) doesn't always answer /<uuid>/device/0, contrary to
what the pattern was built against. When that Collection GET comes back
empty, enumerate_subdevices now probes every href the master itself
answered this cycle individually under the UUID prefix, keeping whichever
ones respond. Subdevice gains a flat_hrefs field for this, and the
coordinator re-polls those hrefs individually each cycle instead of
re-fetching a Collection batch that doesn't exist.
Built a fixture from the reporter's real #205 diagnostics dump: the
fallback finds a candidate through the one href already confirmed live
under this UUID (/information/vs/0, from the #177 thread), and
discover_partitioned's liveness gate correctly holds it back since that
href alone binds no entity -- honest current state, not a guessed
resolution.
The AILITE water-purifier board (RWP70F15ANW) spells its modelNum
'...-REF-WATERPURIFIER-...', so the board-token scan hit the bare 'REF'
token before ever reaching 'WATERPURIFIER' and misrouted the device to
the refrigerator registry, whose resource surface shares almost nothing
with a water purifier -- hence the incomplete-coverage warning. Add a
documented carve-out for this one token co-occurrence and a matching
TestBoardTokenAmbiguity exception.
Also bind the water-purifier hrefs this board additionally exposes:
cup-detection status, the settings/sound/{mode,output,volume} trio (read
live, since this board's own supportedModes vocabulary differs from both
laundry's and air_purifier's hardcoded/live sets), and last-pour
statistics.
Separately, gate hot_water_temperature off when the device doesn't report
a supportedHotTemperatures list (only a hotwaterRange/hotwaterLevel pair
with no confirmed write contract) -- previously an empty options list
plus a live current value rendered as 'unknown' in HA, the second bug
reported in #196. The existing water_purifier_coffee fixture (#107) turns
out to hit the same shape, so its golden drops the entity too.
Issue #195 (TP1X_REF_21K) needed no change: its diagnostics show zero
unbound hrefs and the model already routes to the refrigerator registry,
matching the maintainer's own comment on the issue.
"Unit"/"sub-unit" from #199's multi-indoor-device support wasn't OCF
idiomatic -- OCF calls each component of a composite device a
"subdevice" (see subdeviceIdList), so rename SubUnit -> Subdevice
throughout: the registry module, coordinator state, BoundEntity's
subdevice field, diagnostics keys (subdevices/subdevices_skipped/
subdevice_probes), entity unique_id prefixes (unit1_/sub_<uuid>_ ->
subdevice1_/subdevice_<uuid>_), device-name fallback labels, golden
fixtures, tests, README, and the adding-device-support skill.
Breaking change to entity unique_ids and diagnostics keys, acceptable
since this hasn't been released yet.
Conflict was purely additive: both sides appended golden-regression
tests at the same point in the file, and each side's last test shared
the single trailing assert block. Kept every test from both sides, each
with its own copy of that assert.
One real semantic merge on top of that. #136 (on main) remodelled the
legacy ARTIK051 board's beep from a buzzer_volume Number to a beep
Switch, and HJcom's ARTIK051_DONGLE_FAC_18K is exactly that board
generation (is_legacy_board -> True), so its golden -- written before
that change existed -- still expected buzzer_volume. Regenerated it:
buzzer_volume/unit1_buzzer_volume -> beep/unit1_beep on the master and
the second indoor unit alike, with nothing else moving and still no
unit2_ keys. That is main's intended behavior reaching the sub-unit for
free, which is the point of binding siblings through the same registry.
967 passing. Re-ran the corpus-wide unique_id audit over all 57
fixtures (main added five this branch had never seen): no collisions
within a platform.
The skill is what tells the next person how to read a diagnostics dump,
and this branch changed the dump. Without these edits it describes the
old shape and, in one place, leads somewhere that fails silently.
The trap: on a multi-unit appliance a sibling's coverage gap appears in
unbound_hrefs as the *real* href it was seen on -- /foo/vs/1, or
/<uuid>/foo/vs/0. The skill's own rule is "every href must resolve, or
the repair fires", so the natural next move is to bind the href in front
of you. Binding runs against each unit's canonical view, so a registry
entry for an indexed or prefixed href matches nothing on any device: no
error, no entity, gap still open. Section 8 now says registry hrefs are
always canonical and nothing under capabilities/ or by_type/ should ever
mention a unit index.
Section 1 documents the four new blocks (sub_units, sub_units_skipped,
sub_unit_probes, multidevice) and that `resources` is now this unit's
own. Section 2 notes that a sibling's block is canonicalized precisely
so it drops into the standalone-discovery recipe unchanged -- the reason
that canonicalization exists is invisible unless stated. Section 10
covers the fixture's optional oic_res/seeds/probes keys, _load_device_full,
and why a multi-unit golden carries prefixed keys while the master's stay
bare.
Section 11 is new: the ordered triage for "one of my units is missing",
which is the read that would have turned #177 from days of archaeology
into a few minutes -- probes first (did we look?), then the skipped
candidates' own reps (did we reject it, and was that right?), then the
board's own count, then which pattern the board uses.
Section 5's "don't guess" rule also needed a boundary. It reads as
covering all speculative traffic, but this codebase deliberately probes
hrefs no dump contains -- read_identity and enumerate_sub_units both do.
A RETRIEVE is non-mutating and a 4.04 is tolerated throughout that path;
it's guessing a *write* against live hardware, or inventing an entity
from a field you can't explain, that the rule is actually about.
Samsung 2-in-1 air conditioners put more than one logical indoor unit
behind a single IP and a single DTLS session. Only the unit the config
entry was set up against was ever discovered; the second one -- a whole
physical appliance the user can see in SmartThings -- had no entities at
all. Two reporters turned out to have two different mechanisms:
ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the
whole tree discoverable and lists three complete parallel resource
sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1,
...), on OCF-standard and vendor hrefs alike. /device/0's batch
carries only the index-0 hrefs, so a sibling is reachable only through
its own /device/<n> collection.
TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance
tree entirely (which is why a direct /device/1 probe returns nothing
on this board). /subdevices/vs/0 carries subdeviceIdList instead, and
that UUID appears as a literal href prefix; /<uuid>/information/vs/0
was confirmed live to return the wall unit's own model and serial
(TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K.
The detection signals don't overlap on either board, so no
disambiguation is needed -- enumeration checks both and takes what
answers.
Both patterns are the same thing underneath: a logical unit is a seed
collection path to poll plus an href transform between the canonical
href the registry knows and the actual on-the-wire href. That is the
whole abstraction (SubUnit), applied at four boundaries -- discovery,
the coordinator, the adapter, and the platforms. Capabilities, the
registry and the climate composite stay written against canonical hrefs
and are untouched.
Uniqueness comes from a key_prefix inside the flattened state key, so
the master unit's keys are byte-identical to every release before this
and every existing golden file is an unchanged regression guard. Each
sub-unit gets its own device-registry entry linked by via_device and
named from its own /information/vs/<n>, so it lands in its own room
rather than crowding the master's device page.
A sub-unit materializes only when it yields at least one primary
(non-diagnostic) entity with a populated value. That gate is not
decoration: the reporter's /device/2 is an unused slot that SmartThings
shows disabled, yet it answers with a full 14-href batch, and it
flattens to exactly one non-None value -- a diagnostic alarm_code
derived from an empty /alarms/vs/2. Without the entity-category filter
it becomes a phantom third climate card. The rule is deliberately
domain-agnostic rather than a list of HVAC hrefs, so a multi-drum
washer (#19) gets the same treatment with no new curation. Units that
answer but fail the gate are logged and reported in diagnostics, so a
genuinely missing unit stays diagnosable from a dump.
Enumeration fetches things that must not then be treated as appliance
state. A rejected candidate's seed has to be read to evaluate the gate,
but only units that pass are polled again, and StateCache has no
eviction -- so discovery runs before the first cache apply and those
reps are held aside for diagnostics rather than frozen into the cache
forever. /multidevice/vs/0 is probed on every device regardless of
family, so merging it into the resources dict would have reached
discovery on any board whose registry doesn't ignore that href -- only
the air conditioner one does -- raising a spurious coverage-gap repair
for a washer or fridge whose firmware answers it. It is corroborating
metadata (numofsubdevice, confirmed read-only) and now lives beside the
resources rather than in them.
Diagnostics reports each unit separately: top-level `resources` is this
unit's own and only its own, which is what the module docstring and the
adding-device-support skill have always claimed it was, and each
sibling or rejected candidate carries its own reps canonicalized so a
block reads exactly like the master's instead of needing to be
de-indexed by hand.
Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely
verbatim, both sibling seeds and the hand-read /multidevice/vs/0
included. The TP2X_FAC_BORA one has a real device0, oic_res and
sub-unit /information/vs/0, with the remainder of that unit's tree
constructed and documented as such in seeds_note; /<uuid>/device/0 is
the one part of that pattern still inferred rather than observed, and
can't be tested through the debug panel because a Collection returns a
list.
- config_flow: the #192 port-rescue made the "every port refused" fast-fail
permanently unreachable (PREFERRED_PROBE_PORTS is always non-empty and
always rescued), so removed the dead branch instead of leaving it as
misleading dead code. A dead host now fails via the handshake loop's own
error, which carries the real per-port reason.
- oven._has_option: added the is_stub_rep carve-out cooktop.py's identical
per-token exists_fn already has on the same kind of href, so a not-yet
sub-polled /mode/vs/0 doesn't permanently exclude energy_saving/
cooktop_on_alert before their first real fetch lands.
- airconditioner.ENERGY_METER_LEGACY: build via dataclasses.replace() like
its ENERGY_METER_GENERIC sibling, instead of hand-copying href/poll_tier
(which would silently drift if common.ENERGY_METER ever gains a field).
- Fixed two stale comments: is_legacy_board()'s docstring still listed
Volume among tokens needing legacy-only gating, though 'beep' now applies
unconditionally across board generations; and common.py's UNIVERSAL
invariant comment didn't mention that airconditioner also now excludes
ENERGY_METER from the wholesale bundle.
- #191 (CAC token): added the fixture/golden/capability-test coverage the
AVT token in the same branch got, including an honestly-documented list
of the ten hrefs this board generation doesn't cover yet.
- fridge.cooler_temperature_setpoint: dropped the hardcoded "N °C" state
labels -- the resource's own unit field isn't necessarily Celsius on a
different model reporting the same href, and the options themselves are
already read live via options_field, so a static per-value label risked
asserting the wrong unit for a future device sharing this capability.
- airconditioner._legacy_cumulative_power_kwh: parse with float (matching
common.wh_to_kwh's own numeric parsing) instead of this module's
integer-only _int, so a decimal-formatted reading doesn't raise.
- test_config_flow: the port-rescue test bound the real 49154 directly,
which could collide with an actually-running service on some machine;
now monkeypatches PREFERRED_PROBE_PORTS to an OS-assigned port instead.
- oven.py: consolidated six byte-for-byte-identical single-token options
write_fns (lamp/sound/fast_preheat/natural_steam/energy_saving/
cooktop_on_alert) into one _option_switch_write(prefix) factory.
845 tests passing (up from 841 -- 4 new CAC coverage tests).
/power/0 and /power/vs/0 had no poll_tier, so they only ever refreshed on
the once-per-30s summary poll -- everything else that drives real-time
switch/climate/fan state (e.g. remote-control enablement) is already on
the faster subscribe/subpoll 'warm' cadence for exactly this reason. Scoped
to just this one tier bump; the model-specific priority-flip claim in the
same issue thread isn't independently verified, so it isn't part of this
change.
Both switches were shipped unconditionally (no exists_fn) as an unverified
guess -- the module docstring already flagged them as "unproven." Every
range/oven fixture in the corpus, including the new issue #183 dump,
reports neither fastpreheat_* nor NaturalSteam_* in /mode/vs/0's options at
all, so both were phantom controls: always read as off, and toggling them
wrote a token the firmware never recognized in the first place. That
matches the reporter's exact complaint ("doesn't appear to do anything").
Also bound two tokens confirmed present on this dump but never modeled at
all: EnergySaving_On (the 120-hour energy-saving standby from the app) and
BurnerOnAlert_Off (cooktop-on alert), following the same single-token
options-merge pattern as the existing lamp/sound/fast_preheat switches.
Child lock, the setpoint mismatch, and the missing cook-start control from
this issue are not code bugs -- see the issue comment for what was verified
and what still needs more information from the reporter.
Every ARTIK051_KRAC_18K-generation unit confirmed on hardware (three units
across two reporters) only ever carries Volume_100 or Volume_Mute in
/mode/vs/0's options -- never an intermediate value -- so the existing
buzzer_volume Number (0-100, step 10) modeled a control this firmware
doesn't have. Worse, its write path could never produce the literal
'Mute' token needed to actually turn the beep off, since it only ever
wrote a plain integer string. The 'beep' switch already used on newer
boards is the correct model here too; it's no longer gated off the legacy
board generation, and the Number entity is removed.
The WindFree-preset-not-applying report earlier in this issue self-resolved
per the reporter's own follow-up testing, so no code change was needed for
that part.
RT42DG6630B1FZ is a single-door "cooler only" fridge that reports its
setpoint on /temperature/definite/cooler/vs/0 -- a vendor resource outside
both TEMP_CURRENT_GENERIC's '/temperature/current/' and TEMP_SETPOINT's
'/temperature/desired/' href prefixes, so it was entirely unbound and the
setting stayed app-only. Its supportedList (1/2/3/4/7 °C) isn't a
contiguous range, so this is modeled as a select over the device's own
live options rather than a NumberDesc that would let a user pick an
unsupported value like 5 or 6.
AVT-WW-TP1-23-AXX500 (AX053B810HGD) reported device_type 'unknown' with
empty oneUiVersion, falling back to common caps. It's the same BESPOKE
Cube Air lineage as A-VTWW-TP2-21-COMMON (issue #151), just with the
'-WW-' delimiter shifted one letter left ('A-VTWW-' -> 'AVT-WW-'), which
splits into an 'AVT'/'WW' token pair the existing whole-token 'VTWW' entry
can't see. Added 'AVT' as its own board token onto the same air_purifier
registry -- the resource surface (wind/strength fan, HEPA filter, air
quality sensors, alarms) already matched with zero unbound hrefs once
routed there, so no new capabilities were needed.
ARTIK051_KRAC_18K-generation boards report /energy/consumption/vs/0's
cumulativePower in centiwatt-hours, not the plain Wh every other AC board
family reports -- confirmed against the reporter's own SmartThings-app
reading (raw 117430000 vs. the app's authoritative 1,174.30 kWh is exactly
a /100000 factor, not the shared wh_to_kwh's /1000 alone). Split
common.ENERGY_METER into generic/legacy variants on the airconditioner
registry, discriminated by the existing is_legacy_board() check, so every
other AC family keeps the unmodified shared capability.
The liveness sweep's ICMP-based verdict isn't trustworthy on every network
path -- a segregated-VLAN report showed it calling three closed ports live
while missing the one port a concurrent nmap scan found genuinely
open|filtered, which also happened to be one of our two historically
confirmed DTLS ports. Rather than trust a "not live" verdict against that
prior, always give PREFERRED_PROBE_PORTS a real handshake attempt even when
the sweep excludes them, bounded to at most those two extra attempts.
This is a stop-gap for the reported failure mode, not a full fix for the
sweep's underlying unreliability -- left a comment on the issue with the
diagnosis and flagged the sweep itself for a deeper redesign.
The 0.16.0 device-type simplification dropped oneUiVersion detection on
the assumption every device it typed was already reachable via a modelNum
board token. Cassette AC units (TP1X_DA-AC-CAC-01001_0000) were the one
exception -- they only ever resolved through oneUiVersion's "Air
conditioner" string, since 'CAC' was never added to the board-token
table -- so they silently fell back to common caps and lost their
climate entity.
/oic/res's baseline-Interface response only lists resources with the
discoverable policy bit set, and a real dump (issue #177 follow-up,
TP1X_REF_21K) confirms /device/0's whole x.com.samsung.da.* tree is
registered without it -- so a second logical Device's Collection, if
one exists, would be just as invisible to /oic/res as /device/0 is.
Probe /device/1 and /device/2 directly instead: a plain non-mutating
RETRIEVE, tolerated-404 same as every other speculative read in this
module. Parsed with the same parse_device0_batch used for /device/0
itself, and folded into identity.raw so diagnostics can tell "checked,
found nothing" apart from "never checked".
/oic/res is OCF's baseline resource-discovery endpoint: a unicast
RETRIEVE returns every href/Collection the connection hosts, not just
the one /device/0 seed path the coordinator polls. Relevant to the OCF
"Composite Device" model (issue #177) -- a single physical unit sharing
one IP/session across more than one logical Device, each exposed as its
own Collection resource (same rt shape as our own /device/0). Nothing
routes on this yet; captured alongside the existing /oic/p and /oic/d
reads so a report from a multi-unit device shows us whether its
firmware actually implements that model before any code assumes it does.
status on both /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0 is a bare On/Off boolean, the same
shape already shipped writable elsewhere in this file (MUTE_ONCE,
AUTO_CLEAN, AIR_PURIFY) without a live-confirmed write either -- worst
case a wrong token no-ops. The paired mode selects (switchPowerSaveMode,
motion-detect modes) stay read-only: their behavioral effect on live HVAC
isn't inferable from the dump, same reasoning as ANOMALY_LOAD's mode field.
Both FilterRemind_*/RemindBeep_* option-array tokens are already
present -- and both On and Off already confirmed -- on the existing
ME7500D fixtures (issue #152), so this is a straight sibling of the
Sound/Lamp switches rather than new discovery work. Gated with
exists_fn like Lamp since the MW7300B combi dump has neither token.
Doesn't address the rest of issue #181 (power-level slider,
non-reported cooking modes, 3-level light, child lock, send-to-
microwave) -- those need write-contract confirmation this dump
doesn't carry.
README.md and requirements-dev.txt already document that the test
harness needs Python 3.13+ (pytest-homeassistant-custom-component
doesn't resolve below it), but only in prose. Add requires-python so
pip fails fast with a clear message on an older interpreter instead of
a wall of "Requires-Python >=3.13" version-list noise.
Lennox-branded heat pump on the Samsung RAC board family (modelNum
TP1X_LNX-AC-RAC-01001_0000) already routes correctly via the existing
'-RAC-' token, but its dump has two resources no prior AC fixture
carried: /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0. Bind both as read-only
sensors, matching the CURRENT_LIMIT/ANOMALY_LOAD precedent -- nothing
in the dump confirms write safety on live HVAC hardware.
Three things this branch left stale.
"Adding a new appliance type" step 4 told contributors to key the registry
on the lowercased suffix of oneUiVersion and pointed at _type_key() for the
transform. Neither exists any more, and oneUiVersion no longer routes at
all. Describe the board-token table instead, including the two rules that
keep it a table: whole-token matching covers every delimiter spelling, and
an entry must name the specific device type rather than the board family
that contains it.
The repo-layout line for identity.py said "Reads device identity for type
detection". It has never fed type detection -- it reads /oic/p and /oic/d
for the HA device registry, and now also carries OCF's device-type
declaration into diagnostics.
The test setup installed pytest-homeassistant-custom-component and
homeassistant unpinned on top of requirements-dev.txt, which already pulls
both in at matching versions, and used whatever `python3` resolves to. On
3.12 or older nothing resolves and the install fails outright with a wall of
version-conflict output that doesn't name the real cause. Say 3.13+, drop
the redundant install, and note CI runs 3.14.
Two things a review of this branch turned up.
/oic/d's `n` is free text the owner sets from the SmartThings app, so it may
carry a person's name. Nothing in the /device/0 dump has ever exposed it --
it only became reachable when diagnostics started reporting /oic/d earlier
in this branch, which would have started carrying it into public issue
reports. Redact it. `rt`, the device-type signal the block exists for, is
untouched, and no /device/0 resource uses a bare 'n' key, so nothing else
changes.
The unknown-device-type warning logged only modelNum. That line is what a
user pastes into an issue, and modelNum alone can't identify a washer from a
dryer -- both report the shared DA_WM_ laundry board, and detection reads
the consumer-model code out of `description` for exactly that reason. Log
both fields.
oneUiVersion looks like the signal you'd want -- the device naming its own
type, '7.0 Dishwasher' -- and it was the first thing detection consulted. It
never earned the position:
- Only 7 of 49 fixtures report it at all.
- All 7 resolve to the same registry from their modelNum board token alone.
- No device-support issue has ever been fixed by adding a mapping for it.
Every one went through modelNum. The alias keys it needed in
_REGISTRY_BY_KEY ('airpurifier', 'air_conditioner', 'hood') were
speculative when the registries were first written and never used since.
So it bought a key-normalizing helper (_type_key), a lookup with a suffix
fallback (for_device), three alias keys, and a second config-flow step whose
only reason to exist was phrasing a sentence about oneUiVersion -- for a
signal that has never once been decisive.
Remove it from detection. It stays in diagnostics, where it's genuinely
useful: it names the firmware generation ('7.0 Air conditioner' is Tizen
Lite), which matters when triaging an issue.
Detection order was also duplicated in four places -- the coordinator, the
config flow's probe, the golden-regression harness, and the skill -- which
is how the harness and the shipped order drift apart. Collapse it into
by_type.resolve(resources), and call that everywhere.
The two "appliance type not recognized" config steps become one. They
differed only in whether they blamed a missing oneUiVersion, which is not a
distinction a user can act on, and never was.
Verified by the full suite (795 passing), including every golden regression
-- so entity output is byte-identical for all 49 device fixtures.
TestOneUiVersionIsNotConsulted locks in the premise rather than just the
outcome: for every dump that reports a oneUiVersion, the model strings alone
must still reach a registry. If a future device breaks that, the test says
so instead of the device silently losing half its entities.
Also note in requirements-dev.txt that Python 3.13 resolves the pinned
harness floor -- 3.12 and older resolve nothing and fail the whole install.
for_device_by_model() had grown to 21 sequential `if key is None` branches
and 102 comment lines against 59 lines of code -- 33 of the repo's 243
commits have touched this file. Most of that bulk came from one wrong
primitive: substring matching on a delimited string.
Samsung spells the same board family with either delimiter, so '_RAC_' and
'-RAC-' each needed their own rule, and 'ARTIK051_DONGLE_REF' (issues #77,
#83) matched no '_TOKEN_' spelling at all because REF lands at the end of
the pipe-prefix with no trailing underscore -- which is what
_model_num_segments() existed to work around. Which field a rule searched
(modelNum, or modelNum + description) was historical accident. Collisions
like WAC vs WA were resolved by one `if` physically preceding another,
invisible in the code and explained at length in prose.
Tokenize on any non-alphanumeric run, upper-case, and look the tokens up in
a flat table. Every delimiter spelling collapses to one entry, both fields
go through the same matcher in a documented order (modelNum, then
description, then the fuzzy consumer prefix), and specificity is a property
of the table rather than of line ordering.
Two behaviours are preserved deliberately:
- modelNum is matched before description, which is what keeps the legacy
gas cooktop correct: it reports 'ARTIK051_GB_CT_001' (CT) alongside
'ARTIK051_GLOBAL_COOKTOP' (COOKTOP, which otherwise means induction).
It is the only known device whose two fields disagree.
- _consumer_model_key still splits on '_' only. Widening it to '-' would
read the dishwasher's 'ADW-WW-RTL-24-AILITE' board segment as a bare 'WW'
washer.
Verified identical: all 49 device fixtures resolve to the same registry
before and after, and every existing for_device_by_model test case passes
unchanged. The table also picks up two families that previously depended on
oneUiVersion alone (TP1X_DA-AC-AIR air purifiers, ADW dishwashers), so they
now survive firmware that omits it.
TestBoardTokenAmbiguity guards the one property the flat lookup needs --
that no real model string contains two tokens naming different device types
-- across the whole fixture corpus, so a newly added dump exercises it
automatically.
The skill gains a section on routing: what each detection stage is for, the
rules for adding a token (name the specific type, never the board family;
never add a delimiter spelling; two-letter tokens are a last resort), when
to reach for the consumer prefix or a resource signature instead, and the
measured stake -- an unrouted device loses roughly half its entities.
Device-type detection currently parses board part numbers out of
/information/vs/0's modelNum. OCF has a standard field for exactly this
question -- /oic/d's `rt` -- and read_identity() already fetches the
resource, but kept only `n` and threw the rest away. No captured dump has
ever included it either: /device/0 batch responses don't carry /oic/d, and
diagnostics didn't report it, so there's no evidence on whether real
hardware populates it usefully.
Keep `rt` as DeviceIdentity.device_types, keep both raw payloads whole
(we don't yet know which of their fields identify a type), and surface
them in diagnostics so incoming issue reports answer the question.
Nothing routes on it yet.
/oic/d and /oic/p identify the unit with bare two-letter keys -- 'di' and
'pi' -- as sensitive as the serial number redact.py already covers but far
too short to match on: 'di' alone is a substring of 'condition', 'display'
and 'dispenser'. Add a whole-key match alongside the substring rules.
Issue #172: Samsung Microwave units (ME8000T-/AA0) omit /information/vs/0 and have empty oneUiVersion, falling back to unknown device type. Route via /oven/vs/0 and MicroWave modes in supportedModes.
An Opus review of merged PR #170 found the cleanLevel-scalar existence
gate on AIR_QUALITY doesn't hold up as a general rule: three fixtures
in this repo (air_purifier_device.json, air_purifier_vtww_device.json,
range_hood_device.json) carry genuinely populated Dust/FineDust/
SuperFineDust readings with no such scalar, so requiring it risks
silently dropping real air-quality readings on AC hardware this repo
hasn't seen yet. Reverted _has_sensor_type to item-type presence only
(as before #170) and moved the #166 fix to enabled_default=False on
all five entities instead -- same conservative, non-existence-gated
treatment already used for tropical_night_mode and the fridge/cooktop
precedents it was modeled on. Golden fixtures and tests updated to
match; the five sensors are bound-but-disabled on windfree/#17-style
boards again rather than unbound.
Also added icons for the AC fan_mode values #170 missed -- the raw
numeric labels ("1".."5") that TP1X_DA-AC-RAC-01001 and the window-AC
board report instead of turbo/max -- and swapped the whole fan-speed
icon family to mdi:fan-speed-1/2/3 for a more purpose-built look than
the generic speedometer, applied consistently to both the AC climate
card and the air purifier fan. Fixed motiondirect/motionindirect to
match core's smartthings integration's arrow pairing (previously
inverted).
Known limitation, not fixed here: enabled_default only affects newly
registered entities. Anyone who already has tropical_night_mode or the
five air-quality sensors enabled from #164 (a narrow window before
this fix, but real) won't see them auto-disable -- they'd need to
disable them by hand in Settings > Devices > Entities. A real fix
needs a one-time entity-registry migration, which this integration has
no existing infrastructure or test coverage for; scoping that felt
like its own follow-up rather than something to bolt on here.
HA core's bundled smartthings integration (the cloud counterpart to
this same Samsung AC feature set) uses mdi:weather-dust for its
wind_free preset rather than a generic windy icon -- a better fit for
a feature about avoiding direct airflow, not blowing harder. Match it
for both the AC climate preset and the air purifier fan preset.
HA only consults icon-translation state icons when the entity has no
static icon of its own (Entity.icon, if set, always wins -- see
homeassistant.helpers.entity's state_attributes construction). Audited
every entity with a labelled state/state_attributes catalog in
translations/en.json against its descriptor's icon= setting: every
select (cycles, courses, brightness levels, ...) and most sensors
already carry a fixed icon in code, so per-state icons there would be
silently shadowed. The three that don't -- air_purifier_fan's
preset_mode, machine_state, and connection_mode -- get one per value
here.
HA's core climate component already ships default icons for common
preset_mode/fan_mode values (eco, away, sleep, auto, low/medium/high,
...), but this integration's own values -- WindFree (nano/nanosleep),
Quiet, Smart, Speed, Long wind, the motion-aware direct/indirect
presets, Dry comfort, 2-Step, and the turbo/max fan speeds some boards
report -- fall outside that vocabulary and rendered with the generic
circle-dot fallback (the icon the #169 screenshot is missing). Adds
icons.json with an icon per value, mirroring the state-label catalog
these same values already have in translations/en.json.
Issue #166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) reported tropical
night mode, clean level, dust, fine dust, odor, and super fine dust
entities showing up even though the reporter's units have no such
physical features. All six were added in #164.
The Sleep_<N> options token backing tropical_night_mode is present in
every AC dump on record regardless of confirmed reality, so there's no
usable signal at boot time -- it's now registered but disabled by
default (matching the precedent already set by fridge.rack_count /
cooktop.paired_hood_model), letting units that do have it opt in.
/sensors/vs/0's item-type list has the same problem (all five types
always listed, permanently zero on this board), but there turned out
to be a real tell: a top-level x.com.samsung.da.cleanLevel scalar is
present only alongside genuinely populated readings on every dump on
record (tp1x_da_ac_rac_01011, the tp1x_da_ac_air air purifier fixture)
and absent on every all-zero ARTIK051_PRAC_20K dump, including both
#166 units and the original windfree/#17 fixtures this capability was
first verified against -- which, per their /information/vs/0, turn out
to be the same board revision as #166's units, so that "verification"
never actually proved a real sensor either. AIR_QUALITY's exists_fn now
requires that scalar, and the windfree/airconditioner golden fixtures
are updated to match (those five entities no longer bind there).
discover() only emitted a BoundEntity per capability *entity*, so a
coverage-only Capability (entities=(), used to mark a href as handled
elsewhere -- e.g. the AC climate card's wind/strength, wind/direction,
temperature/control hrefs) produced zero rows. The coordinator computed
its hot/warm href lists by walking `bound`, so every such href's
poll_tier was silently discarded and it fell back to the ~30s summary
poll only -- no sub-poll cadence and never attempted for OCF OBSERVE.
This is the root cause of issue #166's "up to a minute" lag for
remote-driven fan-speed changes: /wind/strength/vs/0 carries poll_tier
'warm' via airconditioner.COVERAGE but never reached
_hot_hrefs/_warm_hrefs, so it wasn't in the OBSERVE-attempt href list
and only refreshed on the summary poll.
discover() now takes an optional tier_log(href, poll_tier) callback
fired for every href a capability matches, entities or not. The
coordinator uses it directly instead of deriving tiers from `bound`.
Samsung pre-populates /alarms/vs/0 with one row per supported alarm type,
each carrying a '<Name>_OFF' placeholder code (no 'Deleted' state at all)
when that alarm isn't firing. common._active_alarm_codes only ever
filtered on 'Deleted' state, so every device using this shared capability
(including the AC family) showed these inert placeholders --
'ErrorCode_OFF', 'FilterAlarm_OFF' -- as if they were live alarms.
Confirmed the '_OFF' suffix convention holds across every alarm code seen
in this repo's fixtures so far (ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF,
CT_E_OFF, WaterTankFull_OFF, AC_V_0002_OFF, all placeholders; DoorA_Opened,
FilterAlarm, SNSF_Reached, all genuinely active with no suffix) -- issue
#166's own dump has both a FilterAlarm_OFF placeholder and, on a second
unit, a live FilterAlarm/state=Created alert, which is what motivated
generalizing range_hood.py's existing (but narrower, ErrorCode_OFF-only)
special case into the shared helper instead of duplicating it further.
The other three points in #166 (filter-usage percentage vs. filterStatus
disagreement, an "air purification" config toggle the reporter says has no
physical effect, a "beep on/off" control) don't have a confirmed code fix:
the percentage math already matches the device's own filterUsage/
filterCapacity fields (filterStatus is a separate device-computed field we
already relay verbatim, not something we derive), the air-purify resource
is correctly wired to what the board reports and its absence from the
official app's own options list suggests an inert shared-board-profile stub
rather than an integration bug, and a "Beep volume" NumberDesc keyed off
the same Volume_100 option both dumps report already exists (0 mutes it).
Two real bugs, both latent (no shipped fixture exercised them), plus a
consistency gap and a couple of correctness/DRY nits flagged by review:
- async_set_fan_mode resolved a fan_mode label against the static
_FAN_TO_DEVICE reverse map before checking whether the resulting code is
actually one of the unit's own supportedModes. A board using non-standard
wind-strength codes while still spelling a standard-looking label in
modesName (e.g. codes "31"-"33" named "Low"/"High"/"Turbo") would silently
write a code ("1"/"3"/"4") the device never advertised. Now validates the
static hit against the unit's own supported codes before trusting it,
falling through to the live modesName scan otherwise.
- air_purifier.WIND_STRENGTH_FAN reused key='fan', the same key as FAN in
the same registry -- BoundEntity's unique_id is built from key alone, not
href, so a board reporting both hrefs would have one fan entity silently
shadow the other. Renamed to 'wind_strength_fan' (translation_key
unchanged). No shipped fixture reports both hrefs today, but the two caps
living in the same registry made this a real latent hazard, the exact one
AIRFLOW_GENERIC's own comment already documents and deliberately avoids.
- microwave.py's cooking_mode select still used a static, union-of-all-
dumps mode list, even though both shipped microwave fixtures already
report x.com.samsung.da.supportedModes on /mode/vs/0 -- the same shape
oven._oven_mode_options was just built to prefer over exactly this kind
of static list (issue #138's follow-up, this same PR's skill update).
ME7500D advertises 4 modes; the select was offering 11. Applied the same
live-first, static-fallback pattern.
- Added the issue #152 fixture the microwave lamp fix was missing (the
SKILL.md step this PR itself added asks for one).
- climate.py's _legacy_airflow rebuilt a 2-key presence dict from
coordinator.resource()'s truthiness, which collapses "href absent" and
"href present with an empty {} rep" to the same falsy value -- while
is_legacy_board (and discover()'s own binding) test key membership, not
truthiness. Simplified to pass last_resources through directly, matching
is_legacy_board's actual contract instead of a cheaper approximation of
it, so the "can never disagree" claim in both docstrings is actually true.
- Hoisted the 'power' payload branch duplicated verbatim across
_airflow_fan_write/_fan_write/_wind_strength_fan_write into one
_power_write helper (registry/capabilities/air_purifier.py).
- Removed two now-unused imports (test_air_dresser_capabilities.py,
test_air_purifier_vtww_fan.py) and replaced a tautological
code-in-_DEVICE_TO_FAN check with one that actually exercises the live
climate entity's fan_modes/fan_mode (test_climate_ac_modes.py).
- Fixed a pre-existing (not from this PR) no-op test on main --
test_registry_reproduces_golden_state_keys_for_induction_cooktop computed
golden/state_keys and never asserted on them.
756 tests pass.
TP1X_REF_21K's EU region variant reports a bare resource-monitoring
poll-interval config (minPeriod in ms) the US variant doesn't -- the only
unbound href keeping the coverage-gap repair open. Door sensors, the
reporter's actual ask, were already covered generically by
fridge.DOOR_GENERIC/DOORS_FALLACK.
This board (a floor-standing + wall-mounted indoor unit pair sharing one
outdoor unit and one local IP) reports no oneUiVersion and carries the
'_FAC_' modelNum token, which no existing routing rule matched -- it fell
back to 'unknown' and exposed nothing but a power switch, with no climate
entity generated at all (both issues' reported symptom).
Once routed to the existing airconditioner registry, it binds cleanly
against the exact same CLIMATE composite every other room-AC family uses --
same Cool/Dry/Wind/AIComfort mode vocabulary, same wind-strength/humidity/
filter/energy resource shapes already modeled. Only two hrefs are unique to
this board: /subdevices/vs/0 (an opaque paired-subdevice id list -- issue
#150 asked whether the second indoor unit can be controlled separately;
it can't through this or any other resource in the dump, the same
"remote device ids, not locally actionable" role as the existing
/remotedeviceinfo/vs/0 ignore) and /runn/vs/0 (a single undocumented int
with no supported-values list to interpret). Both added to _AC_IGNORED
rather than guessed at.
The lamp SwitchDesc was modeled on issue #137's dump, which only ever
showed 'Lamp_Off' -- 'On' was never actually confirmed as the paired
value. Issue #152's ME7500D dump (same TP1X_DA-KS-MICROWAVE-01051 family)
is the first to report a real non-Off value, and it's 'Lamp_High' (a
brightness level), not 'Lamp_On'. So the switch always read as off
regardless of the device's real state, and toggling it on wrote a token
('On') the device has never been observed to accept -- matching the
reported "light control does not have any effect."
value_fn now treats any non-Off/non-absent value as on; write_fn now
writes back 'High'/'Off', the two tokens actually confirmed live, instead
of the never-confirmed 'On'.
The reporter's suspicion about the fan is unconfirmed and this dump's own
/hood/fanspeed/vs/0 shape already matches the no-separate-power case
fan.py's LocalThingsRangeHoodFan handles correctly (issues #137/#142), so
no fan change was needed here. A second dump attached in a comment on this
issue (model ME8000T, a large combi wall-oven with a very different mode
vocabulary) reports its own distinct gap and doesn't resolve to any known
device type at all -- that's a separate, substantial device-support task
left for its own follow-up rather than folded into this fix.
This board reports no oneUiVersion and no modelNum token any existing
family routed on, so it fell back to unknown -- exposing nothing but power
even though most of its resources (air quality sensors, HEPA filter,
device-active, diagnosis, plumbing hrefs) are already handled generically
by the existing air_purifier registry via the '-VTWW-' modelNum fallback.
The one genuinely new piece is its fan: this board reports wind strength as
numeric codes ("87"/"89"/"90"/"91") on /wind/strength/vs/0 with a separate
modesName array ("SMART"/"MAX"/"WINDFREE"/"Sleep") giving the actual names,
unlike the existing TP1X_DA-AC-AIR family where supportedModes IS the name
list already. Generalized LocalThingsAirPurifierFan to resolve a mode code
through modesName when present (same live-label pattern as climate.py's AC
wind-strength fix, issue #155) instead of adding a second hardcoded fan
class, and added WIND_STRENGTH_FAN reusing the same 'air_purifier_fan'
translation catalog -- both board generations land on the identical
smart/max/windfree/sleep vocabulary already labelled there.
/mode/convenient/vs/0 is empty on this dump and added to COVERAGE alongside
the existing plumbing hrefs this board also shares with the TP1X_DA-AC-AIR
family.
A different board generation from issue #162's DA_DF_A51_20_COMMON, also
carrying the '_DF_' modelNum token and so already routed into the
air_dresser registry -- but reporting two resources #162's board doesn't:
/st/airdressercourse/vs/0 -- the course table id (Table_00), read the
same way washer/dryer read /st/washercourse|dryercourse/vs/0. Wired up
as AIR_DRESSER_COURSE's table_href and added to the global ignore list,
mirroring that existing pair exactly.
/airdresseroption/sanitize/vs/0 -- a genuine on/off setting (not covered
by any existing capability), added as AIR_DRESSER_SANITIZE.
Introducing table_href means the course select's translation_key is now
always the table-lookup callable, so the bare 'air_dresser_cycle' catalog
entry added for #162 (only ever used when no table_href was passed) is
unreachable in every case and is removed -- both boards fall back to the
shared 'cycle' entry until their course tables get named, same as
washer/dryer's own precedent for an unrecognized table.
This board reports no oneUiVersion and no modelNum token any existing
family routed on, so it fell back to the global unknown-device CAPABILITIES
set -- exposing only power/child-lock/start-stop-pause/delay/energy/machine
state, with /course/vs/0, /diagnosis/vs/0, and /washer/vs/0 all unbound and
no course/mode select at all (the actual reported gap).
Every one of those resources turns out to already be handled by the shared
laundry machinery: /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS, and
/course/vs/0's cycle select works unmodified through laundry.cycle_options'
existing supportedOptions fallback (this board has no /wm/editcourse/vs/0
at all, so editCourseList never populates). /washer/vs/0 gets a new minimal
AIR_DRESSER_SETTINGS capability (wrinkle_prevent only) rather than reusing
dryer.DRYER_SETTINGS wholesale, since this device never reports
dryLevel/dryTime/dryerType at all and binding them would ship three
permanently-unavailable sensors.
Course codes aren't identified yet (no code->name mapping was reported), so
they render as their raw codes until named in translations, same as
dryer.py's precedent for unidentified codes.
TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN) reports /wind/strength/vs/0
supportedModes as "0"/"31"-"35" instead of the "0"-"4" scale climate.py's
_DEVICE_TO_FAN was built from. Only "0" matched, so fan_mode/fan_modes
silently dropped every speed but Auto -- exactly the reported symptom.
Rather than hardcoding a second numeric scale, codes _DEVICE_TO_FAN doesn't
cover now fall back to the device's own modesName label (parallel-indexed
with supportedModes), mirroring how preset_mode already resolves dynamically
off a device's own supportedModes instead of a per-model table. Boards using
the standard 0-4 scale are unaffected -- _DEVICE_TO_FAN is still tried
first, so existing auto/low/medium/high/turbo labels don't change.
capabilities/airconditioner.py's is_legacy_board() (renamed from the
private _is_legacy_board -- it's now a cross-module helper) and
climate.py's _legacy_airflow() implemented the same "does this board have
/airflow/vs/0 but no /wind/strength/vs/0" test independently, one via
literal href strings and the other via coordinator.resource() truthiness.
is_legacy_board() now uses the module's own HREF_AIRFLOW/HREF_WIND_STRENGTH
constants, and _legacy_airflow() delegates to it via a minimal two-key
presence dict (cheaper than a full last_resources snapshot copy) instead of
re-implementing the check, so the token entities and the climate card's
legacy read/write paths can't drift apart on which board generation is in
play.
Same issue as the beep fix in the previous commit: this also assumed
_option_token returned the full 'Sleep_<N>' token and tried to split
off the prefix itself. With the canonical value-half _option_token,
that always returned None. Read the value directly instead.
#146's zero-as-"not measuring" carve-out was meant for ARTIK051 boards'
plain x.com.samsung.da.humidity field, which only populates while Air
monitoring is briefly on and zeroes out afterward. It was accidentally
applied to fivepercentHumidity too, which every other AC board relies on
and which has never been documented getting stuck at zero -- so a real 0%
reading on those boards silently became "unknown". Only the humidity
fallback field now collapses 0; fivepercentHumidity passes 0 through as a
real reading.
Both PRs independently modeled the same /mode/vs/0 Volume_*/Sleep_*
option tokens: #129 as beep/tropical_night_mode, #146 (already merged)
as buzzer_volume/good_sleep gated to the legacy ARTIK051 board
generation. They also each defined a helper named _option_token with
different return semantics (full token vs. value half) in
non-overlapping parts of the file, so git didn't flag it as a
conflict even though the second definition silently shadowed the
first.
Keep a single _option_token (value-half, the one already used by
buzzer_volume/good_sleep/spi/etc.), adjust beep's read/write to that
convention, and gate beep/tropical_night_mode off the legacy board so
they don't duplicate buzzer_volume/good_sleep on ARTIK051_KRAC-class
devices. Added a regression test locking in the gate.
Follow-up to the issue #138 fix: rather than hand-adding
ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
to oven._OVEN_MODES, read them from the device's own /mode/vs/0
supportedModes when it reports one, falling back to the static
NV7000BS-era guess only when it doesn't. Matches the adding-device-support
skill's preference for device-reported option lists over hardcoded ones,
and the SelectDesc's write validation now checks the same live list it
displays instead of a separate static tuple.
Prompted by issue #138's fix, which extended oven._OVEN_MODES with newly
confirmed modes instead of reading them from the device's own
supportedModes field via options_field -- the pattern laundry.py already
uses for buzzer/finish sound. Document that preference so future
device-support work reaches for options_field/a callable first and treats
a static tuple as a last resort, not the default.
The range/oven-combo device in issue #138 (NE63A6511SS/AA, no
/information/vs/0) already resolves cleanly to the range registry via the
issue #74 for_device_by_resources fallback, with zero unbound hrefs -- the
reporter was just on an older release (0.11.1) predating that fix.
However its /mode/vs/0 supportedModes advertises ConvectionRoast, KeepWarm,
BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean, none of which
were in oven._OVEN_MODES. Since range.py reuses oven.OVEN_MODE's SelectDesc
wholesale, those modes were silently rejected by the mode select's write
validation and missing from its options. Extend the confirmed mode list and
lock in a scrubbed fixture, golden, and test for this dump.
mbillow's follow-up review: exposing read-only Software/Firmware (and
Outdoor/Touch IC) version strings is "data for the sake of exposing it" --
no user control, just clutter, and every other registry leaves
/information/vs/0 in the global ignore as identity plumbing. Conforming to
that stance rather than expanding the entity surface for no user story.
- by_type/airconditioner.py: revert to *ignored.IGNORED (no _IGNORED_LESS_INFO
filter); drop airconditioner.INFO from the registry.
- capabilities/airconditioner.py: remove the INFO capability, the _info_version
/_info_items_of_type /_has_info_version helpers, and HREF_INFORMATION.
- translations/{en,nl}.json: drop software_version, firmware_version,
firmware_version_2/3, outdoor_unit_version, touch_ic_version.
- tests: drop the six INFO tests; golden regenerated for 8 AC + dehumidifier.
625 tests pass.
Opus review of #149 caught that swapping is_stub_rep(rep) in for the
default field-presence gate (not just the 9 hand-audited exists_fn call
sites) was too broad: it silently excludes entities on ANY resource whose
normal, valid state includes reporting {} -- /alarms/vs/0's {} is
fridge.py's documented no-alarm state, not an absence signal, and it's not
the only one (job_beginning_status, diagnosis_status, sabbath_mode,
defrost_delay, ice_maker_enabled all lost entities on real fixtures under
the broader change). That's the opposite of #127's fix: a real fridge
would have dropped its alarm sensor on first-poll timing, not just its
phantom energy sensors.
Restored the default gate to include on either a stub or a genuinely-empty
rep -- verified byte-identical to the pre-#149 baseline across all 40
fixtures, apart from the 9 deliberately-audited exists_fn sites (energy
meter, self-check error, cooktop burner, range-hood auto-op), which are
unaffected and still fix#127. Added tests/test_entity.py exercising
_is_included directly (previously untested) and fixed two now-stale
"not rep" doc references the review also flagged.
parse_device0_batch used to collapse /device/0's {"href": "..."} "no data
yet" marker into a plain {}, indistinguishable from a resource the device
had actually polled and confirmed empty. Every exists_fn using the "not
rep or ..." stub carve-out (and entity._is_included's default field-gate)
then treated both the same way, creating phantom always-"unknown" entities
for any resource a model simply doesn't support (e.g. GSzabados's fridge's
/energy/consumption/vs/0).
is_stub_rep() now recognizes only the literal {"href": ...} marker as a
stub; a genuine {} is treated as the device's real (if empty) answer and
gates the entity off like any other missing field. Updated the energy
meter, self-check error, cooktop burner, and range-hood auto-operation
exists_fn call sites, plus three golden fixtures that had baked the
phantom-entity behavior in as "expected".
runningProgress's own field name states its domain, so restore unit='%' and
state_class='measurement' rather than leaving it an opaque passthrough --
that hedge made sense for activationState (no supported-values list, no
write contract to invent) but not here, where the name itself is the
evidence.
"Nadraaien voortgang" also wasn't idiomatic Dutch (nouns don't stack that
way); "Voortgang nadraaien" matches how the rest of the catalog compounds
these names.
An Opus review of the previous commit found that giving the switchHotwater
fallback and LOCK.hotwater_lock the same key introduced a real bug:
adapter.flatten() (the source of coordinator.data, which every switch's
is_on reads) only ever honours exists_fn, never entity.py's implicit
own-field-presence default that gates plain registration. With only one
side of the pair gated, both descriptors still wrote the same key into the
flattened state dict, and whichever was processed last -- decided by
device-reported href order, not correctness -- silently won. Reproduced
with the existing coffee fixture: reversing resource order flipped
hotwater_lock from correct (False) to a stuck True.
Fixed by gating both sides symmetrically via a shared tri-state helper that
also treats an unfetched /status/lock/vs/0 stub as "outcome pending" rather
than "confirmed absent" -- otherwise the stub window let both descriptors
pass exists_fn at once, which would have registered two switch entities
with the same unique_id. The fallback also re-asserts its own field's
presence, a check it used to get for free before it shared LOCK's key.
Also addresses two smaller findings from the same review, both in the
range-hood after-run capability (#147): runningProgress's unit='%' was a
guess from a single "0" sample with no supported-values/range field to
confirm the domain -- inconsistent with treating activationState as
read-only for the same "don't guess" reason -- so it's now a bare
passthrough sensor; and entity_category='diagnostic' was dropped from the
two read entities since after-run is a feature the user actively watches
and cancels via the (correctly uncategorized) button, not passive
diagnostics.
- _speed_zero_is_off now keys off the hood resource's own
settableMinFanSpeed/supportedFanSpeed fields instead of asking whether
the device has any power resource at all, so a combi appliance's cavity
/power/0 can no longer be toggled off by turning off just the vent fan.
- async_turn_on() no longer resets an already-running fan to its lowest
speed when called without a percentage.
- _has_separate_power() reads through the O(1) resource cache instead of
copying the full resource snapshot on every property access.
- _kimchi_mode_write rejects values the compartment didn't advertise in
supportMode instead of writing them blind.
- kimchi_ripening_status no longer lowercases its value, since it has no
enum catalog entry to translate the lowercased token back through.
- Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0
aggregate fallback on the one fixture that reports both.
Adds regression tests for the combi-appliance power targeting, the
already-on turn_on no-op, kimchi mode write validation, and a kimchi
select display/write casing round-trip; a translation-coverage guard for
kimchi_zone_mode codes mirroring the existing AC preset one.
Water purifier (#144, #145): /favorite/hotwater/vs/0's switchHotwater field
is a Locked/Unlocked hot-water lock, not a "favorite enabled" flag. It's the
same lock as LOCK.hotwater_lock, just surfaced through this href on boards
that don't populate /status/lock/vs/0's hotwaterLock -- the two now share
the hotwater_lock key/translation, gated so only one is ever active.
Range hood (#147): binds /afterrun/vs/0 (after-run activation state,
progress, and a cancel button), clearing the last unbound href for
AHD-WW-TP1-22-COMMON.
The legacy Comode codes resolve through the same dynamic resolver as a real
convenient resource, so Nano lands on the existing 'nano' preset (already
labelled WindFree) rather than on a 'windfree' value of its own -- the new
tests asserted the label instead of the value. 2Step had no catalog entry in
either language and would have surfaced as the raw code.
Also updates the existing five-percent-humidity test, which reached into the
descriptor's field/value_fn directly, to the rep_fn the fallback needs, and
covers the fallback itself.
TP2X_REF_20K-class 3-compartment kimchi refrigerators report each
compartment's storage mode and ripening status/timer on
/status/kimchi/<slot>/vs/0, plus a top-compartment door sensor on
/kimchidoors/top/vs/0 -- all previously unbound. Bind them as pattern
capabilities (fridge.KIMCHI_ZONE, fridge.KIMCHI_DOOR_GENERIC), deriving
the per-compartment entity key and display name from the href's
top/middle/bottom segment, the same way DOOR_GENERIC/TEMP_CURRENT_GENERIC
already do.
Storage-mode option labels were translated directly from the reporter's
own SmartThings app screenshots rather than guessed from the raw device
codes or their English paraphrase, confirming the on-screen option order
matches supportMode's array order (including the freezer triplet's
-19/-21/-17°C -> Standard/Strong/Weak mapping).
Also tighten FLEX_ZONE's exists_fn: this device's /mode/vs/0 also
populates modes/supportedOptions, but with a token shape that never
overlaps (a "_[n]:[n]" suffix supportedOptions carries that modes never
repeats), so the existing "supportedOptions is nonempty" check let the
entity bind anyway and get stuck permanently on "unknown". Requiring an
actual resolvable value keeps it working for the RF9000/Bespoke-class
fridges it was built for while leaving it absent here.
Fixture is a scrubbed diagnostics dump from the unit the writes and
calibrations were verified on; the issue #136 unit is the same model with a
slightly different token set (no Spi, FilterTime_5460, OutdoorTemp_81), which
the presence gating handles the same way.
Covers the pieces the golden's state_keys can't: that the token entities stay
off newer boards, that an absent token yields no entity, that humidity's zero
reads as unknown, that fan/swing/preset read and write through /airflow/vs/0
and the Comode token, and that a board with /wind/* and /mode/convenient/vs/0
still takes the resource paths.
Refs #136
This board generation predates every AC dump the registry was built from and
differs in three ways, all handled here behind presence checks so no other
family's behaviour changes:
* No /wind/* resources at all. Fan speed and vane direction share a single
/airflow/vs/0 resource, whose speedLevel uses the same 0-4 scale as
_DEVICE_TO_FAN and whose direction uses the same codes as _DEVICE_TO_SWING,
so the existing maps are reused rather than duplicated. The resource reports
no supportedModes, so the full scale is offered.
* No /mode/convenient/vs/0. The convenient-mode preset is a Comode_* token in
/mode/vs/0's options, synthesised into a convenient-shaped rep so the
existing dynamic preset resolver keeps working unchanged. Codes were learned
by driving one unit through its cloud integration and reading the token back
each time: Nano is what the app calls WindFree, plus Quiet/Comfort/2Step/
Speed (Fast Turbo) and Off.
* Several settings that newer boards expose as dedicated resources are options
tokens here: SPI, auto clean, air monitoring, beep volume, Good Sleep,
outdoor temperature and filter time. Writes reuse option_write's single-token
merge, the same mechanism the display light already uses on this href.
Newer families carry some of the same tokens *alongside* dedicated resources
for those settings, so the token entities are gated on this generation's
resource shape (/airflow/vs/0 present, /wind/strength/vs/0 absent -- the same
test the climate entity's fan/swing fallback uses, so the two can never
disagree). Without that gate they duplicated auto clean on TP1X/TP2X boards
and applied a calibration from this board to theirs.
Two calibrations, both from hardware rather than from the token names:
OutdoorTemp is offset by 55 (token 75 against a 20.3 C outdoor thermometer in
the same install, token 74 against a 19.4 C forecast; Fahrenheit fits far
worse), and FilterTime is tenths of an hour (token 1710 while the official
Samsung app displayed "171 hours 0 minutes" for the same unit's filter).
Whether filter time counts up or down is deliberately not claimed: it was seen
rising while the unit ran, which contradicts the app's "remaining" wording.
Humidity now falls back to the plain x.com.samsung.da.humidity field where
fivepercentHumidity is absent, still as one entity rather than two, and 0 reads
as "not measuring" rather than 0% -- on this board the field only carries a
reading (51%, matching the same unit's cloud integration) while Air monitoring
is on, which the unit switches back off by itself after about a minute.
Fan, swing, preset, SPI and beep-volume writes were confirmed by read-back on
hardware. Good Sleep's upper bound is a guess (only 0 has been observed), and
/airflow/0 -- the OCF-standard mirror of the vendor resource -- is ignored
rather than modelled, since air_purifier.py found the opposite reliability
ordering between these two hrefs on its own family.
Refs #136
Room air conditioners on the ARTIK051 board (ARTIK051_KRAC_18K, issue #136)
report no oneUiVersion and carry a '_KRAC_' token in modelNum. The existing
'_RAC_' check can't see it -- the 'K' sits between the underscore and 'RAC' --
and the consumer-prefix fallback only covers washers/dryers/dishwashers, so
these units fell back to 'unknown': 8 of their 19 resources ended up unbound
and the device exposed nothing but a power switch.
Same ARTIK051 board family as the '_TVTL_' air purifier handled just below.
Combi microwave units report their vent fan on the same resource shape a
standalone range hood uses, so reuse range_hood.HOOD_FAN directly in the
microwave registry. Unlike a standalone hood, this board has no sibling
/power/0 or /power/vs/0 resource, so LocalThingsRangeHoodFan now falls
back to treating fan speed 0 as the off state when no separate power
resource is present. Also gate HOOD_FAN's automatic_operation sensor on
field presence, since this board doesn't report it.
Microwaves (combi and plain) were routed onto the oven registry (issue
#121), which meant entities carried oven-flavored keys (oven_state,
oven_mode, oven_setpoint) and inherited oven-specific behavior that's
wrong for this family: a 30-270C setpoint range instead of this family's
actual 40-200C, a cooking-mode list missing MicroWave/MicroWaveGrill/
MicroWaveConvection/KeepWarm entirely, and a lamp switch that read/wrote
the oven's 'UpperLamp' option token instead of this family's 'Lamp' token.
Adds a microwave device type (by_type/microwave.py,
capabilities/microwave.py) that reuses the oven board family's shared
operational-state/door/connected/recipe-cook capabilities but defines its
own cooking-mode, setpoint, and cavity capabilities with the corrected
bounds/vocabulary, plus a new power_level sensor for the cavity's Watt
setting that was previously unexposed.
Opus review of the previous commit caught two real bugs. The airflow fan's
power writes preferred /power/vs/0, copied from the TP1X fan class -- but
that order is only harmless there because TP1X never reports /power/0 at
all. This family's dumps carry both hrefs, and the power_switch entity is
unconditionally bound to /power/0 when present, so the fan was writing to
a different resource than power_switch reads/writes, leaving the two
entities disagreeing until the next poll. Flipped to prefer /power/0,
matching the range hood's fan and common.POWER_GENERIC.
Also renamed the new FanDesc's key from 'fan' to 'airflow_fan': BoundEntity
unique_ids are derived from key alone, not href, so it collided with
air_purifier.FAN's own 'fan' key on the (currently unobserved, but
unenforced) possibility of a board reporting both.
Added a platform-level test file covering the power-href preference and
percentage<->speed-code mapping, mirroring test_range_hood_fan.py's harness.
/airflow/0's speed was left read-only because the first round of diagnostics
wasn't conclusive (0 for both Auto and High, 3 for Low/Medium and Sleep) --
likely because all five dumps were captured within about a minute of each
other, faster than this integration's own poll cycle could settle each
change. A second round, captured 60-90s apart per setting on two independent
units, confirmed a clean monotonic 0-4 mapping across Auto/Sleep/Low/Medium/
High instead.
Builds an ordered-speed fan off that confirmed range, the same SET_SPEED
shape as the range hood's fan -- this board never self-reports a
supportedModes-style label list, so there's no named-preset table to
preserve, just percentage steps over the raw code. /airflow/vs/0's vendor
speedLevel stays a read-only fallback since it was unreliable in that same
second round.
Conflicts resolved:
- capabilities/airconditioner.py: keep my NumberDesc/SelectDesc imports +
upstream's hoist of _filter_usage_percent -> common.filter_usage_percent
(local def removed by upstream's auto-merge; AIR_FILTER.air_filter_usage
now references the common helper). Upstream's ANOMALY_LOAD, HREF_WIND_OSCILLATION
and oscillation climate-write kind kept; my beep/tropical/INFO/AIR_QUALITY/
threshold-Select/filter-hours kept. Both change sets coexist.
- translations/{en,nl}.json: switch section -- keep both my 'beep' and
upstream's display/pet_filter_activation/auto_empty/dustbin_auto_close/
uvc_intensive_mode entries.
Regenerated the 8 AC + dehumidifier goldens (+ the 2 new upstream AC fixtures
ara_ww_tp1_22, windfree_oscillation) against the merged code. 631 tests pass.
Per mbillow's review on PR #129 (CHANGES_REQUESTED). Items 1-7 + smaller.
1. INFO: expose each /information/vs/0 version item per (type, ordinal) instead
of collapsing to a single first-Firmware value. Boards carry 1-3 Firmware
items (separate MCUs) plus an Outdoor unit and (window AC) a Touch IC item;
each is a distinct version string. _info_version(items, type_, ordinal)
+ _has_info_version exists_fn gate, so an item with no number (tp2x_rac_20k's
second Firmware) suppresses the entity rather than binding unknown.
2. air_filter_threshold is locally writable, not cloud-only -- confirmed live
on ARTIK051_PRAC: POST filterDesiredUsage=700 to /filter/airdustfilter/vs/0
-> 2.04, read-back 700, persists; restored to 500. Converted from a
read-only sensor to a SelectDesc keyed to the device's
supportedFilterDesiredUsage enum (options_field), with a write_fn that
POSTs the scalar field. Only binds where the enum is advertised; boards
without it leave this writable field unexposed rather than guess the valid
set (don't-guess).
3. air_filter_usage_hours uses unit_fn reading filterCapacityUnit ('Hour'->'h')
instead of a hardcoded unit, so a board advertising a different unit doesn't
mislabel a duration statistic.
4. air_filter_usage_hours state_class is total_increasing, not measurement --
filterUsage is a lifetime hour counter that resets on filter replacement.
5. INFO and air_filter_threshold now carry exists_fn (AIR_QUALITY already did),
so they don't bind a permanently-unknown entity when their item/field is
absent. Also fixes the caww_tp2 golden nuance (filterDesiredUsage absent
-> no threshold key, matching what HA would actually create).
6. AIR_QUALITY: CleanLevel is corroborated as numeric by a top-level
x.com.samsung.da.cleanLevel scalar (tp1x_da_ac_rac_01011 reports both as
'1'), so clean_level is now an int measurement; odor/dust/fine_dust stay
string diagnostics. Reinstated the 2-element-array ambiguity note (Dust/
FineDust/SuperFineDust report ['0','0']; v[1] meaning unconfirmed, v[0]
taken as the reading). SuperFineDust is now modeled for consistency with
Dust/FineDust (same shape), rather than skipped without reason.
7. _beep_write restores the last non-Mute Volume level on 'On' instead of
forcing Volume_100, so an intermediate setting (e.g. Volume_50 set via
the cloud) survives an off/on cycle; falls back to 100 when no prior level.
Smaller: test_air_quality now asserts the tp1x_da_ac_rac_01011 clean_level==1
non-zero reading (the one value_fn-regression catch in the corpus); renamed
by_type's _AC_IGNORED -> _IGNORED_LESS_INFO to resolve the two-meaning
collision with the capabilities module's _AC_IGNORED (href strings); golden
regenerated for the 8 AC fixtures + dehumidifier.
612 tests pass.
When HA restarts without a clean DTLS close_notify (crash, host reboot),
the appliance keeps an orphaned DTLS association keyed to the client's
(IP, source port). Reconnecting from a fresh ephemeral port looks like a
new peer, so the device holds the orphan until its own timer reaps it,
which is 5 to 15 min on always-on appliances (fridges), during which the
new session's first reads hang. This is the root cause behind the repeated
"DTLS handshake timeout" reconnect storms on always-on devices (#119).
Bind a deterministic source port per device so every reconnect re-handshakes
over the same 5-tuple, which the device must treat as a rebooted peer and
evict the old association for (RFC 6347 §4.2.8). Recovery drops from a
device-timer wait to a single handshake.
The port must be stable across restarts and unique per device on the HA host
(the library socket is unconnected, so a shared source port would cross-
deliver datagrams). _local_source_port() uses the host's last IPv4 octet as
the offset from DTLS_LOCAL_PORT_BASE (unique on a /24), with a CRC32 fallback
for non-IPv4 hosts.
Requires smartthings-local >= 0.1.1, which adds DtlsCoapSession(local_port=).
The fix is backwards compatible upstream: local_port defaults to None
(previous ephemeral-port behaviour).
Root-caused and verified upstream in QuiteYellow/SmartThings-Local#14
(bench-verified on oven + dryer, field-verified on an always-on fridge
across repeated restarts).
- vacuum_station._parse_iso_utc was a verbatim copy of
water_purifier._parse_iso_utc; promoted to common.parse_iso_utc and
pointed both families at it. Also made it tzinfo-aware rather than
unconditionally overwriting with UTC -- harmless today since every
dump seen is a bare or Z-suffixed UTC timestamp, but a board that
ever emits a real offset would otherwise have it silently clobbered.
- Added the new vacuum_station type to the README's supported-appliance
table, and noted that combi microwaves route through the oven
registry.
- The fan's power write hardcoded /power/vs/0 while is_on already read
/power/0 as a fallback -- a board reporting only the OCF resource
would show correct state but silently no-op on every turn-on/off.
Mirrors LocalThingsRangeHoodFan's existing _power_payload pattern:
target whichever power href the board actually reports.
- async_set_preset_mode fell off the loop silently on an unmatched
mode with no log and no error, unlike the rest of this codebase's
write-rejection handling. Logs a warning now.
- Deduplicated HEPA_FILTER's usage-percent calculation, which was an
inline reimplementation of airconditioner._filter_usage_percent;
promoted the shared logic to common.filter_usage_percent and pointed
both families at it.
- Gave air_purifier.SOUND_MODE its own translation_key instead of
defaulting to the same catalog entry laundry.SOUND_MODE uses. That
entry's state table is {voice, tone, mute}; this board's is
{mute, buzzer} -- sharing it left 'buzzer' with no label.
The 60s/5-reconnect window from the original fix could never actually
fire: consecutive reconnect attempts are never closer together than one
summary poll interval (30s) plus the 5s reconnect pause, so at most ~2
timestamps can ever land inside a 60s window regardless of how unhealthy
the connection is. That silently downgraded every reconnect to INFO
permanently, including the persistently-broken case the change was
supposed to still surface at WARNING.
Widen to a 300s window with a threshold of 3, which is reachable under
sustained failures and still a reasonable proxy for the README's
"actually broken" case.
A-VSKR-TP1-22-VS9500AL connects successfully but its dump shows no
vacuum-body state at all -- no suction level, no battery, no cleaning
mode -- only the clean/auto-empty 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, so the station is the only "device" this integration's local
API can reach at all.
New vacuum_station device type (these hrefs share nothing with any
existing family, so there's no shared-href ambiguity to resolve
against another type) routed via a new '-VSKR-' modelNum fallback.
Binds with zero unbound hrefs: dust-bag full/usage sensors, auto-empty
and dustbin auto-close switches, a discharging-time select, and
clean-station status including UV-C intensive mode, operation time,
and finished/emitted timestamps. A couple of fields with unconfirmed
exact semantics (stick_status, dustbag_usage's unit) are exposed as
plain diagnostic values rather than an asserted binary/percentage
meaning.
This newer board family reports fan modes (Smart/Max/Mid/WindFree/Sleep)
directly on /mode/vs/0's top-level modes/supportedModes fields, unlike
the older ARTIK051_TVTL family this registry already supported, which
packs everything into an options[] array with no usable fan-speed
selector at all (see the module docstring's Comode_Off finding). Both
board generations share the /mode/vs/0 href, so the existing MODE
capability and a new FAN capability are discriminated by a match_fn
checking for the top-level supportedModes field, rather than adding a
new device type.
The fan entity only exposes PRESET_MODE, not an ordered percentage --
WindFree/Smart/Sleep are named behaviors, not "faster/slower" positions
relative to Max/Mid, matching how the AC family's own named convenient
modes are modeled as a preset rather than a speed number.
Also picked up the rest of this board's previously-unbound hrefs while
in there (display, HEPA filter, panel status, pet-filter mode, sound
settings), reusing airconditioner.DISPLAY_LIGHT and
airconditioner.MUTE_ONCE for the two hrefs identical to the shared
DA-AC- board family, since the "incomplete capability coverage" repair
was firing on more than just the fan gap the issue described.
TP1X_DA-KS-MICROWAVE-01041 (MW7300B) reports no oneUiVersion and an
unrecognized consumer token, so it fell back to 'unknown' and only got
common capabilities. It shares the same '/oven/vs/0' cavity resource
and '/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*)
as the wall oven already supported, so this reuses that registry via a
new '-MICROWAVE-' modelNum fallback rather than adding a new device
type. The only href it didn't already cover was /recipe/cook/vs/0, an
empty quick-recipe-display blob bound with no entity per the 'don't
guess' rule.
Newer TP1X_DA-AC-RAC-01011_0000 firmware (Bespoke AI WindFree Deluxe,
AR60H10D1JWNME) drops /wind/direction/vs/0 entirely and reports swing
via a separate vertical/horizontal Swing|Fix pair on
/wind/oscillation/vs/0 instead, which left swing_mode/swing_modes
silently empty and the href unbound. climate.py now falls back to the
oscillation resource when /wind/direction/vs/0 is absent, mapping the
same off/vertical/horizontal/both vocabulary the existing swing control
already uses.
Also binds the board's /anomalyload/vs/0 overload-response resource as
read-only diagnostic sensors (operation state + mode) -- the same
"don't guess" precedent as the existing CURRENT_LIMIT capability, since
nothing in the dump confirms the exact behavioral difference between
its 'Alarm' and 'PowerSaving' modes or whether toggling it is safe on
live HVAC hardware.
Most of the other gaps this issue reported (Fan-only mode, WindFree
preset labels, target-temperature channel selection, power on/off via
the vendor resource) turned out to already be fixed by the just-merged
cool-only global RAC work.
Samsung's firmware occasionally drops the DTLS session briefly --
normal appliance-side behavior per the README's "Known device
behavior" section -- so the coordinator recovering from that on its
own doesn't need a WARNING. Only escalate once reconnects pile up
within a trailing 60s window (5+), matching the README's own "more
than a handful per minute" definition of an actually broken
connection.
AR10/13/18BYEAAWKNME report no oneUiVersion and no '_RAC_'/'-RAC-'
token at all, so for_device_by_model() fell through to 'unknown' and
every href went uncovered. The board carries the same TP1X-class
resource surface as every other room AC already supported (mode/
convenient/wind/temperature/power/filter/humidity), so this reuses the
existing airconditioner registry via a new 'ARA-WW-' modelNum fallback
rather than adding a new device type -- confirmed against all four
reporters' dumps binding cleanly with zero unbound hrefs.
TP1X_DA-KS-RANGE-0102X already resolves via the '-RANGE-' modelNum
fallback and /cooktopmonitoring/vs/0 already binds through
range.COOKTOP_MONITORING, so this model binds with zero unbound hrefs
today -- add a fixture to lock that in.
The DA_WM_TP1_21_COMMON board family already routes through the 'WA'
consumer-model-prefix fallback and binds cleanly against the washer
registry with zero unbound hrefs, but no fixture locked that in for
this specific board generation -- add one.
HA's set_temperature service forwards an optional hvac_mode to the entity, and
the entity is expected to apply it. The AC climate entity ignored it and only
wrote the setpoint, so a set_temperature call carrying hvac_mode (e.g. a
dashboard "turn on to Auto 24" button) set the temperature but never changed the
mode or powered the unit on. Apply the mode first -- which powers the unit on
when it was off -- then the setpoint.
Layer the ARTIK051_PRAC additive entities onto the upstream registry on a
fresh branch (additive-only; the round-1 display-light/mute-once/WindFree
work was independently shipped upstream and is not redone here).
New entities, all driven by single-token option_write or item reads:
- beep (switch): Volume_Mute/Volume_100 option token; single-token merge --
a full options RMW reverts on ARTIK051_PRAC. Cloud counterpart:
samsungce.airConditionerAudioFeedback (on/off only; level is cloud-only).
- tropical_night_mode (number 0-16): Sleep_<N> option token. Cloud:
custom.airConditionerTropicalNightMode.
- air_filter_usage_hours / air_filter_threshold (duration sensors, h):
raw filterUsage count and filterDesiredUsage alarm threshold. Threshold
SET is cloud-only (samsungce.dustFilterAlarm); local read-only.
- clean_level / odor / dust / fine_dust (diagnostic sensors): /sensors/vs/0
items[]. No unit advertised on the resource, so no device_class until a
populated reading + unit is observed (don't-guess rule). SuperFineDust
intentionally not modeled.
- software_version / firmware_version (diagnostic sensors):
/information/vs/0 items[]. The href is globally ignored as identity
plumbing; the AC registry drops that ignore so INFO is the sole cap on it.
Translations: en.json + nl.json (every-language-mirrors-english invariant).
Golden regression: regenerated the 8 AC fixtures + dehumidifier (reuses
AIR_FILTER) for the new state keys.
608 tests pass.
- Pin the FAN_ONLY reverse-write fallback to 'Wind' (the original single
spelling) instead of letting it silently flip to 'Fan' just because
'Fan' was added second to the dict -- _device_code_for_hvac() resolves
the code from a unit's own supportedModes first, so this dict is only
a fallback for a unit reporting none at all, and that fallback
shouldn't change behavior as an unintended side effect of insertion
order.
- Add direct tests for _preset_to_ha() (pure function, previously
untested) and the FAN_ONLY fallback pin.
- Cross-reference the AC family's inverted Light_On/Light_Off polarity
against air_purifier.py's plain-polarity use of the same token name on
the same resource name, so a future refactor doesn't assume they're
the same thing.
- Fix a one-column continuation-line misalignment and a stale PR-number
reference in a comment.
Finishes PR #91's contribution (pedroperosin) with the requested review
changes applied, on our own branch:
- Preset resolution is now fully dynamic, read from each unit's own
/mode/convenient/vs/0 supportedModes instead of a static per-model
table -- any board's convenient modes (including WindFree's Nano/
NanoSleep) surface without code changes. Unlabelled codes across
existing fixtures (longwind, motionindirect, motiondirect, drycomfort)
plus the two new WindFree ones are added to en.json/nl.json.
- 'Auto' now maps to HVACMode.AUTO instead of HEAT_COOL: these are
single-setpoint "device decides" units, not two-setpoint heat+cool
ones. 'Fan' is added alongside 'Wind' as a second FAN_ONLY spelling;
a new _device_code_for_hvac() picks the code from the unit's own
supportedModes since the flat map can't disambiguate two device codes
mapping to one HA value.
- Detection gains a hyphenated '-RAC-' modelNum fallback (alongside the
existing '_RAC_') for cool-only global RAC variants whose
/otninformation/vs/0 ships no swVersionInfo block.
- Adds a display_light switch sourced from /mode/vs/0's opaque options
blob (inverted Light_On/Light_Off token) for boards with no dedicated
/light/vs/0 switch.
Write-path behavioural change (touches the path iterated on across
#9/#17/#27/#38/#54): power now targets the vendor /power/vs/0 instead of
the OCF /power/0, and _is_on() reads vendor-first. /power/0 is absent on
several known AC boards, so the previous OCF-first read/write pair could
report and act on stale state -- consistent with #53's "can turn on but
not off". Target temperature now picks its channel (OCF pair vs. vendor
items[]) based on which the unit actually reports, reading and writing
the same one.
The vendor temperature write and the new display-light write both carry
only the changed field(s), not the whole resource -- confirmed sufficient
on the wire, the device merges the rest itself. That requires the
coordinator's optimistic cache to do the same merge on the read side so a
setpoint change doesn't blank out current/min/max/unit for the settle
window: common.py gains merge_items_field() next to the existing
merge_options_field(), and async_send_command wires it in for any write
touching x.com.samsung.da.items.
New fixture/golden for the cool-only global RAC variant (TP1X_DA-AC-RAC-
01001, AI_RAC_GLOBAL_COOLONLY_3.0); display_light added to the goldens
for boards that gain the new options-based switch. Regenerated against
current main rather than copied from the original PR, since those
goldens had already shifted (current_temperature_c/humidity from issue
#75).
Some TP2X_WATERPURIFIER_20K units are coffee-capable and expose five
resources issue #90's original dump never had:
/favorite/coffee/vs/0, /favorite/hotwater/vs/0,
/brand/recipe/info/vs/0, /coffee/custom/recipe/vs/0,
/recipe/coffee/vs/0, and /recipe/coffee/deletion/vs/0.
Adds FAVORITE_HOTWATER (a switch + select pair on
/favorite/hotwater/vs/0, mirroring the existing FAVORITE_CAPACITY
pattern) and COFFEE (a switch + status sensor on
/favorite/coffee/vs/0). The remaining four hrefs are static
capability-advertisement blobs or empty on every dump seen so far --
no live "current recipe" or "current custom slot" field to expose --
so they're added to the ignored coverage list per the 'don't guess'
rule rather than modeled speculatively.
Confirmed against the issue #107 diagnostics dump with zero unbound
hrefs.
WA8000T reports no oneUiVersion and used the 'WA' consumer-model
prefix, unmapped in _CONSUMER_PREFIX_TO_KEY (only WW/WD/WF/WV were
covered), so it fell into the unknown-device-type fallback.
Adding a bare 'WA' entry collided with the unrelated '_WAC_' (Window
Air Conditioner, issue #87) board-family token: some devices report
description == modelNum, so 'WAC' shows up as its own description
segment and 'WAC'[:2] == 'WA' matched the new washer prefix before the
more specific '_WAC_' modelNum check ever ran. Fixed by reordering
for_device_by_model() to check board-family modelNum tokens first and
the fuzzier 2-letter consumer-model-prefix scan only as a fallback,
rather than patching the prefix matching itself (an earlier attempt --
requiring a digit immediately after the prefix -- broke issue #79's
real DVE50A8800 case, which has no digit there either). Added a
regression test pinning the WAC/WA disambiguation directly.
Confirmed against the issue #106 diagnostics dump with zero unbound
hrefs.
Renames DeviceRegistry.name from 'cooktop' to 'gas_cooktop' for the
NA9300K-class gas-cooktop registry (PR #23), so diagnostics/device-info
labels no longer collide with the unrelated induction_cooktop family
(issue #86) -- two different OCF surfaces that happen to share the
English word "cooktop".
Safe rename: _REGISTRY_BY_KEY's 'cooktop' lookup key is unchanged, so
all three existing detection paths (oneUiVersion "Cooktop" exact
match, the legacy ARTIK051 modelNum rule, and the resource-signature
fallback) keep routing real devices exactly as before. Entity
unique_ids are built from device serial + entity key, not registry
name, so existing entities are unaffected. Only the DeviceInfo.name
and diagnostics device_type strings change, both cosmetic.
The merge of main into this branch dropped PROBE_STATUS's closing
),\n) and glued issue #74's COOKTOP_MONITORING addition directly onto
its entities tuple, leaving an unclosed paren (SyntaxError on import,
breaking Pytest and Hassfest CI). Restores the closing parens; no
functional change.
PR #75 (WindFree AC) added CURRENT_TEMPERATURE/HUMIDITY capabilities
to the shared airconditioner registry, which every AC device picks up
-- including the Window AC from PR #87. Both branches built their
golden fixtures independently against their own base commit before
either landed, so neither saw the other's addition; once both merged,
the Window AC's fixture went stale. The two extra keys are real,
working sensors from #75's work, not a regression.
AIComfort isn't a distinct thermodynamic operation like Cool/Dry/Heat --
it's an AI-driven overlay on top of the device's own 'Auto' behavior,
confirmed by A-CAWW-TP2-20-COMMON reporting both 'Auto' and 'AIComfort'
as separate, mutually-exclusive entries in /mode/vs/0's supportedModes.
Modeled the idiomatic HA way instead of a flat _DEVICE_TO_HVAC entry:
hvac_mode reports AUTO and a new 'ai_comfort' preset carries the
distinction. Entered/left only via the preset (writes the primary mode
resource, not the convenient one) -- there's no dedicated HVACMode
value for it, so it's not offered in the hvac_mode dropdown directly.
Also adds a once-per-(href, code) warning log when a device-reported
mode has no entry in the relevant map, so a future gap like this one
surfaces in the log instead of silently vanishing -- the exact failure
mode issue #93 called out ("this class of gap is invisible without
diffing against supportedModes").
A-CAWW-TP2-20-COMMON (and likely other CAWW/TP2X-class boards) reports
'AIComfort' as a distinct entry in /mode/vs/0's supportedModes,
alongside 'Auto' (already mapped to HEAT_COOL). _read_modes() silently
drops any code missing from _DEVICE_TO_HVAC, so AIComfort was
unreachable -- one of HA's five other AC hvac_modes, unused by this
device family until now.
The other two gaps in issue #93 are already addressed elsewhere and
not duplicated here:
- Fan-only via the 'Fan' device code, and the WindFree/LongWind/
NanoSleep preset codes, are covered by the still-open PR #91, which
replaces the static preset table with a fully dynamic resolver over
the device's own supportedModes.
- The 'Left_And_Right' -> horizontal swing mapping is already on the
still-open claude/device-support-issue-75-windfree-ac branch.
The TP2X_WATERPURIFIER_20K water purifier reports no oneUiVersion and
its modelNum/description don't match any consumer-prefix or existing
board-family token, so it fell into the unknown-device-type fallback
with only common capabilities. Add a new water_purifier registry,
routed via a 'WATERPURIFIER' modelNum/description fallback rule.
Models dispense settings (type/temperature/capacity/pouring status),
sterilize and filter status, favorite-capacity presets, and the three
water/buzzer locks. Per the adding-device-support skill's "never
hard-code the one dump's values" rule: dispense-capacity bounds and
step come live from the device's own desiredCapacityRange/
capacityResolution fields (range_field/step_fn), not a hardcoded
constant, and the hot-water-temperature control is a select over the
live supportedHotTemperatures list rather than a number with invented
bounds, since only a few discrete temperatures are selectable.
/mode/vs/0 and /automation/waterpurifier/vs/0 are left unmodeled: the
former carries an opaque wizard-workflow token with no coherent
current-value contract, the latter is a static support-flags blob with
no live setting to expose.
Confirmed against the issue #90 diagnostics dump with zero unbound
hrefs. Updates the README's supported-appliance-types table for the
new device type.
The AY18CG7500GED dehumidifier (modelNum TP1X_DA_AC_DHM_01001_0000)
shares the DA_AC_ board family with the room-AC models but carries the
'_DHM_' token instead of '_RAC_'/'_PRAC_'/'_WAC_', so it fell into the
unknown-device-type fallback. Add a new dehumidifier registry, routed
via a '_DHM_' modelNum fallback rule, distinct from airconditioner
since target humidity (not temperature) is the primary control and
there's no climate composite.
Reuses airconditioner.py's AUTO_CLEAN/AIR_FILTER/MUTE_ONCE capabilities
directly (identical resource shapes on the shared board family). Adds
a new humidity sensor + target-humidity number pair and an
operating-mode select. Per the adding-device-support skill's
"never hard-code the one dump's values" rule, the target-humidity
number has no hardcoded min/max (falls back to HA's own 0-100 default
for a percentage field) and reads its step live from the device's own
`increment` field rather than a spec-sheet-derived constant. The
operating-mode select's options come live from supportedModes.
/mode/convenient/vs/0 is left unmodeled: only supportedModes is present
on this dump, with no live current-value field to confirm a read/write
contract.
Confirmed against the issue #88 diagnostics dump with zero unbound
hrefs. Updates the README's supported-appliance-types table for the
new device type.
The AW06C7155EWAZ window air conditioner (modelNum
TP1X_DA_AC_WAC_01001_0000) reports no oneUiVersion and uses the '_WAC_'
(Window Air Conditioner) modelNum token instead of the '_RAC_'/'_PRAC_'
tokens already handled by for_device_by_model. Add a fallback rule for
that token, routing it to the existing airconditioner registry.
The device's resource surface (mode/convenient/wind/temperature/power/
filter/humidity) is already fully modeled by the airconditioner
capability set, so this is purely a detection-routing fix -- confirmed
against the issue #87 diagnostics dump with zero unbound hrefs.
TP1X_DA-KS-COOKTOP-01011 (NV8500T-/KO4) is the same board family and
/cooktop/status/vs/0 resource shape as issue #44's range combo, minus
the oven -- but its modelNum uses the hyphenated '-COOKTOP-' token,
which for_device_by_model's existing '_COOKTOP' check (underscore-
delimited, matching the unrelated NA9300K gas-cooktop family in
cooktop.py) doesn't match. The device fell back to 'unknown' with only
energy/alarms from the global fallback.
Add the hyphenated token check, routing to a new 'induction_cooktop'
registry (by_type/induction_cooktop.py) that reuses range.py's
COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS and
cooktop.PAIRED_HOOD_STATUS wholesale rather than pulling in range.py's
oven capabilities, which this device has no hrefs for at all.
Along the way, three fields the reporter asked for turned out to be
gaps in the shared range.py capability itself, not just missing
routing -- also present (and previously unmodeled) on issue #44's
original combo-range dump:
- /cooktop/status/vs/0's own `power` and `childLock` fields (distinct
from common.POWER's /power/0 or /power/vs/0, which a combo range
additionally carries for the whole appliance) -- childLock gets a
write_fn (a safe lock toggle, direct single-field PUT), power stays
read-only (no live device to confirm a remote write wouldn't leave a
burner active unattended).
- Each burner's `panDetection` field.
New: a Bluetooth meat-probe capability for /bluetooth/probe/status/vs/0
(read-only -- connection, battery, current/target temperature), and
/cooktop/recipe/status/vs/0 is ignored (every field empty on this idle
dump, same treatment as the microwave family's /recipe/cook/vs/0).
Regenerates the range golden fixture (gains cooktop_power/
cooktop_child_lock/burner_N_pan_detected) and adds a dedicated fixture
for the standalone cooktop.
The ARTIK051_DONGLE_REF firmware family reports the literal string
"Nothing(SVC)" as serialNum on every unit -- non-empty, so the existing
`if not serial` checks in config_flow.py's _probe_and_validate and
coordinator.py's _run_discovery don't catch it. Two such appliances on
one install (a fridge and a freezer, each its own dongle) then collide:
config_flow gives both the same unique_id and rejects the second as
"already configured" (bug 2), and even once that's worked around,
device_serial feeds every entity's unique_id too, so the second
appliance's entities get silently dropped with "does not generate
unique IDs" log lines (bug 4).
Add _is_placeholder_serial (duplicated in both modules rather than
imported, to avoid pulling config_flow into the runtime coordinator's
import graph or vice versa for a two-line check) and treat it the same
as an empty serial: fall back to host/port.
Type detection (bug 1) and the door sensor's field-name gap (bug 3),
also reported in this issue, are already fixed via the
claude/device-support-issue-77-freezer branch, which hit the same
ARTIK051_DONGLE_REF family from a different report -- not duplicated
here.
Five raw course codes were rendering unlabeled because no translation
entry existed for them, confirmed by the reporter selecting each cycle
on the physical appliance and reading back the raw code from the
entity's state:
- washer_cycle_table_02: '52' Eco Cold, '54' Towels, '60' Self Clean+
(a WF50A8600AV/US). '54' shares a display name with the existing '24'
Towels -- a different code on the same table legitimately landing on
the same label, matching the existing '21'/'65' Colors and
'27'/'5E' Rinse+Spin pairs, not a duplicate-in-error.
- dryer_cycle_table_03: '01' Normal, '06' Time dry (a DVE50A8600V/A3,
the same model added in the previous commit's detection fix).
No code changes -- select.py already derives which raw values it
normalizes from the shipped catalog, so labelling a code is purely a
translations/en.json (mirrored to nl.json) addition.
DVE50A8600V/A3 reports description
'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080' -- a paired
listing of two related model numbers (DVE50A8800 and DVE50A8600) joined
by an underscore, rather than the usual single trailing consumer-model
token. for_device_by_model only ever checked the literal last
underscore segment ('8600', which has no recognizable 2-letter prefix
on its own), so the real 'DV' token one segment earlier was never
reached and the device fell back to 'unknown' with only common
capabilities -- no dry level, cycle, or wrinkle-prevent entities.
Replace the single last-segment extraction with _consumer_model_key,
which scans segments from the end and returns the first one that
resolves. Behavior is unchanged for every existing single-token
description (the last segment still matches first); it just keeps
looking when that segment doesn't.
RR40M7165WW is the fridge half of the same household dongle setup as
issue #77's freezer -- identical pipe-delimited ARTIK051_DONGLE_REF
modelNum, so the previous commit's detection and door-sensor fixes
already cover it with no further code changes. Add its fixture as a
second, independent regression case: it exercises the 'cooler' instance
segment instead of 'freezer' for both the temperature pattern caps and
DOOR_GENERIC, and notably reports /door/onedoorfreezer/vs/0 despite
being a single-door fridge (shared firmware naming across the product
line, not an actual second compartment) -- worth having its own golden
so that stays working too.
RZ32M713EWW/EE (an ARTIK051-dongle standalone freezer) reports no
oneUiVersion and a pipe-delimited modelNum
('ARTIK051_DONGLE_REF|<rest>') -- REF is the last underscore segment
before the pipe, not wrapped in underscores on both sides like the
'..._REF_...' shape for_device_by_model's substring check expected, so
the device fell through to 'unknown' with only common capabilities:
no door sensor, no temperature sensors, nothing fridge-specific.
Replace the substring check with a segment-based one
(_model_num_segments splits the pipe-delimited prefix on '_') that
catches both shapes. Same root cause, same fix, and same device family
independently reported and root-caused in issue #83 (which also covers
two further bugs -- config-flow/coordinator serial collisions on a
second symptom of this firmware, 'Nothing(SVC)' as a literal serial --
not needed here since this reporter has a single unit; left for #83).
Once routed to the refrigerator registry, fridge.py's existing pattern
capabilities pick up /temperature/current/freezer/0 and
/temperature/desired/freezer/0 for free -- they were never the actual
problem, just unreachable under the 'unknown' fallback (which never
tries pattern capabilities at all). The door sensor needed one more
fix: /door/onedoorfreezer/vs/0 reports the vendor-prefixed
x.com.samsung.da.openState, not the bare openState DOOR_GENERIC read,
so the entity existed but stayed permanently unavailable. Check both
field names via rep_fn.
Three gaps reported against an ARTIK051_PRAC_20K WindFree unit vs. the
SmartThings integration:
1. Missing WindFree/motion convenient-mode presets -- left alone here.
PR #91 replaces climate.py's static _DEVICE_TO_PRESET table with a
generic resolver that reads any preset code straight off the unit's
own supportedModes, which already covers this (and more generically
than a per-model dict would) -- adding one here would just conflict.
2. No horizontal oscillation: /wind/direction/vs/0's supportedModes
includes Left_And_Right, which _DEVICE_TO_SWING had no mapping for.
Add it to HA's standard 'horizontal' swing constant.
3. No standalone humidity/current-temperature sensors: the climate card
already reads both internally, but nothing exposed them as entities
for history/automations. Add CURRENT_TEMPERATURE (OCF
/temperature/current/0) with a CURRENT_TEMPERATURE_VS vendor fallback
(same match_fn-gated pair shape as common.py's POWER_GENERIC/
POWER_VS_FALLBACK), and a HUMIDITY sensor reading /humidity/vs/0's
fivepercentHumidity field -- the only one of the three
humidity-shaped fields across /humidity/0 and /humidity/vs/0 that
isn't permanently stuck at 0 on every dump seen.
Regenerates the five existing AC goldens (all pick up
current_temperature_c; most pick up humidity) and adds a dedicated
fixture from the issue's WindFree dump, whose /humidity/vs/0 actually
has live fivepercentHumidity data.
NE63B8411SS reports no oneUiVersion and no /information/vs/0 resource at
all, so neither for_device nor for_device_by_model's modelNum tokens have
anything to key off -- it fell through to the unknown-device fallback,
which also mis-binds /temperatures/vs/0 against fridge.py's setpoint
capability (a collision the oven family's own capabilities are normally
excluded from the global registry specifically to avoid).
Add a resources-based signature to for_device_by_resources(): 'Bake' in
/mode/vs/0's supportedModes is oven/range-exclusive vocabulary, and paired
with the /oven/vs/0 cavity resource it reliably identifies this family
even with no model info at all. Route to 'range' when a cooktop-status
resource is also present, else plain 'oven'.
This board's cooktop half also doesn't expose the per-burner
/cooktop/status/vs/0 array range.py already models -- only the coarser
/cooktopmonitoring/vs/0 summary resource. Add a read-only
COOKTOP_MONITORING capability for it (cooktop running state, warming
center state) rather than leaving it unbound.
Since entity.py started routing named descriptors through the catalog
under desc.key, SamsungEntityDescription.name has been read for its
value nowhere -- only twice as a flag, to decide whether an entity was
translated at all. That left 148 English names duplicated between Python
and translations/en.json with nothing keeping them honest: six had
already drifted, invisibly, because editing the Python side changes
nothing a user sees.
So the field is gone, and translation_key defaults to desc.key. A
descriptor now sets translation_key only to share one catalog entry
across descriptors or to point at a differently-named one, and the
catalog is the only place an entity name exists.
Every descriptor resolves to exactly the translation key, icon, entity
category, enabled-default and gating it did before -- with one
deliberate exception: the hood fan, previously the sole descriptor with
no key at all, now resolves to 'fan'. That is inert, because fan.py sets
_attr_name = None so the entity presents as the device itself.
The three helpers that forwarded a name into a descriptor
(laundry.bool_option_switch, washer._bool_option_switch, air_purifier's
sensor table) lose that parameter. test_translations.py now requires a
catalog entry for every descriptor rather than only translated ones.
Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
PR #68 restated its own translation data in Python: a 60-line
TRANSLATED_SELECT_STATES table of frozensets duplicating every
entity.select.*.state key, a second _TRANSLATED_COURSE_TABLES table
naming which course tables have translations, and a strings.json that
was a 835-line byte-for-byte copy of translations/en.json save 43
[%key:...%] references. Each needed hand-syncing, and one was already
drifting.
Home Assistant loads exactly one file per language for a custom
integration -- translations/<lang>.json. It never reads strings.json and
never resolves [%key:...%]; both belong to Core's build tooling, which
custom integrations don't run through (hassfest skips a missing
strings.json and validates translations/en.json instead). So en.json is
the source, and the new catalog.py reads the keys and states back out of
it for the two decisions Python genuinely has to make:
- select._display() normalizes a raw Samsung option to a lowercase
state key only when the catalog knows it, else leaves the vendor's
casing alone. Derived sets are identical to the removed literals.
- laundry.cycle_select() keys off a device-reported course table only
when that table has an entry, else falls back to the name-only
'cycle' key. Translating Table_00 is now a translations-only change.
Also fixes six names that had already drifted between the Python
descriptors and the catalog, restoring HA's sentence case for two
generic ones (Auto release dry, Bubble soak) and taking the catalog's
wording for the rest, and adds a test so the vestigial descriptor names
can't silently disagree with the UI again.
Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
Six fridge SwitchDesc write_fns built their payload with
`'On' if p else 'Off'`. The switch platform passes the literal
string 'Off' on turn-off, which is truthy, so the guard always
produced 'On' -- turning these switches off silently re-sent On
and they could never be turned off:
- ICEMAKER_NIGHTTIME (ice.night.status)
- STATUS_LOCK helper (devicecontrol + device.sound)
- DEFROST_DELAY (delayDefrost)
- WELCOME_LIGHTING (status)
- CABINET_LIGHT dim (light.dimming.status)
- ICEMAKER_STATUS_FALLBACK (iceMaker)
Compare `p == 'On'` instead, matching the pattern the other
capability files already use. Adds a regression test asserting
every affected write_fn sends 'Off' on 'Off' and 'On' on 'On'.
Power users can now pick a resource href from a live dropdown, view its
current value, and POST a minimal patch straight to the device -- to pin
down device-specific write behavior without waiting on a new release.
Bypasses the remote-control block and all write_fn/validate_fn logic by
design; the existing remote-control settings toggle moves behind the same
options-flow menu.
Simplification (feedback: this was overcomplicated): drop the
validated_table gate entirely. cycle_select's table_href now just builds
the translation key directly from whatever course table the device
reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of
comparing against a hardcoded known-good value and falling back to no key
on any mismatch. A table we haven't shipped translations for yet (e.g.
FlexWash's Table_00) still gets a key built for it -- Home Assistant's own
missing-translation handling takes it from there, the same graceful
fallback already relied on for any individual untranslated code within an
existing table. Adding a newly-confirmed table later is just new
strings.json entries, no code change.
Independent (Opus) review of the prior version caught two real issues,
fixed here regardless of the simplification above:
- translation_key was resolved once at entity construction from whatever
coordinator.last_resources held at that moment. Discovery can run while
a sibling resource is still an empty stub (documented precedent: see
_is_included), so a callable translation_key could permanently bake in
a stale value for the entity's lifetime. Moved resolution into a
translation_key property override (Entity.translation_key is a property
upstream, not a plain attribute), re-evaluated against live coordinator
data on every access, matching how options/current_option already work.
- The supportedOptions fallback's "smallest passing K wins" docstring
claimed every larger passing K is an exact multiple of the true one.
False: the shipped dishwasher fixture has passing K=7 (true) alongside
10, 14, and 35, none of which are multiples of 7 -- position 0 always
lands on the same real course code regardless of K, which alone
satisfies the current-course guard for several unrelated splits.
Corrected the reasoning to what's actually true (an empirically-matched
heuristic across six real dumps, not a proof) and added a regression
test locking in the real dishwasher case so this isn't silently lost.
Course codes on the shared /course/vs/0 contract aren't guaranteed
consistent across board generations: washer/combo devices report course
table Table_02, dryer devices Table_03 (x.com.samsung.da.st.courseTable,
previously fully ignored), and every code in washer_cycle/dryer_cycle was
confirmed exclusively against those. FlexWash's older DA_WM_A51 board
reports Table_00 instead -- applying the same translations there risked
showing a wrong name for any code that happens to numerically collide
between tables, not just an untranslated one.
SelectDesc.translation_key can now be a callable (resources -> key or
None), mirroring the existing pattern for `options`. laundry.cycle_select
gains optional table_href/validated_table params: when given, the renamed
washer_cycle_table_02/dryer_cycle_table_03 keys only apply when the
device's own course table matches exactly -- a different table, or no
table id at all, gets no translation_key (raw code display) rather than
a guess. dishwasher's call site is unchanged (static key, unconditional):
no equivalent table-id resource exists in any dump seen, and no evidence
its course codes vary by table the way washer/dryer's do.
entity.py and select.py resolve a callable translation_key once (via
coordinator.last_resources) and reuse that resolved value everywhere
_display() needs it, rather than re-checking the raw descriptor field.
Some DA_WM_TP1/TP2-class boards populate /wm/editcourse/vs/0 without ever
filling in editCourseList itself (issue #1), so the Cycle select never gets
created even though the device clearly has one (confirmed via SmartThings
app screenshots and a currently-selected course).
/course/vs/0's own x.com.samsung.da.supportedOptions turns out to already
carry the course list, just undocumented: a 1-hex-nibble header followed by
one fixed-width record per course, self-indexed by a course-code first byte
rather than positional like editCourseList. Confirmed against six
independent real-world dumps pulled from open and closed GitHub issues.
cycle_options() now falls back to deriving this when editCourseList is
empty, gated on two checks: the derived codes must all be distinct, and
must include whatever course is currently selected. Larger multiples of
the true record width trivially re-pass both checks too (they're just a
sparser sampling of the same table), so the smallest passing width wins
rather than requiring one unambiguous match.
Also fires on the washer_flexwash fixture, newly creating a Cycle select
there -- unconfirmed against any ground truth for that device (a different,
older board generation with no editCourseList and no screenshots to check
against), flagged for follow-up discussion rather than silently accepted.
Per the five running-state diagnostics dumps (Auto/Sleep/Low/Medium/High)
gathered in the issue thread:
- Blooming_* has no corresponding SmartThings app setting, so it's dropped
entirely rather than kept as an unexplained diagnostic.
- Comode_* reads 'Off' on all five, ruling out the original guess that it
was the fan-speed selector -- still exposed read-only, purpose unconfirmed.
- OptionCode_60282 and the missing humidity sensor are confirmed correct as
already modeled.
- /airflow's speed doesn't map monotonically to the five settings and the
dumps were all captured within one ~30s poll cycle of each other, so it
stays read-only pending a cleaner, time-spaced capture.
FilterProgress is untouched here: an earlier pass on this issue read the
thread as confirming 100 means "fresh" and renamed the sensor to filter_life
to match, but that reading was backwards -- the reporter clarified 100
means fully used and needs replacing, which is what filter_progress (the
already-shipped name) already implies. That rename was caught before
merging and is not part of this change.
New '-CAWW-' modelNum token for multi-indoor-unit commercial AC
installs -- these report no oneUiVersion, same as the other RAC/PRAC
boards. Once routed to the existing airconditioner registry, every
resource in the reporter's dump already binds except one new
SAC-specific installation-topology blob, now ignored.
The bypass toggle is off by default and turned ON to allow writes with
remote control reported off, but the description said "only turn this
off if..." -- backwards from the actual control. Caught in Opus review.
The remote-control-off write block was device-wide and unconditional:
whenever a device reports remote control off, every write is rejected
with a user-facing error, on the assumption the device would reject it
anyway. Issue #54 reports a washer where that assumption doesn't hold --
default detergent/softener dosing writes through even with remote
control off, since they apply to the built-in programs too, not just a
custom remote-controlled cycle.
Add a per-device options flow (Settings > Devices & Services > this
device > Configure) with a single toggle, stored in entry.options (not
entry.data) so it doesn't affect the device's identity/unique_id.
coordinator.async_send_command reads it ahead of the existing
remote_control_enabled() check; defaults to False everywhere, so devices
that don't touch this option see no change in behavior.
Widening the settle window to tens of seconds (previous commit) made a
real gap much more likely to bite: apply() gated every source,
including 'optimistic', on _is_settling. /course/vs/0 backs several
independent washer selects (cycle, detergent quantity, softener
quantity, ...), so picking a second one while the first write's window
was still open -- routine within a ~43s window -- had its own
optimistic value silently dropped from the cache instead of shown,
recreating the exact "write doesn't seem to apply" symptom the guard
exists to prevent, just for whichever write lost the race.
Let source='optimistic' always bypass the gate; poll/sweep/observe
stay gated as before. mark_write_pending still re-arms the window
right after, so the newer write is protected going forward.
async_send_command's settle guard used DEFAULT_SETTLE_S's fixed few
seconds, which is long enough for fields that update instantly on the
device but not for ones that visibly take a few seconds of internal
validation or hardware movement to catch up. Issue #9's washer packs
cycle/detergent/softener selection into /course/vs/0's shared options[]
array, and that settling time regularly outlasted the fixed window --
same device, same integration, but /washer/vs/0's temperature/spin
fields (plain flags) confirmed instantly while these didn't. The short
window expired before the confirm poll (or the device itself) caught
up, so a stale read landed unprotected and reverted the optimistic
value, only to self-correct again once a later poll saw the real
change -- reading to the user as the write reverting and then
reapplying itself a few seconds later.
Size the window to always outlast the PUT and the confirming refresh's
poll combined, as issues #17/#53 also needed, but without that fix's
early-release mechanism (reverted previously for its own races around
overlapping writes) -- just hold the guard for the full window and let
it expire on its own.
An independent review turned up the real bug behind the climate lag:
async_send_command applied the optimistic value and settle guard to
bound_entity.href, but write_fn's path_segs -- the resource actually
POSTed to -- can point somewhere else entirely. The AC's composite
climate entity is bound to /mode/vs/0, yet a power/temperature/fan/
swing/preset command writes to its own sibling resource (/power/0,
/temperature/desired/0, ...), which is also what climate.py reads the
displayed state from. The optimistic value landed on /mode/vs/0
instead, so the resource the entity actually shows stayed stale until
the next unrelated read of it -- surviving the earlier optimistic-apply
fix (issue #27), which applied to the same wrong href.
Derive the write's target from path_segs and apply/guard/log against
that instead. Reverts the previous commit's settle-window-sizing
change on this branch: that was chasing a real but speculative edge
case (a confirm poll slower than the settle window) that a follow-up
review couldn't confirm matches the reported symptom, and its
early-release mechanism had its own races (a debounced refresh that
hadn't actually run yet, overlapping writes to the same href) for
marginal benefit once this fix lands. Simpler to drop it than carry
that risk for a case not in evidence.
Added a coordinator-level regression test against the real AC CLIMATE
capability (not just a synthetic mismatched-path descriptor) sending a
power command and asserting the optimistic value lands on /power/0,
not /mode/vs/0.
The /operational/state/vs/0 href (and its start/pause/stop buttons and
"cycle active" sensor) is shared across the dryer/dishwasher/oven/washer
families, but the entity names hardcoded laundry vocabulary ("Start
cycle", "Cycle active") that doesn't fit an oven's bake/roast session.
Rename to generic "Start"/"Pause"/"Stop"/"Running".
Also fold oven.py's duplicate stop button into a single STOP_BUTTON
constant in operational.py -- both wrote the identical state='Ready'
RMW, so there was no reason for two copies to maintain.
Ovens with modelNum like TP1X_DA-KS-OVEN-0107X report no oneUiVersion
and don't match any consumer-prefix or other fallback token in
for_device_by_model, so the device came back as "unknown" and every
resource fell through to the global capability registry instead of
oven.py's own -- explaining the unbound /connected/vs/0 href, the
unrelated-looking entities, and the non-functional controls reported
in the issue. Add a '-OVEN-' modelNum token fallback, mirroring the
existing '-RANGE-' fallback from issue #44.
Rewires /mode/vs/0's packed-options parsing (display_light/operating_mode/
blooming_level) onto laundry.py's existing option_value/replace_in_options/
bool_option_exists/bool_option_value instead of hand-rolled reimplementations,
hoists the duplicated int-conversion helper into common.py (shared by
range_hood.py too), collapses the five near-identical AIR_QUALITY sensors
into a table-driven loop, extracts the /consumable/vs/0 item lookup into a
named helper, and moves the humidity ignore list into capabilities/
air_purifier.py's own COVERAGE list to match the airconditioner/range_hood
convention of keeping by_type files as pure composition.
No behavior change; golden state keys and all existing tests are unaffected.
Adds a by_type registry for the AX60R5080WD/SE air purifier family, verified
against two independent diagnostics dumps (issue #56 and its comment). Binds
power, alarms, energy, diagnosis (reusing dishwasher.DIAGNOSIS), the dust/
fine-dust/super-fine-dust/odor/clean-level sensors off /sensors/vs/0, filter
progress, a device-active diagnostic, and a display-light switch parsed out
of /mode/vs/0's packed options list.
Fan speed/direction (/airflow/0, /airflow/vs/0) and two other /mode/vs/0
tokens (Comode_*, Blooming_*) are exposed as read-only diagnostics rather
than full controls -- neither dump has a supported-values list to confirm
their write contracts, so they're left for a follow-up once that's
clarified in the issue thread.
Hoists range_hood's items[]-sensor-value helper into common.py
(sensor_item_value) since air_purifier now reads the same /sensors/vs/0
shape.
mark_write_pending's window was a fixed few seconds, started before the
PUT and the confirming /device/0 refresh that follows it -- but that
refresh is a full summary poll, which can legitimately take far longer
than that on these AC devices. The window routinely expired while the
confirm poll was still in flight, so a stale read (the device's own
resource tree hadn't caught up to the instant physical change yet)
landed unprotected and reverted the optimistic write, with nothing to
correct it again until the next scheduled summary poll. That's the
20-60s lag both issues report even after the earlier optimistic-apply
fix.
Size the window to cover the PUT and confirm-poll timeouts combined,
and release it as soon as that round trip actually completes (success
or failure) instead of leaving it open for the rest of a now much
longer window.
mark_write_pending's settle window was dropping every update for a
just-written href, including the coordinator's own post-write refresh,
because nothing ever wrote the optimistic value into the cache for it
to protect. The write reflected on the device immediately but reverted
in HA until the next 30s summary sweep.
- NumberDesc gains native_min_fn/native_max_fn/step_fn hooks (mirroring the
existing unit_fn pattern) so an entity's slider bounds can track the live
rep instead of staying pinned to whatever unit the descriptor was written
against. Oven setpoint was hardcoded to Celsius bounds (30-270), which
silently capped issue #44's Fahrenheit range at 270F -- below a normal
350F bake temp. Verified Fahrenheit bounds (175-550, step 5) come from
that dump's /mode/vs/0 Bake modeSpec.
- Wire oven.OVEN_SPEC into the oven registry, not just range -- it was only
reachable from range before, so a standalone oven reporting
/oven/spec/vs/0 would have false-tripped the coverage-gap repair.
- Fix a docstring in test_golden_regression.py left over from the
cooktop.py -> range.py rename.
PR #23 independently adds registry/capabilities/cooktop.py for an unrelated
standalone-cooktop product (NA9300K-class, burner state encoded in
/mode/vs/0's options array) -- different hardware and a different OCF
surface than issue #44's oven+cooktop combo range, but the same file path.
Rename ours to range.py to keep both mergeable.
TP1X_DA-KS-RANGE-0102X (model NSI6DG9100SRAA) reports no oneUiVersion and
previously fell through to the unknown-device fallback, leaving /connected,
/cooktop/spec, /cooktop/settings/status, /cooktop/status, and /oven/spec
unbound. Add a 'range' device registry that reuses the oven family's
cavity/setpoint/mode/operational-state/door capabilities and adds a new
cooktop.py module modeling per-burner power level, state, and hot-surface
entities (gated so unreported burner slots don't appear), plus a hot-surface
auto-shutoff config sensor. Route range/cooktop models to it via a
'-RANGE-' modelNum token, mirroring the existing RAC/PRAC air-conditioner
fallback pattern.
The dryer (DA_WM_TP1_21_COMMON) pause/stop buttons mentioned in the same
issue are working as intended -- the reporter confirmed that's an expected
in-person-only limitation, not a bug.
Move the on/off interpretation into a single remote_control_enabled()
in registry/capabilities/common.py so the write-guard added in the
previous commit can't silently drift from the Smart Control binary
sensor's own reading of the same hrefs. Also promotes both
/remotectrl hrefs to poll_tier='warm' so the coordinator's cached
state backing that write guard doesn't lag up to a full 30s cold
summary poll behind the device's actual toggle state.
Devices with a /remotectrl href already surface it as a read-only
"Smart Control" binary sensor, but writes weren't checking it before
now. async_send_command now blocks every write (any platform) with a
ServiceValidationError telling the user to enable remote control via
the appliance's manual, ahead of any per-description validate_fn.
/simplify pass on the issue #27 fix: extract a shared _snake_to_title
between entity.py and discovery.py, factor discover()'s two binding
loops through one _bind() helper, and close a TOCTOU race the cache
merge introduced -- apply() is the sole path StateCache mutations flow
through, so the read-then-write is now serialized under one lock
instead of two independently-locked calls.
Issue #27: the flex-zone/cooler-drawer select vanished after a device
stopped including supportedOptions on an update for /mode/vs/0.
ObserveManager.apply() handed reps straight to StateCache.apply_rep,
which fully replaces the cached rep -- so a partial update (missing a
field the select's exists_fn/options_field gate on) silently erased
data a fuller update had previously supplied. apply() now merges
incoming reps onto whatever's already cached instead.
Also give ice-maker entities (and any future pattern-cap instance) a
device-given display name instead of the href-derived "Icemaker
One"/"Icemaker Two": Capability.name_field lets a pattern capability
read and normalize an instance name (e.g. iceMaker.name's "CUBED_ICE")
for use as the entity name prefix, independent of the stable
key/unique_id.
CLIMATE_CONSUMED_HREFS (power, current/target temp, fan, swing, preset)
were bound as no-entity coverage capabilities with the Capability
default poll_tier='cold'. The coordinator only OBSERVE-subscribes and
sub-polls 'hot'/'warm' hrefs, so cold-tier state only refreshed on the
~30s full /device/0 summary sweep -- matching the 20-30s HA lag reported
on issue #17 despite commands landing on the device instantly. Pin them
to 'warm', same as CLIMATE's own primary href, so they get push
notifications (or warm-tier sub-polling as a poll-only fallback).
The switch and select shared a stub-time asymmetry: only the select had a
`not rep` carve-out, so an unfetched-stub rep at the moment platforms are
set up (entity creation runs once, ever) would instantiate a Select, while
flatten() re-evaluates exists_fn every poll against live data -- once the
resource populated to a single-level list, the switch's exists_fn would win
instead and feed the already-created Select a bool through their shared
'ai_energy_level' key, which isn't a valid select option.
Dropped the stub carve-out from the select's exists_fn so both sides
require real, populated data to decide the platform -- on a device that
stubs this cold-tier href on its very first poll, the entity now simply
doesn't appear until a reload, instead of appearing as the wrong widget
type. Added tests for the missing/empty-list supportedAiLevel shapes on
both widgets and a regression test locking in the stub behavior.
Consolidate the AC/power-exclusion rationale (previously spelled out nearly
verbatim in three places) down to one canonical explanation next to
common.POWER, with one-line pointers elsewhere. Dedupe the switch/select
test classes' identical _desc() lookup into one shared helper.
FIRMWARE_UPDATE and ALARMS were already copy-pasted into all 6 device-type
registries by hand; POWER/KIDS_LOCK/REMOTE_CONTROL into 5 of 6. Consolidate
into two bundles in common.py, unpacked via *common.UNIVERSAL / *common.POWER
the same way ignored.IGNORED already is:
- UNIVERSAL: ALARMS, ENERGY_METER, FIRMWARE_UPDATE (moved from fridge.py),
SELF_CHECK (moved from fridge.py), AI_ENERGY_LEVEL, and the kids-lock/
remote-control pairs. Safe everywhere -- discover() only binds a href
actually present in a device's dump, so a capability with no known
conflicting family is a no-op where the href is absent and a real,
wanted entity where it's present. This also broadens AI_ENERGY_LEVEL,
ENERGY_METER, and SELF_CHECK to device types they weren't confirmed on
before, on the same reasoning.
- POWER: just POWER_GENERIC/POWER_VS_FALLBACK, applied to the 5 non-AC
registries. Airconditioner keeps its own opt-out: its climate entity
already owns /power/0 and /power/vs/0 via a bare, no-entity claim
(airconditioner.COVERAGE), and a real power capability on the same
href would make _build() raise (a href with multiple caps requires
every cap to have rt_filter or match_fn; the bare COVERAGE cap has
neither).
Full test suite (327 tests, all 6 device-type golden fixtures) passes
unchanged -- none of the newly-broadened capabilities bind on any existing
fixture, confirming the no-op reasoning held in practice, not just theory.
Issue #40: /energy/ailevel/vs/0 was unbound on a plain washer. The
capability already existed for fridges but was gated off entirely on
single-level hardware (the common case), so it's moved to common.py
(cross-family, like fridge + washer now) and split into two entities:
a switch when supportedAiLevel has exactly one entry (aiLevel is really
just an on/off toggle there), and a select otherwise, with '0' (off)
synthesized back into the select's options since supportedAiLevel never
lists it but it's a real observed value.
Also drops the translation_key/strings.json entries -- aiLevel's raw
digit values already render fine untranslated, and translating a
handful of levels can't cover devices with more.
Addresses the reuse finding skipped in the previous /simplify pass:
washer's bubble soak/pre-wash/intensive switches and dishwasher's storm
wash/auto release dry switches were two separate implementations of the
same '<prefix>_On'/'<prefix>_Off' read-modify-write-on-options[] contract.
Moved bool_option_write/bool_option_value/bool_option_exists/
bool_option_switch into laundry.py (same module that already owns
cycle_write/cycle_select for the identical 'Course' token), and pointed
both washer.py and dishwasher.py at it. washer.py keeps only its
washer-specific per-course validate_fn, passed into the shared factory
as a prebuilt callable -- the factory itself has no opinion on validation.
No behavior change; re-verified every existing assertion (washer toggles,
dishwasher storm_wash/auto_release_dry, dosing alarms) plus all golden
state-key sets by hand against the refactored code.
/simplify pass on the bubble soak/pre-wash/intensive switches:
- Move validate_fn dispatch from switch.py into coordinator.async_send_command,
next to the existing write_fn getattr -- every platform gets validation for
free instead of switch.py hand-rolling it alone, and it avoids building the
full resources snapshot twice per write (switch.py was calling
coordinator.last_resources twice; the coordinator now snapshots once, and
only when a validate_fn is actually present).
- Collapse the four per-switch factories (write/value/exists/validate) plus
the _AVAILABILITY_FIELD side-table into one _bool_option_switch() that
builds the SwitchDesc directly, so the three call sites read as one line
each instead of six, and a typo'd prefix can no longer silently KeyError
against a separate lookup table.
- Rename _dosing_alarm_exists to _option_exists and reuse it for the new
switches too -- it was already the exact same "is this token present"
check the toggles need.
No behavior change; re-verified write_fn/rep_fn/exists_fn/validate_fn against
the same fixtures and golden state-key sets as before.
Add a validate_fn hook to SwitchDesc, checked in switch.py before dispatch
and surfaced as a ServiceValidationError so an unsupported write shows a
real error in the UI instead of the coordinator's silent log-only rejection.
Wired it into the three course-gated washer switches using their
availability bitmaps (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet),
which line up positionally with editCourseList. Turning a toggle off is
never blocked, and the check fails open whenever the course or bitmap can't
be resolved.
Also fixes a bug in _bool_option_write: it took a `p and 'On' or 'Off'`-style
truthy check, but switch.py always calls it with the string 'On' or 'Off' --
both truthy, so every write landed as 'On' regardless of intent.
A follow-up dump confirmed these ride as plain BubbleSoak_On/Off,
PreWashSetting_On/Off, and IntensiveSetting_On/Off tokens in the same
/course/vs/0 options array as the cycle select, so they're exposed as
self-gating config switches the same way other options-array fields are.
Per-cycle availability (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet)
lines up positionally with editCourseList but isn't used for gating, since
exists_fn only runs once at setup against whatever course happened to be
active then.
A washer/dryer combo user's editCourseList carries five Course_XX codes
that weren't named in washer_cycle: 36 (Wash+Dry), 37 (Air Wash),
38 (Cotton Dry), 39 (Synthetics Dry), and 1F (Intense Cold, distinct
from the existing 8F code used by non-combo models).
Rebased onto PR #36's merge (temperature fallback for /temperatures/vs/0,
display-light switch). Reconciliation, per Opus review:
- Dropped our duplicate LIGHT capability in favor of PR #36's DISPLAY_LIGHT
(same href/entity, cosmetic differences only).
- Dropped /option/muteonce/vs/0 and /selfcheck/vs/0 from the ignore list
PR #36 added -- both have confirmed, cleanly modelable contracts (a plain
On/Off field, and a documented Start/Cancel self-check action already
covered by the existing fridge.SELF_CHECK pattern), so keeping our bound
versions instead of ignoring them. This also picks up mute_once/selfcheck_*
on PR #36's own TP1X_DA-AC-RAC-01011 fixture -- regenerated its golden.
- Deduped the ~10 housekeeping hrefs both branches independently ignored.
- Added a unit test for climate.py's _temps_vs_item (the temperature-
fallback selection logic), which had no coverage -- climate.py isn't
importable in the registry-level AC tests, and the existing "no unbound
hrefs" tests only exercise binding, not the fallback itself.
Closes the coverage-gap repair for two more room-AC boards (issues #37,
- TP2X_RAC_20K reports no oneUiVersion and no '_PRAC_' modelNum token, so
its device type went unrecognized -- add a '_RAC_' fallback token to
for_device_by_model.
- Bind the resources both dumps actually populate: mute-once (switch),
display light (switch), self-check (reused from fridge.SELF_CHECK), and
the circuit-breaker current-limit setting (read-only diagnostic sensors,
since its write contract isn't confirmed from the dump).
- Ignore the rest as plumbing/opaque/unconfirmed: control-set descriptor,
keepnormalstate flag, software-reset trigger, AI Sleep handshake,
absence-monitoring stubs, remote-data-control, remote-temperature,
reservation ruleset, and the welcome-temperature tracker.
Adds scrubbed fixtures + goldens for both dumps and extends the AC
capability/golden-regression tests to assert zero unbound hrefs.
Adds two small, additive fridge capabilities:
- AI_ENERGY_LEVEL: a select on /energy/ailevel/vs/0 exposing the AI
energy-saving level, gated behind supportedAiLevel actually offering
more than one choice (previously globally ignored since every dump
seen only reported a single supported level).
- selfcheck_error: a diagnostic sensor on the existing SELF_CHECK
capability surfacing x.com.samsung.da.error from the last self-check,
for hardware that reports it.
This does not touch FLEX_ZONE, /mode/vs/0, or /icemaker/status/0.
- Parameterize FakeObserveSession.notify_on_subscribe as dict[str, Any] | None
to match the surrounding fully-typed attributes (the dict is delivered as
the OBSERVE rep to on_notification); add the typing.Any import.
- Reword the test_coordinator comment: subscribe() delivers a notification
for every href it subscribes to (when notify_on_subscribe is set), not one.
Covers the #19-#27 fridge/washer coverage-gap batch: FlexWash (WV) and
washer/dryer combo detection, the CV_FDR_ flex-zone fix (also closes#32),
the Cool Select Zone pantry select, and the new energy sensors.
The observe tests raced a background thread (5 ms wall-clock sleep)
against the coordinator reaching its resubscribe. try_enter_observe_mode
clears _notified before subscribing, so a notify only counts if it lands
between that clear and the end of the grace sleep. When the intervening
update cycle (reconnect, cache sweep, executor hops) took longer than
5 ms, every notify was cleared and the mode stayed 'poll' —
test_reconnect_from_observe_mode_resubscribes_immediately failed roughly
1 in 7 runs.
Replace the threads with FakeObserveSession.notify_on_subscribe, which
delivers the rep synchronously from subscribe() the way a real device
answers a subscription. That puts the notify inside the grace window by
construction rather than by timing. Tests that require the resubscribe to
fail now set it to None explicitly instead of relying on the absence of a
racing thread.
No production code changed; no assertion weakened, and no sleeps, retries
or timeouts added.
Claude-Session: https://claude.ai/code/session_01PUSU6tDjHjtbExtyXDPT3N
Review follow-up (Opus + /simplify) on the previous commit:
- power_energy_kwh/energy_saved_kwh/energy_last_month_kwh/
energy_this_month_kwh used a plain `field in rep` exists_fn, unlike their
siblings power_watts/energy_kwh in the same capability. Per
entity._is_included, an explicit exists_fn bypasses the stub carve-out
entirely -- an empty {} rep at platform setup (device/0 returned a
not-yet-fetched stub) would permanently drop these entities for the
session instead of picking them up once a sub-poll populates the
resource. Restore the same `not rep or ...` guard used above.
- fridge._flex_zone_current/_flex_zone_write independently rebuilt the same
supportedOptions set; factor into _flex_zone_supported.
- note in a comment that the flex-zone match assumes at most one
modes/supportedOptions overlap (true on every dump seen); add the
missing negative assertion that dry_level self-gates off on a plain
washer (was only positively asserted on the combo fixture).
Six new diagnostics dumps, six gaps closed:
- refrigerator: bind /diagnosis/vs/0 (reuse dishwasher.DIAGNOSIS -- same
shape) into the refrigerator registry; it was never wired up there,
tripping the coverage repair on any fridge that reports it (#20, #26).
- refrigerator: add PANTRY_ZONE for the Cool Select Zone pantry compartment
(/status/pantry/one/vs/0, x.com.samsung.da.mode/supportedOptions) -- same
shape as BEVERAGE_ZONE but a distinct resource/field set (#20).
- refrigerator: generalize FLEX_ZONE to identify the current mode by list
membership in supportedOptions instead of a hardcoded
CV_TTYPE_RF9000A_ prefix check. TP1X/Bespoke-class fridges use a
CV_FDR_ prefix instead, which the old code didn't recognize -- the
select existed but always read as unknown, and writing to it would have
appended a duplicate CV_FDR_ flag rather than replacing the existing one
(#26, #27; also closes#32).
- common: add energy_saved_kwh (x.com.samsung.da.cumulativeSavedPower) and
power_energy_kwh (x.com.samsung.da.cumulativeConsumption) to the shared
energy meter, plus fridge-only energy_last_month_kwh/energy_this_month_kwh
(monthlyConsumption/thismonthlyConsumption) -- all self-gating on field
presence (#26).
- by_type: add the WV consumer-model prefix (FlexWash twin washers, e.g.
WV55M9600AW) to the by-model fallback map. These report no oneUiVersion
and previously matched no prefix at all, so they fell all the way
through to the unrecognized-device registry with zero capabilities
bound (#19).
- washer: add a self-gating dry_level select to WASHER_SETTINGS for
washer/dryer combo units, which carry a writable dryLevel field
directly on /washer/vs/0 with no separate dryer resource or course
(#22).
The reset-water-filter button and "ice type vs. two named icemakers"
requests from #26/#27 are left alone -- no write contract or exclusivity
behavior is evidenced in either dump, and both icemakers report On
simultaneously on the Bespoke unit, so synthesizing a single-select would
be a guess rather than a fix.
Adds scrubbed fixtures + goldens for FlexWash, a washer/dryer combo,
ARTIK051_REF_17K, and TP2X_REF_20K, plus unit tests for the new/changed
capabilities.
- WV consumer-model prefix (FlexWash twin washers, e.g. WV55M9600AW)
wasn't in the by-model fallback map, so these units fell through to
the unknown-device registry with zero capabilities bound (#19).
- Washer/dryer combo units carry a writable dryLevel field directly on
/washer/vs/0 with no separate dryer resource; add a self-gating
select for it, off supportedDryLevel presence, so plain washers are
unaffected (#22).
Covers the air-conditioner support (#17) and the washer dosing-select /
diagnostics fixes (#9). main was still advertising 0.6.0 despite the AC work
already merging, so this moves it for the next release.
Quality-only cleanups on the air-conditioner support, no behavior change:
- climate.py: reuse common.normalize_temp_unit for the C/F unit read (also
handles the "Celsius"/"Fahrenheit" long forms); collapse the three
fan/swing/preset read properties into _read_mode/_read_modes and the three
write setters into _set_mapped, removing the copy-paste.
- Single source of truth for the climate-consumed hrefs: they lived both as
constants in climate.py and as a list in airconditioner.py. Declare them
once in airconditioner.py (HREF_* + CLIMATE_CONSUMED_HREFS, which also builds
the COVERAGE caps) and import them into climate.py, so a new sibling read
can't drift out of sync with its coverage entry.
Two fixes for issue #9 (WW90T634DHE washer):
- washer dosing selects: the four detergent/softener dosing selects read their
current value from `<Prefix>LevelCtrl_<code>` (un-padded, e.g. "3") but their
options from `Supported<Prefix>LevelCtrl_<hexpairs>` (zero-padded, e.g. "03").
HA's SelectEntity renders a select "unknown" whenever current_option is not in
options, so all four sat "unknown" (idle and running) even though every other
select worked -- which is why it was only those four. Normalize the current
value to the supported code with the same integer value so it matches an
option (and its translation); convert back to the device's native un-padded
format on write.
- diagnostics: pkg_version("smartthings-local") reads package metadata off disk
(listdir + open + read_text), tripping HA's event-loop blocking-call detector.
Offload it to the executor. Audited the rest of the package: config_flow's
socket/crypto and every coordinator DTLS call are already offloaded via
async_add_executor_job -- this was the only blocking call left on the loop.
Also documents air-conditioner support in the README (device table, capability
module list, platform list), missed when that support landed.
Updates the washer dosing tests to the corrected value/write format and adds a
current-option-is-a-valid-option regression; adds a diagnostics test asserting
the version lookup runs off the event loop.
Add support for Samsung room air conditioners (ARTIK051_PRAC-class), the
first device whose core controls map onto a single Home Assistant `climate`
entity rather than a scatter of switches/selects/numbers.
- New `climate` platform + `ClimateDesc`: one composite entity that reads
power, HVAC mode, current/target temperature, fan (wind) strength, swing
(wind direction) and the convenient-mode preset across several OCF
resources and writes back to each. On/off folds into HVACMode.OFF /
TURN_ON/OFF; convenient mode folds into preset_mode. The entity binds one
primary resource (/mode/vs/0) and reads its siblings from the coordinator
snapshot, reusing the cross-resource read pattern from number/select.
- New `airconditioner` capability module + by_type registry, routed via the
`_PRAC_` modelNum token. Reuses common ALARMS/ENERGY_METER,
fridge.FIRMWARE_UPDATE and dishwasher.DIAGNOSIS; air purify and auto clean
as config switches; air dust filter status/usage as diagnostics (usage
normalized to a percentage of rated capacity).
- Climate-consumed and all-zero/ambiguous resources (temperature/wind,
/sensors, /humidity) are declared as AC-scoped coverage so every href in
the dump binds or is covered -- no coverage-gap repair.
- Fan/swing modes map onto HA standard constants (auto-localized); the
custom fan `turbo`, presets `quiet/smart/speed`, and the filter-status
enum get translations in strings.json + translations/en.json.
- Scrubbed fixture, golden, and tests: registry routing, zero unbound
hrefs, the climate write contract, and filter-% normalization.
Clears the 'Node 20 is being deprecated' warnings by moving the actions
we pin to their current Node 24 majors (both released 2026-07-20).
hassfest@master and hacs/action@main are third-party and float on their
own branches.
The suite was never run in CI (only hassfest/HACS), and requirements-dev
listed only pytest + smartthings-local while conftest.py needs the full
Home Assistant test harness.
- requirements-dev.txt: add pytest-homeassistant-custom-component (pulls
in home-assistant + pytest + pytest-socket) and the integration's
runtime deps (cbor2, pyOpenSSL, cryptography) needed to import it.
- .github/workflows/validate.yml: add a Pytest job on Python 3.14
(current Home Assistant's floor) that installs requirements-dev and
runs the suite.
- test_config_flow.py: the UDP liveness-sweep test needs real loopback
sockets, which the HA harness blocks by default; take the pytest-socket
socket_enabled fixture so it runs instead of erroring.
Full suite: 249 passed.
Review follow-up. An explicit exists_fn bypasses entity._is_included's
empty-{} stub carve-out, so the sentinel-aware energy meter would drop
power_watts/energy_kwh when /device/0 returns a not-yet-fetched stub for
/energy/consumption/vs/0. Restore the carve-out (`not rep or ...`), and
hide power_watts when instantaneousPower is absent from a populated rep
(not just when it's the -500 sentinel) so a partial rep can't spawn a
phantom power sensor.
First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound
hrefs and an "incomplete capability coverage" repair. Handle them and,
while here, make the washer/dryer/dishwasher families consistent instead
of each carrying a bespoke variant of the same controls.
Dryer coverage:
- /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor
fallback pairs (prefer the standard OCF resource, fall back to -vs).
- /buzzersound/vs/0: new Buzzer sound select.
- /course/vs/0: cycle select shared with washer/dishwasher; ignore the
/st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0).
Consistency / de-duplication:
- Move generic OCF controls (power/kids-lock/remote-control fallback
pairs, energy meter) into common.py; every registry uses them.
- Move shared laundry controls (buzzer, job-beginning-status, and the
/course/vs/0 cycle-select machinery) into laundry.py; washer and
dishwasher stop hand-rolling their own copies.
- Energy meter is now sentinel-aware everywhere: the dead '-500'
instantaneousPower reading no longer shows a misleading 0 W (fixes it
on dryers and dishwashers, matching the earlier washer fix).
- Job-beginning-status reads x.com.samsung.da.currentStatus, the field
every dump actually carries; the dryer sensor was previously blank.
Adds a scrubbed dryer fixture, golden, and capability tests, plus an
.claude/skills/adding-device-support skill capturing the dump-reading,
OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
The config flow only probed 49154/49155, so appliances whose local
CoAP/DTLS API binds elsewhere in the ephemeral range (e.g. a dishwasher
answering on 49153) could never be added.
Add a fast UDP liveness sweep across 49152-49160 that uses the ICMP
port-unreachable / ECONNREFUSED asymmetry to find the live port(s)
before attempting the expensive DTLS handshake. Closed ports fall out
immediately; a live-but-silent port is kept as a candidate. The real
handshake then runs only against discovered ports, preferring the
historically known 49154/49155 when several look live.
Also drop the probe's /device/0 GET deadline from a bare 15s literal to
a named PROBE_GET_TIMEOUT_S constant (10s) — the slowest observed full
dump is ~8s, and 10s matches the per-resource read timeout used
elsewhere.
Bumps version to 0.5.0.
machine_state was rendering the raw lowercase OCF values (idle/active/
pause) untranslated in the UI. Add device_class=enum, options, and a
translation_key matching the existing pattern used by ice_making_status
and connection_mode, in both the shared operational.py capability and
oven.py's separate machine_state sensor.
Bump patch version for this fix plus the ice_type 'off' translation and
iot_class correction earlier on this branch.
The refrigerator fixture's x.com.samsung.da.iceType.supported list
includes "Off" alongside the whiskey_iceball_* values, but strings.json
only translated the whiskey_iceball options, leaving "Off" to render
untranslated in the ice-type select.
Coordinator prefers CoAP OBSERVE push notifications (observe.py) over
polling when the device supports it, falling back to polling only when
observe mode isn't available or drops.
Add missing washer support to the appliance table, fix stale test-count
and repo-layout claims, trim protocol-internals jargon that belongs to
the smartthings-local library rather than this integration, and point
"Adding a new appliance type" at HA's own diagnostics download instead
of a gitignored local script.
Assumes the LevelCtrl code scheme is None/Low/Medium/High (00-03) on both
dispensers -- code 00 has no on-screen equivalent in the app's 3-choice
Faible/Moyen/Élevé picker, assumed to be what "Activation" off collapses
to -- and Level2Ctrl is Soft/Medium/Hard for detergent water hardness,
1x/2x/3x for softener concentration. detergent_quantity and
softener_quantity share one translation_key (same vocabulary), same
pattern as fridge.py's shared 'brightness_level' key.
Not cross-device verified: only one dump + screenshot set (issue #9) to go
on, and the softener concentration reading doesn't cleanly match its
screenshot (assumed to be a setting changed between dump and screenshots,
not a different code scheme -- see the comment in washer.py).
progress_percentage lacked the active-state gate already applied to
progress/cycle_active/finish_time, so it kept showing a stale device value
(e.g. 1%) while idle -- now zeroed the same way. Shared by dryer/dishwasher/
oven via operational.py's OPERATIONAL_STATE.
Also exposes detergent/softener auto-dispense quantity, water hardness/
concentration, and low-reservoir alarms from /course/vs/0's options array,
using the same decode/RMW helpers already used for course selection and
drum-clean tracking. Gated by exists_fn so washer models without these
fields (e.g. the existing test fixture) are unaffected.
Water consumption is unaffected -- common.WATER_METER is already wired
into the washer registry; this reporter's device just doesn't expose
/water/consumption/vs/0.
Washer (#6): instantaneousPower is a dead sentinel ('-500') on every
TP1-class washer dump collected so far, and cumulativePower is absent
outright on at least one model. WASHER_ENERGY_METER now hides both
sensors instead of showing a misleading "0 W"/perpetual "unavailable".
Fridge (#7): temperature sensors/setpoints hardcoded '°F', ignoring the
unit each device actually reports per-reading -- fixed via a new
unit_fn hook read live from the resource. Also corrects
DEFROST_BLOCK_STATUS's polarity (DEFROST_BLOCK_ON means actively
defrosting, not "blocked", confirmed against live dumps) and adds
REFRIGERATION_FALLBACK for /refrigeration/0, closing the last unbound
href surfaced by issue #7's diagnostic dump.
Oven: applies the same live-unit-reading fix defensively to
OVEN_SETPOINT, which shares the same aggregate resource shape.
2026-07-15 11:41:21 -05:00
377 changed files with 93311 additions and 3464 deletions
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
> ### Where things live
>
> This project split into two repos partway through development.
>
> - **[`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local)** (PyPI package): the reusable protocol layer. DTLS session handling, CoAP wire encoding, Block2 reads, bounded retry/retransmit, inter-request rate limiting, cert-chain validation. No HA dependency; usable from any Python project.
> - **This repo**: the Home Assistant integration built on top of it. Config flow, a per-device-type capability registry, the polling coordinator, and all the HA entity classes.
>
> `custom_components/localthings/manifest.json` pulls in `smartthings-local` from PyPI like any other HA integration dependency.
This integration uses the [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) library to handle the low-level DTLS/CoAP communication with devices.
### What you get
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's `oneUiVersion` and picks the matching capability registry (dryer, oven, dishwasher, refrigerator) on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's identity and picks the matching capability registry on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
Credential setup is one-time. The first device you add asks for the AC14K_M CA cert and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
Credential setup is one-time. The first device you add asks for a CA certificate and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
Your state stays on your LAN: HA talks to the appliance over a direct DTLS session, and Samsung's cloud sees nothing from this integration. (The appliance itself still maintains its own connection to Samsung; that's firmware behavior on the device side, not something this integration controls.)
| Dishwasher | `by_type/dishwasher.py` | Power, kids lock, remote control, alarms, energy + water meters, water filter, operational state, cycle options/settings, door LED, sound mode/volume, firmware-update sensor |
| Refrigerator | `by_type/refrigerator.py` | Power, kids lock, remote control, alarms, energy meter, water filter, status lock, door alert, icemaker (nighttime + generic per-compartment), flex zone, refrigeration mode, autofill, welcome/cabinet lighting, Sabbath mode, beverage zone, plus pattern-matched per-compartment temperature/setpoint/icemaker/door capabilities for multi-cavity fridges, firmware-update sensor |
| Type | Registry |
|---|---|
| Air conditioner | `by_type/airconditioner.py` |
| Air purifier | `by_type/air_purifier.py` |
| Dehumidifier | `by_type/dehumidifier.py` |
| Dryer | `by_type/dryer.py` |
| Oven | `by_type/oven.py` |
| Microwave | `by_type/microwave.py` |
| Gas cooktop (read-only burner status) | `by_type/cooktop.py` |
| Range hood | `by_type/range_hood.py` |
| Range | `by_type/range.py` |
| Dishwasher | `by_type/dishwasher.py` |
| Refrigerator | `by_type/refrigerator.py` |
| Washer | `by_type/washer.py` |
| Water purifier | `by_type/water_purifier.py` |
| Vacuum clean/auto-empty station | `by_type/vacuum_station.py` |
| Air dresser | `by_type/air_dresser.py` |
Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with.
Other Tizen RT / DAWIT-family appliances almost certainly speak the same protocol underneath, since the auth path and CoAP primitives are shared across the fleet. Adding a new type means writing a new `by_type/<name>.py` registry file; it doesn't require reverse-engineering the protocol again. See **Adding a new appliance type** below.
@@ -39,23 +63,17 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
```
-`49154/udp` or `49155/udp` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. The config flow probes both ports automatically, so you don't need to know which one your device uses.
-Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow probes the whole range and auto-detects the live port, so you don't need to know which one your device uses.
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
---
## Part 2: One-time setup, get the AC14K_M CA credentials
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA: a cert chain that's been public for years and still ships in current Samsung firmware trust stores. It's required because every Samsung Tizen/RT-OCF appliance's factory ACL grants full CRUDN access (`perm=31` on `href=*`) to whatever identity is chained to that CA, so a cert signed by it is the one thing that lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA — a cert chain that's been public for years and still ships in current Samsung firmware trust stores. Every Samsung Tizen/RT-OCF appliance trusts identities chained to that CA with full access by default, so a cert signed by it is what lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
This repo doesn't include the needed CA bundle. For an example of how to obtain it, including fetching the AC14K_M cert and key and verifying they pair, see the `smartthings-local` protocol project's [`setup_cert.py`](https://github.com/QuiteYellow/SmartThings-Local/blob/main/setup_cert.py). However you obtain the CA cert and key, paste their PEM contents into the HA config flow's "CA Certificate (PEM)" and "CA Private Key (PEM)" fields in Part 3. You only need to do this once, since every appliance you add afterward reuses the same stored CA.
### Why this works
- Every Samsung Tizen/RT-OCF appliance has a factory-baked ACE in `/oic/sec/acl` granting the AC14K_M-chained identity `perm=31` on `href=*`.
- TizenRT iotivity derives the peer ID via `memmem(subject_dn, "uuid:")`, which is RDN-agnostic, so a cert with the UUID in any RDN authenticates the same way.
- You don't need the original keyholder's private key. The config flow mints its own key and has `AC14K_M` sign the leaf: different key, same identity, same access.
---
## Part 3: Add the integration in Home Assistant
@@ -64,10 +82,86 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, probes ports `49154`/`49155`, and confirms the device answers`/device/0`. On success it creates the config entry and detects the device type automatically.
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
5. The flow sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and`/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
Entities appear under one HA device per appliance, named `Samsung Appliance (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name.
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on the appliance's own OCF device ID, not its name. (Some Samsung models ship the same serial number on every unit of a model, so the serial can't tell two of them apart -- the OCF device ID can.)
---
## Part 4: Per-device settings
Each device has its own **Configure** option in Settings > Devices & Services, under **Device settings**:
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
- **Remember modes the device reports but doesn't advertise** — some firmware reports a current mode it never lists as supported. Issue #327's air conditioner sits in `Quiet` while offering only `Off/Sleep/Speed/Nano/NanoSleep`, so Home Assistant showed the preset as active but refused to select it. LocalThings remembers any such mode it sees and keeps offering it afterwards, stored on the config entry so it survives a restart — the device only names the mode while it is in it, and you shouldn't have to reach for the physical remote after every reboot. Defaults to on. Turning it off offers only what the device advertises, without discarding what was already learned.
The same **Configure** menu has a **Forget remembered modes** step, which clears what has been learned for that device. Use it if a mode was learned that turns out not to be selectable — otherwise, by design, it stays forever.
---
## Part 5: Reading and writing resources directly
Two HA actions, `localthings.write_resource` and `localthings.read_resource`, talk to a device's OCF resources directly instead of through this integration's entity model. They exist for two overlapping jobs: pinning down a device-specific write contract (the reverse-engineering work `docs/investigations/` and the provenance comments throughout `registry/capabilities/` are all about), and driving a resource this integration doesn't model as an entity yet, without waiting on a release.
Both take a `device_id` (a device picker filtered to this integration) and resolve to exactly one appliance — a target that expands to more than one LocalThings device is rejected rather than silently fanned out across all of them. `href` is always canonical (e.g. `/mode/vs/0`); if the device you targeted is a subdevice — an oven's second cavity, an AC's second indoor unit — it's translated to the real on-the-wire href for you (`/mode/vs/1`, say), and the response reports both forms so there's no ambiguity about what was actually sent.
`write_resource` exists because a single write, one at a time, isn't enough to probe some boards. Issue #300's Samsung wall oven answers `2.04 Changed` to a settings write while idle and then silently reverts it — the write only sticks once a cycle is already running. Finding what actually triggers a cycle needs an *ordered sequence* of writes to different resources, with real delays between them, and a way to check afterward whether anything actually held:
```yaml
action:localthings.write_resource
data:
device_id:abc123...
writes:
- href:/mode/vs/0
payload:
x.com.samsung.da.modes:["Bake"]
settle:5
- href:/operational/state/vs/0
payload:
x.com.samsung.da.state:"Run"
verify_after:30
```
Mind the shapes: what you write is sent verbatim, so the field names and types have to be the ones that resource actually uses. `/mode/vs/0` takes `modes` as an *array* on this board; a bare string, or the singular `mode`, is a different field the device will simply ignore. `read_resource` (below) with no `href` is the quickest way to see the real shape of everything before you write to any of it.
Each write in `writes` (1-10 of them) needs `href` and a non-empty `payload`, sent verbatim as a partial-rep POST — this bypasses the remote-control-off block and every `write_fn`/`validate_fn` a normal entity write goes through, and sends exactly the fields you give it, so it can misconfigure your appliance if you get it wrong. `settle` (0-30s, default 0) is how long to wait *after* that write before starting the next one.
By default the whole sequence holds the device session from the first write to the last, settle delays included, so a routine poll or another entity's write can't land between two steps and blur which write the appliance was reacting to. The cost is that nothing else on that device updates until the sequence ends — up to 10 × 30s if you ask for the maximum of both. Set `hold_session_lock: false` to take the session per write and release it across the waits instead, trading that certainty for a device whose entities keep updating throughout.
The response has one `results` entry per write, with `before`/`after` reps and a `changed` flag (every key/value in `payload` present and equal in the immediate readback):
`verify_after` (0-60s, default 0, omit to skip) is what actually answers the "did it stick" question: after the sequence finishes, it waits that long and then re-reads every distinct href the sequence touched, reporting the result under `verified`, keyed by canonical href. `changed` tells you the write was accepted and reflected immediately; `held` tells you whether it was still there N seconds later, or whether the board quietly put it back — issue #300's exact symptom. Where an href was written more than once in a sequence, `held` compares against the *last* payload sent to it. A `held` of `null` means the re-read itself didn't come back (check `code` next to it) — unknown, deliberately not reported as a revert.
If the session drops partway through a sequence, the action raises rather than returning, and the error names how many writes completed and which — the appliance is left holding a partial sequence, so knowing where it stopped is the difference between a usable result and starting over blind.
`read_resource` is the read half, and it's deliberately not just a cache lookup:
```yaml
action:localthings.read_resource
data:
device_id:abc123...
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. 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.
---
@@ -80,20 +174,21 @@ docker compose up -d --build
docker compose logs -f
```
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step (which needs outbound network access at exactly the moment the integration loads, and repeats on every container recreate). Re-run with `--build` whenever the pinned `smartthings-local` version changes.
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step. Re-run with `--build` whenever the pinned `smartthings-local` version changes.
`docker-compose.yml` sets `network_mode: host`, which is required since DTLS is UDP and won't traverse Docker's bridge NAT to reach LAN appliances, and bind-mounts `custom_components/localthings/` read-only into `ha_config/custom_components/`. Bump `custom_components.localthings` to `debug` in `ha_config/configuration.yaml` for verbose protocol logging.
### Tests
```sh
python3 -m venv .venv
python3.13 -m venv .venv# 3.13 or newer; see below
`requirements-dev.txt`pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
`requirements-dev.txt`already pulls in Home Assistant and pytest at matching versions, so there's nothing to install alongside it. Use Python 3.13 or newer: pip resolves the newest `pytest-homeassistant-custom-component` your interpreter supports, and on 3.12 or older nothing resolves and the install fails outright. CI runs 3.14.
A large suite covering registry composition, discovery, entity descriptors, and golden-file regression against captured device dumps. `requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
---
@@ -101,29 +196,38 @@ python3 -m venv .venv
```
custom_components/localthings/
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
behavior, and golden-file regression against captured device dumps
requirements-dev.txt Test deps, including the smartthings-local package
docker-compose.yml / ha_config/ Local HA dev environment
```
---
@@ -133,29 +237,56 @@ docker-compose.yml / ha_config/ Local HA dev environment
If your appliance's type isn't recognized, or it exposes resources this integration doesn't model yet, a Repairs
issue appears under Settings > System > Repairs pointing you at Settings > Devices & Services > this device >
the menu > Download diagnostics. That download is already redacted of account/network identifiers (Bixby login
email, access tokens, device IDs, MAC addresses, serial numbers) before it's generated, so it's safe to attach
email, access tokens, hashed device IDs, MAC addresses, serial numbers, and the owner-set device name) before it's
generated, so it's safe to attach
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
support for hardware the maintainers don't have.
When a diagnostics dump alone isn't enough to pin down how a resource actually behaves — whether a write sticks,
what order things need to happen in, whether the device reverts a change on its own — the `localthings.write_resource`
and `localthings.read_resource` actions from Part 5 are the tool for probing it directly and reporting back what
you found.
---
## Adding a new appliance type
1.Capture the appliance's `/device/0` response to see what resources/fields it exposes. An authenticated `DtlsCoapSession` from `smartthings_local.protocol.dtls_session` GET is enough; `local-tools/probe_device.py` wraps this.
1.Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform).
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, then route devices to it by adding the board-family token from their `modelNum` to `_BOARD_TOKEN_TO_KEY` — a single row, e.g. `'VSKR': 'vacuum_station'`. Tokens are matched whole (the model string is upper-cased and split on any run of non-alphanumerics), so one entry covers every delimiter spelling Samsung uses: `TP1X_DA-AC-RAC-01001` and `TP2X_RAC_20K` both resolve on `RAC`. Name the specific type, never the board family that contains it — `DA-AC-` prefixes RAC/WAC/DHM/AIR alike, so a bare `AC` row would swallow the dehumidifier and the air purifier. If the board is shared across types (washers and dryers both report `DA_WM_`), add the consumer-model prefix from `description` to `_CONSUMER_PREFIX_TO_KEY` instead. If the device omits `/information/vs/0` entirely (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
`oneUiVersion` is deliberately not consulted — see `resolve()` in that file for why.
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
No config-flow or coordinator changes are needed. Device-type detection and entity wiring are fully driven by the registry.
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
---
## Known DTLS behavior
## Known device behavior
Samsung's RT-OCF DTLS stack occasionally closes sessions actively, usually right after a Block2 GET or in the seconds after a POST. Retry/retransmit bounds and inter-request pacing live in the `smartthings-local` protocol layer (tuned against measured per-firmware request-rate ceilings); reconnect-with-backoff and stale-state fallback live in this repo's `coordinator.py`. From HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`.
Samsung's firmware occasionally drops the DTLS session briefly — this is normal appliance-side behavior, not a bug. The integration reconnects automatically, and from HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`. When an appliance supports it, the integration prefers push-based updates (instant, via `observe.py`) over polling, falling back to polling otherwise.
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN: Samsung's RT-OCF DTLS allows only one active session per peer.
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
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.
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
- **Indexed siblings** — the device answers a `/device/1`, `/device/2`, ... collection alongside its own `/device/0`, mirroring every resource at that index.
- **UUID-prefixed tree** — the device reports a sibling's id in `x.com.samsung.da.subdeviceIdList`, and that id doubles as a literal href prefix for the sibling's own resource tree.
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second subdevice) is silently skipped rather than turned into a phantom entity — check diagnostics' `subdevices`/`subdevices_skipped` blocks if a subdevice you expect to see isn't showing up, and file an issue with that diagnostics download attached.
---
@@ -163,7 +294,7 @@ If reconnects become persistent (more than a handful per minute), something's ac
Patches are welcome, especially:
- New `by_type/` registries for appliance families not yet covered (washer, AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
- New `by_type/` registries for appliance families not yet covered (AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
- Confirmation or refutation of compatibility on additional models within an already-supported type.
- Protocol-level fixes, which belong upstream in [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) rather than here. HA-side fixes (entities, config flow, coordinator, registry) belong in this repo.
"description":"Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates. {reuse_note}",
"data":{
"host":"IP Address",
"ca_cert_pem":"CA Certificate (PEM)",
"ca_key_pem":"CA Private Key (PEM)"
},
"data_description":{
"host":"Local IP address of your Samsung appliance (e.g. 192.168.1.50).",
"ca_cert_pem":"The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.",
"ca_key_pem":"The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
}
},
"confirm_unknown_type":{
"title":"Appliance type not recognized",
"description":"This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
}
},
"error":{
"cannot_connect":"Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
"invalid_ca":"The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
"unknown":"Unexpected error. Check the Home Assistant logs for details."
},
"abort":{
"already_configured":"This device is already configured."
}
},
"issues":{
"device_gap":{
"title":"Incomplete capability coverage for {device_name}",
"description":"This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.