Compare commits

...
251 Commits
Author SHA1 Message Date
Marc Billow b5e25d72d3 Merge pull request #401 from vmonkey/feature/add-wash
Add AddWash controls and sensors for washers
2026-08-20 21:22:20 -05:00
Marek Tyburec 6c980f927b Address review: make the AddWash master switch's On idempotent
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.
2026-08-20 06:56:47 +02:00
Marc Billow 207dd2f830 Bump version to 0.24.0 2026-08-20 03:10:28 +00:00
Marc Billow b0caebe14d Merge pull request #391 from JayChickenK/observe-silent-href-subpolls
Keep asking the appliance for readings that never send live updates
2026-08-19 21:59:34 -05:00
Marc Billow 513d44e8be Merge pull request #403 from mbillow/claude/agents-triage-fixes-7vc6xl
laundry: add DV6800N's Table_00 dryer courses (issue #394)
2026-08-19 21:56:43 -05:00
Marc Billow 2a52cd9cfa laundry: add DV6800N's Table_00 dryer courses (issue #394)
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.
2026-08-20 02:53:16 +00:00
Marc Billow 16dfcd4e50 Merge pull request #390 from JayChickenK/air-purifier-co2
air_purifier: expose CO2 when the device lists it
2026-08-19 20:35:00 -05:00
JayChickenK 4edd5b7866 observe: assign fallback_hrefs under the notify lock
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.
2026-08-19 22:22:11 +02:00
Marek Tyburec 51bce3c8cf Address review: gate the AddWash master write on a readable mask
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.
2026-08-19 19:41:13 +02:00
JayChickenK 35f144a2d1 tests: cancel background subpolls before driving them directly
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.
2026-08-19 18:37:54 +02:00
JayChickenK 43076352ae observe: drop fallback hrefs on late notify; skip empty subpolls
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.
2026-08-19 18:33:22 +02:00
JayChickenK 171316bd95 air_purifier: share has_sensor_type and disable co2 by default
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.
2026-08-19 18:27:01 +02:00
Marc Billow 61d2b9e9ff Merge pull request #402 from mbillow/claude/offline-device-startup-64bjpt
Stop reconnecting a session a dark appliance never opened
2026-08-19 07:27:06 -05:00
Marc Billow 5ab0c9b5a8 Stop reconnecting a session a dark appliance never opened
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
2026-08-19 11:58:01 +00:00
Marek Tyburec 6003394cb2 Add AddWash controls and sensors for washers 2026-08-19 10:02:07 +02:00
Marc Billow 66b4f1400b Merge pull request #399 from mbillow/claude/issues-398-397-vkne4j
Dishwasher: translate Sanitizing progress stage, drop churning drum-clean sensor
2026-08-18 22:06:28 -05:00
Marc Billow c573a41483 Drop dishwasher's drum_clean_last_cleaned sensor (#398)
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.
2026-08-19 02:58:46 +00:00
Marc Billow b5969773db Translate dishwasher's Sanitizing progress stage (#397)
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.
2026-08-19 02:58:32 +00:00
JayChickenK 9a45afe949 observe: keep silent hrefs on the sub-poll cadence
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.
2026-08-18 11:19:29 +02:00
JayChickenK 296ba18cba tests: narrow exists_fn before calling it 2026-08-18 11:10:20 +02:00
JayChickenK 258cdc1647 tests: apply ruff format to CO2 assertions 2026-08-18 11:07:51 +02:00
JayChickenK 33a06b9ae8 air_purifier: expose CO2 when the device lists it
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.
2026-08-18 11:04:25 +02:00
Marc Billow 01c9dc9801 Bump version to 0.24.0-beta.1 2026-08-18 03:08:40 +00:00
Marc Billow f780cc6069 Merge pull request #383 from mbillow/claude/unique-id-strategy-nofs1c
Key devices on the OCF device ID instead of the serial number
2026-08-17 22:03:49 -05:00
Marc Billow d8ebc17808 Merge pull request #386 from mbillow/claude/dishwasher-self-cleaning-sensors-c8b43k
Add Drum Clean+ sensors for dishwasher
2026-08-17 12:07:58 -05:00
Marc Billow 367017cc4c Add Drum Clean+ sensors for dishwasher
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.
2026-08-17 15:50:29 +00:00
Marc Billow ccb4a09c65 Trim the identity work's comments to the contributing guidelines
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.
2026-08-17 05:33:49 +00:00
Marc Billow 1b8e8368ca Type-check the test suite, not just the component
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.
2026-08-17 05:24:23 +00:00
Marc Billow c7aa66ef6e Address code review: close the migration-window duplicate and unify adoption
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.
2026-08-17 05:24:22 +00:00
Marc Billow 1e9fbd4ec5 Cover the identity migration against what existing users can lose
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.
2026-08-17 05:24:22 +00:00
Marc Billow 81dbc6fa03 Key devices on the OCF device ID instead of the serial number
Two Samsung air purifiers of the same model report the identical, well
formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's
unique_id, the device registry identifiers and every entity unique_id
were all minted from that string, the second unit was refused as already
configured, and would have collided entity-for-entity even if it hadn't
been.

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

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

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

Three rules keep that adoption from misfiring:

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

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

Fixes #381
2026-08-17 05:24:22 +00:00
Marc Billow d912bff5d3 Bump version to 0.23.0 2026-08-16 05:32:31 +00:00
Marc Billow 330479d389 Merge pull request #380 from mbillow/issue-364-cloud-courses-disable
Add a global disable for downloaded cycles and clarify setup instructions
2026-08-16 00:27:52 -05:00
Marc Billow c0ef45c7c8 Harden the cloud-courses toggle against three review findings
- _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.
2026-08-16 05:25:36 +00:00
Marc Billow d5dc6421ba Add a global disable for downloaded cycles and clarify setup instructions
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.
2026-08-16 05:04:44 +00:00
Marc Billow 93cd6ae6fe Merge pull request #379 from mbillow/fix-particulate-unit-deprecation
Stop importing the deprecated CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
2026-08-15 23:33:02 -05:00
Marc Billow ffc0eec322 Merge pull request #378 from mbillow/issue-376-cycle-labels
Add washer/dryer Table_02/Table_03 cycle labels for WF21T6500KV/DV19T8745BV
2026-08-15 23:26:44 -05:00
Marc Billow f5d9f0e31b Stop importing the deprecated CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
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.
2026-08-16 04:26:29 +00:00
Marc Billow 55486526cc Relabel washer Table_02 06 from XXL Laundry to Bedding
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.
2026-08-16 04:19:04 +00:00
Marc Billow a18663d5e0 Add washer/dryer Table_02/Table_03 cycle labels for WF21T6500KV/DV19T8745BV
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.
2026-08-16 04:15:02 +00:00
Marc Billow 322e436c22 Merge pull request #377 from mbillow/chore/smartthings-local-0-1-8
Bump smartthings-local floor to 0.1.8
2026-08-15 22:47:52 -05:00
Marc Billow ba089dbb5c Bump smartthings-local floor to 0.1.8
Two releases landed since the 0.1.6 pin, both confirmed by upstream
(QuiteYellow, in issue #361) as additive/opt-in with no interface
changes on our side:

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

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

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

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

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

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

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

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

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

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

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

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

Three things fall out of that:

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

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

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

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

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

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

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

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

dryer_type itself was never wired for translation at all -- no
device_class, no options -- so nothing in any locale's dryer_type.state
table was ever read. Give it device_class=enum, options=("electricity",)
(the only value confirmed across shipped fixtures), and the same
value_fn=lower() normalization #371 used elsewhere, then add the
'electricity' state across all seven locale catalogs, not just Dutch.
2026-08-15 02:44:01 +00:00
Marc Billow bc21f5f8f5 Bump version to 0.22.0 2026-08-15 02:29:22 +00:00
Marc Billow ab035a94af Merge pull request #371 from mbillow/claude/pr-341-review
Normalize appliance enums for HA translations
2026-08-14 21:19:33 -05:00
Marc Billow f6fbfc1f7f Keep the drum-clean unit in code rather than the catalog
Home Assistant resolves a catalog `unit_of_measurement` against the default
language, not the user's (entity_platform re-fetches 'en' for exactly this
key), because a unit is part of the state's identity -- the recorder writes
it into statistics metadata and compares it across restarts. Localizing it
would make switching Home Assistant's language look like a unit change and
suppress the sensor's statistics.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* vacuum_station: translate stick labels; drop diagnostic category

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What that leaves untried is the bare indexed leaf: every indexed href this
project has ever seen arrived inside a /device/<n> batch, and /device/1
4.04ing is evidence about the Collection, not about /mode/vs/1. Leaves
without their Collection is already confirmed BORA behavior in the other
namespace (issue #205). Records the probe list, the OCF composite-device
clause that suggests /sec/devices, a positive control for whether the UUID
prefix routes at all, and the dead ends worth not re-treading.
2026-08-13 02:43:11 +00:00
Marc Billow 6d73ac8694 Bump version to 0.21.1 2026-08-13 02:31:48 +00:00
Marc Billow 1cfe126313 Merge pull request #360 from mbillow/claude/issue-357-5bsr88
laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
2026-08-12 11:28:59 -04:00
Marc Billow 8d1ecb4f2a laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
Adds washer_cycle_table_00 and dryer_cycle_table_00 translation catalog
entries, confirmed by the issue #357 reporter selecting each cycle on a
WF45R6300AW/US washer and DVE45R6300W/A3 dryer and reading back the raw
course code. Table_00 is a separate, older course-code family from the
existing Table_02/Table_03 catalogs -- laundry.cycle_select's table_href
scoping already keeps them apart, so this is a translations-only change.

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

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

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

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

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

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

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

Both halves are fixed:

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

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

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

Add _is_alarms_href() to recognize /alarms/vs/<index> across every
subdevice-translated shape (MAIN identity, indexed renumbering, prefixed
UUID -- Subdevice.to_actual never touches the 'alarms/vs' stem) and have
apply() fully replace the cache for that href instead of merging. This
is a global fix: every family with an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so they were all exposed.
2026-08-12 02:41:02 +00:00
Marc Billow ac995dcbd4 Revert version to 0.21.0
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.
2026-08-10 16:44:23 +00:00
Marc Billow d80bd550fe Merge pull request #351 from mbillow/claude/triage-version-bump-x60dym
water_purifier, range: drop invalid device_class='lock' from switches
2026-08-10 12:37:38 -04:00
Marc Billow cc16766b9d Bump version to 0.22.0 2026-08-10 16:33:39 +00:00
Marc Billow 84465aee72 water_purifier, range: drop invalid device_class='lock' from switches
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.
2026-08-10 16:33:39 +00:00
Marc Billow 711a71876d Merge pull request #350 from galaxysj/codex/fix-washer-course-enum-display
Fix AC setup timeout and verify appliance course mappings
2026-08-10 12:19:21 -04:00
Marc Billow 9004125c97 Merge pull request #347 from mbillow/claude/cloud-cycle-download-select-onx14l
Discover and offer cloud "Download" cycles (issue #342)
2026-08-10 06:52:13 -04:00
Marc Billow 0dd8bfb5fc laundry: fix four findings from the final review of guided setup
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.
2026-08-10 10:43:01 +00:00
Marc Billow 9652a14f64 tests: stop the cloud write test leaving a debounced refresh behind
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.
2026-08-10 10:26:33 +00:00
Marc Billow bb20eea191 laundry: a payload sitting there is not evidence of when it got there
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.
2026-08-10 10:16:00 +00:00
Marc Billow 0fbac7f14d laundry: guided setup left download cycles unselectable, and cleared the course
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.
2026-08-10 10:08:12 +00:00
Marc Billow 78a545341b laundry: show the names assigned so far during guided setup
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.
2026-08-10 09:57:21 +00:00
Marc Billow a4981f40a2 laundry: guided setup for download cycles
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.
2026-08-10 09:40:13 +00:00
galaxysj 17cd78d975 Format subdevice regression test 2026-08-10 15:23:43 +09:00
galaxysj 863fe32526 Cover reported washer course table 2026-08-10 15:19:13 +09:00
galaxysj 13d2a38f5d Avoid probing UUID file-transfer namespaces 2026-08-10 15:16:42 +09:00
galaxysj d9e7daa203 Merge remote-tracking branch 'origin/main' into codex/fix-washer-course-enum-display
# Conflicts:
#	custom_components/localthings/coordinator.py
#	custom_components/localthings/registry/capabilities/laundry.py
#	custom_components/localthings/registry/capabilities/washer.py
#	custom_components/localthings/registry/entities.py
#	custom_components/localthings/registry/subdevices.py
#	custom_components/localthings/select.py
#	custom_components/localthings/translations/cs.json
#	custom_components/localthings/translations/nl.json
#	tests/test_select_display.py
#	tests/test_select_options.py
#	tests/test_subdevice_discovery.py
#	tests/test_subdevices.py
#	tests/test_translations.py
2026-08-10 14:52:37 +09:00
Marc Billow d27bfc6405 laundry: CloudExtraCourse_ means two different things; tell them apart
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.
2026-08-10 03:55:13 +00:00
Marc Billow bee4466b45 translations: localize the download-cycle strings
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.
2026-08-10 03:45:14 +00:00
Marc Billow cef187b7af diagnostics: report discovered cloud cycles in full, names included
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().
2026-08-10 03:33:28 +00:00
Marc Billow 2265c52c77 laundry: apply cleanup review to the cloud-cycle branch
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.
2026-08-10 03:30:23 +00:00
Marc Billow b921bdbb28 laundry: fix six issues from review of the cloud-cycle branch
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.
2026-08-10 03:16:07 +00:00
Marc Billow 9d28b088cb laundry: cover a device that advertises cloud cycles it has never loaded
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.
2026-08-09 21:46:29 +00:00
Marc Billow 22b4508f95 docs: the two cloud-blob widths are the same grammar, not two formats
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.
2026-08-09 21:32:26 +00:00
Marc Billow f45bd6a72c laundry: discover and offer cloud "Download" cycles (issue #342)
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.
2026-08-09 21:21:10 +00:00
Marc Billow 1424c2222e Merge pull request #346 from mbillow/issue-345-progress-finished-hold
washer/dryer: hold progress/progress_percentage at Finish/100 for 5 min
2026-08-09 15:23:56 -04:00
Marc Billow ef66d1db71 washer/dryer: hold progress/progress_percentage at Finish/100 for 5 min
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.
2026-08-09 18:21:26 +00:00
Marc Billow 16cb01ce5d Merge pull request #344 from mbillow/claude/pr-276-squash-review-uplqdz
Fix AC temperature step quantization + washer cycle translations (#342, #343)
2026-08-09 12:52:18 -04:00
Marc Billow 676074b2f8 AC: quantize subdevice temperature writes against their own step (Opus review)
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.
2026-08-09 16:25:12 +00:00
Marc Billow 846aefbcba Fix ty failures in new AC temperature-step tests (CI)
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.
2026-08-09 16:06:22 +00:00
Marc Billow 895fd87d2c washer: fix swapped Towels/Bedding, add missing Table_02 course names
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.
2026-08-09 16:00:27 +00:00
Marc Billow 248e473abe Fix AC temperature step quantization (PR #276, code review)
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.
2026-08-09 16:00:14 +00:00
Marc Billow 6828d0152b Merge pull request #339 from danielhodder/bugfix/338_pad_delay_hours_with_0
Change format delay to always zero-pad number of hours.
2026-08-09 09:47:06 -04:00
danielhodder d6534c788a Change format delay to always zero-pad number of hours.
Resolves #338
2026-08-09 05:29:02 +00:00
Marc Billow 89fea83c80 Merge pull request #334 from mbillow/claude/issue-triage-d7hz7u
Issue triage: filterUsage percentage fix, TP1X_REF_21K auto-door + winecellar, dual-cavity range routing (#330, #328, #324)
2026-08-08 23:09:29 -04:00
Marc Billow d2787327fd Fix ty type-check failures in new tests (CI)
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.
2026-08-09 01:25:23 +00:00
Marc Billow 1f7bdc9ac6 fridge: give DEODOR_FILTER its own entity keys, not AIR_FILTER's (code review)
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.
2026-08-09 01:21:17 +00:00
Marc Billow 61a953d39a Bump version to 0.21.0 2026-08-09 01:09:24 +00:00
Marc Billow 95ce358d55 fridge: fold the three Auto Door Open variant hrefs into one pattern cap (issue #328)
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.
2026-08-09 01:06:37 +00:00
Marc Billow 5e2c23a62d Add device support for dual-cavity range TP1X_DA-KS-RANGE-0101X (issue #324)
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.
2026-08-09 00:58:26 +00:00
Marc Billow b6f0bc22cb Add device support for Samsung Refrigerator TP1X_REF_21K auto-door variants (issue #328)
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.
2026-08-09 00:57:36 +00:00
Marc Billow 07c20e82aa Stop double-converting filterUsage on AIR_FILTER/HEPA_FILTER (#330)
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.
2026-08-09 00:37:09 +00:00
Marc Billow efea9e9888 Merge pull request #333 from mbillow/claude/merge-prs-251-275-312-q2z9kz
Merge #251, #275, #312: washer/dishwasher/dryer course codes + German translations
2026-08-08 20:26:58 -04:00
Marc Billow 94798b5d9b Fix ty type-check failure in select.py
_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.
2026-08-09 00:08:27 +00:00
Marc Billow f5e99d71e3 Address PR #251 review feedback: no invented English fallback text
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.
2026-08-08 23:44:54 +00:00
Marc Billow 8ed3d1467f Apply ruff format to code merged from PR #251/#275
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.
2026-08-08 21:24:02 +00:00
Marc Billowandedenhaus 68dee12eb4 Squash-merge PR #312 and backfill translations to match main
- 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>
2026-08-08 21:23:56 +00:00
Marc Billowandvkostakos 082a1b3cd4 Squash-merge PR #275: add new washing, drying, and dishwasher translations
- dishwasher_cycle: 85 Delicate, 0c Express, 0d Self clean
- dryer_cycle_table_03: 26 Air wash, 2a Hygiene Care+
- washer_cycle_table_02: 35 E Cotton

Co-authored-by: vkostakos <7722961+vkostakos@users.noreply.github.com>
2026-08-08 21:17:54 +00:00
Marc Billowandgalaxysj 00db7890f5 Squash-merge PR #251: fix appliance course labels and AC setup timeouts
- Add confirmed Samsung Table_02 washer course mappings (69-79, 88)
- Add confirmed dishwasher course mappings (82, 8a, a7, a8, 8c, 88)
- Localize new washer/dishwasher course labels in en, cs, nl
- Decode device-provided personal washer course names from TLV payloads
- Show unrecognized washer enum bytes as 'Unknown (0xNN)'
- Normalize select current-state and options through one display path
- Bound first-setup subdevice enumeration with a shared time budget

Co-authored-by: galaxysj <224385302+galaxysj@users.noreply.github.com>
2026-08-08 21:17:43 +00:00
Marc Billow 55765fdf9a Merge pull request #332 from mbillow/claude/issue-327-device-state-u9hz6q
Remember modes a device reports but never advertises (issue #327)
2026-08-08 15:56:14 -04:00
Marc Billow 3675d8087b Scope learning to the device, and simplify the store
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.
2026-08-08 19:49:25 +00:00
Marc Billow 4f3bdde6e5 Address review findings on the learned-modes store
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.
2026-08-08 19:42:20 +00:00
Marc Billow d65735ac47 Remember modes a device reports but never advertises (issue #327)
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.
2026-08-08 19:10:53 +00:00
Marc Billow 867f4b0ae8 Bump version from 0.19.0 to 0.20.0 2026-08-07 19:55:02 -04:00
Marc Billow 9da775a8de Merge pull request #326 from mbillow/claude/oven-control-write-options-uthy5u
Add write_resource/read_resource services for probing write contracts (issue #300)
2026-08-07 19:52:12 -04:00
Marc Billow 6ee60beae9 Make holding the session across a sequence the caller's choice
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.
2026-08-07 23:46:28 +00:00
Marc Billow fffe923afc Take the device as a field, not a service target
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.
2026-08-07 22:58:48 +00:00
Marc Billow a6d818dfc0 Fix four review findings in the raw write/read services
- 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.
2026-08-07 22:58:37 +00:00
Marc Billow cec3dd4a68 docs: use real field shapes in the write_resource examples
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.
2026-08-07 22:58:37 +00:00
Marc Billow 5dbe990c1d Add write_resource/read_resource services for probing write contracts (issue #300)
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.
2026-08-07 22:08:28 +00:00
Marc Billow 9228da1d9c Merge pull request #323 from mbillow/claude/issue-triage-qgveie
Device support: A/C, fridge, cooktop, oven coverage gaps (issues #319, #318, #314, #300, #288)
2026-08-07 13:09:22 -04:00
Marc Billow ce60b6967b Clarify why windfree/windsleep are plain switches, not climate presets
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.
2026-08-07 17:05:39 +00:00
Marc Billow a7dc1db8ff Extract usable parts of PR #316 (System Fresh Air Ventilator support)
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.
2026-08-07 16:38:09 +00:00
Marc Billow 8551974719 Fix ty type-check failures in new test files
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.
2026-08-07 15:52:46 +00:00
Marc Billow e2dcc75ed5 Address Opus review findings on the device-support commits above
- 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.
2026-08-07 14:50:24 +00:00
Marc Billow c203bd42c5 Add edge-lighting and indicator-light support for TP1X_DA-AC-CAC-01001 (issue #288)
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).
2026-08-07 14:27:25 +00:00
Marc Billow 42fd9c2aa4 Close coverage gap and fix phantom lamp switch for TP2X_DA-KS-WALLOVEN (issue #300)
/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.
2026-08-07 14:22:32 +00:00
Marc Billow df5b704f3e Close gas-cooktop coverage gap for TP2X_DA-KS-COOKTOP-000001 (issue #314)
/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.
2026-08-07 14:18:08 +00:00
Marc Billow 26c9168fb7 Add internal air-filter support for TP1X_REF_21K refrigerators (issue #318)
/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.
2026-08-07 14:13:38 +00:00
Marc Billow edf77309ba Add device support for AILP_DA-AC-FAC-02011 air conditioner (issue #319)
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.
2026-08-07 14:10:13 +00:00
galaxysj d24c94e303 Localize appliance course labels 2026-08-06 23:24:16 +09:00
Marc Billow f07ae4020e Merge pull request #310 from edenhaus/config-flow-prefill-on-error
Keep user input on error in the config flow
2026-08-06 08:47:48 -04:00
Marc Billow dd953b8150 Merge pull request #304 from perseus177/ac-presets-per-hvac-mode
feat(climate): derive legacy AC presets from the unit's own capability bits, per HVAC mode
2026-08-06 08:46:55 -04:00
perseus177 b820a96277 docs(airconditioner): record that the board zeroes Sleep_ on leaving a sleep mode
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.
2026-08-06 12:20:18 +02:00
perseus177 eed04faaed fix(climate): derive presets only when the board publishes both capability maps
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.
2026-08-06 12:04:46 +02:00
Robert Resch 960eca2d6a Keep user input on error 2026-08-06 10:17:43 +02:00
Marc Billow 789aaf9849 Merge pull request #306 from mbillow/claude/issue-triage-backoff-xkeddl
Fix reconnect/retry gaps found in issue triage (#291, #287, #294)
2026-08-05 22:09:52 -04:00
Marc Billow e3e7f4f43c test: suppress ty's invalid-assignment on the fake-session swap
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).
2026-08-06 02:07:39 +00:00
Marc Billow a3cc918343 fix(coordinator): two gaps a follow-up Opus review found in the split
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.
2026-08-06 02:01:30 +00:00
Marc Billow 69f93be4dc fix(coordinator): close the observe-mode race an Opus design review found
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.
2026-08-06 01:39:03 +00:00
Marc Billow 50bb893407 review: re-arm the settle window on retry, tighten comments, close a test gap
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.
2026-08-06 01:06:22 +00:00
Marc Billow 77c2d7831e fix(coordinator): retry a command once after a dead-session reconnect
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.
2026-08-06 00:44:35 +00:00
Marc Billow 252306838d fix(coordinator): downgrade observe mode when a device stays unreachable
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).
2026-08-06 00:42:31 +00:00
Marc Billow 455ed5b27c fix(config_flow): normalize a pasted PEM before parsing it
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.
2026-08-06 00:41:43 +00:00
Marc Billow 26e4c9c167 Merge pull request #267 from kkqq9320/fix/air-quality-state-class
fix(air_purifier): record long-term statistics for the particulate sensors
2026-08-05 19:55:56 -04:00
perseus177 2d772f16a7 feat(climate): offer legacy presets per HVAC mode, from the unit's own capability bits
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.
2026-08-05 17:24:40 +02:00
perseus177 30bd0fd2af fix(airconditioner): Good Sleep needs the mode token its duration belongs to
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.
2026-08-05 15:46:53 +02:00
Marc Billow 4e47a1c3d9 Merge pull request #296 from perseus177/ac-good-sleep-halfhours
fix(airconditioner): good_sleep is hours, but the token counts half hours
2026-08-05 08:18:03 -04:00
Marc Billow bea5206c06 Merge pull request #293 from mbillow/claude/ac-filter-reset-cleanup
feat(airconditioner): reset the legacy filter counter locally
2026-08-05 08:16:02 -04:00
perseus177 75e985d392 style: let ruff format the write helper
`ruff format --check` is part of the Validate workflow and my hand-wrapped
version of the dict literal was not what it produces. No behaviour change.
2026-08-05 12:27:24 +02:00
perseus177 93f45cb356 fix(airconditioner): good_sleep is hours, but the token counts half hours
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.
2026-08-05 12:21:10 +02:00
perseus177 e6909fb847 fix(translations): mirror the new button string into every catalog
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.
2026-08-05 01:48:08 +00:00
perseus177 9ee0329467 feat(airconditioner): reset the legacy filter counter locally
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.
2026-08-05 01:46:29 +00:00
Marc Billow e9e278726a Merge pull request #280 from g1za/main
ITA typo fix
2026-08-04 21:40:34 -04:00
Marc Billow ccdfe7088e Merge pull request #281 from atc722/agent/nv9000d-regression-fix
Fix read-only sensor categories and add NV9000D coverage
2026-08-04 21:40:03 -04:00
Marc Billow c423efdd23 Merge pull request #292 from mbillow/claude/code-comments-guidelines-jiv4ta
Add code comment guidelines; dramatically trim excessive comments
2026-08-04 21:36:23 -04:00
Marc Billow 3918b1e5c8 Fill in the Korean translation gaps left by the AI Purify/auto-clean-stop merges
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.
2026-08-05 01:33:08 +00:00
Marc Billow 6dd4de8b6b Merge remote-tracking branch 'origin/main' into claude/code-comments-guidelines-jiv4ta 2026-08-05 01:32:58 +00:00
Marc Billow 7086b134c0 Add code comment guidelines; dramatically trim excessive comments
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.
2026-08-05 01:24:17 +00:00
Marc Billow 7f66d21d73 Merge pull request #283 from atc722/agent/korean-translation
Add Korean translation
2026-08-04 21:07:42 -04:00
Marc Billow 9e12992cb4 Merge pull request #284 from rtvanhook/main
Update README.md
2026-08-04 21:01:09 -04:00
Marc Billow b59b5ae1b4 Merge pull request #290 from perseus177/ac-autoclean-stop
feat(airconditioner): stop a running auto clean, and read its progress
2026-08-04 20:53:44 -04:00
Marc Billow f8a7a1fa66 Merge pull request #268 from kkqq9320/feat/avt-ai-purify
feat(air_purifier): expose the AI Purify sensing engine on /airlevelcheck/vs/0
2026-08-04 20:02:16 -04:00
perseus177 290a348017 feat(airconditioner): stop a running auto clean, and read its progress
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.
2026-08-05 00:45:20 +02:00
GeekERDr 54a676f841 Update README.md 2026-08-04 05:31:24 -05:00
hoon a38437fca4 Add Korean translation 2026-08-04 17:13:32 +09:00
kkqq9320 15279066b5 review: move the state_class into the shared tuple's fourth column
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.
2026-08-04 15:25:12 +09:00
hoon 3d0dca20f9 Add NV9000D cooktop coverage and fix sensor setup 2026-08-04 14:47:42 +09:00
kkqq9320 96d06369bc review: floor the sensing interval at one minute
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.
2026-08-04 14:44:54 +09:00
g1za 330b2f344f ITA typo fix 2026-08-04 07:34:53 +02:00
kkqq9320 a5484f746a review: unfold AI Purify into one entity per field
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.
2026-08-04 14:20:58 +09:00
kkqq9320 412fff9b99 feat(air_purifier): expose the AI Purify sensing engine on /airlevelcheck/vs/0
/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.
2026-08-04 12:57:03 +09:00
Marc Billow 0c2e219464 Merge pull request #273 from mbillow/claude/device-discovery-config-flow-dmeagp
Rebuild device discovery on the ClientHello probe and resolve identity up front
2026-08-03 20:36:17 -04:00
Marc Billow b5699badbe Stop the v1 migration re-keying devices onto a placeholder serial
_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.
2026-08-04 00:12:40 +00:00
Marc Billow d5adf311da Normalize cs.json to LF line endings
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.
2026-08-03 20:33:30 +00:00
Marc Billow 6033709f24 Replace the blanket "cannot connect" with a real failure taxonomy
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.
2026-08-03 20:29:53 +00:00
Marc Billow 15be379243 Rebuild device discovery on the ClientHello probe and resolve identity up front
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.
2026-08-03 20:14:54 +00:00
Marc Billow cdaff4a1ca Merge pull request #272 from mbillow/claude/issue-triaging-fuq5sn
Issue triage batch: dehumidifier, AC, dryer, fridge, dishwasher, climate fixes
2026-08-03 15:45:12 -04:00
Marc Billow 6a6eef25b8 Fix ty type error in test_dryer_drum_clean.py
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.
2026-08-03 19:38:57 +00:00
Marc Billow da25d567cb Remove pointless catalog-literal tests; document the anti-pattern
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.
2026-08-03 19:32:44 +00:00
Marc Billow e89aa4bab5 Fix transposed Normal/Express 60 dishwasher cycle labels (issue #226)
'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.
2026-08-03 19:30:02 +00:00
Marc Billow fb5ed32b0f Add discrete freezer setpoint support for TP1X_REF_21K (issue #229)
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).
2026-08-03 19:24:59 +00:00
Marc Billow 1becd85f6e Fix HOMECARE_WIZARD_V2 false-positive warning and None entity_id log (#235)
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.
2026-08-03 19:22:06 +00:00
Marc Billow 0cc9486ad5 Add missing dryer cycle labels for DV90DG6845LHU5 (issue #244)
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.
2026-08-03 19:17:41 +00:00
Marc Billow e6d7dcddc8 Add dryer Drum Clean+ tracking; fix multi-entry DrumCleanLog parsing (#258)
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.
2026-08-03 19:14:29 +00:00
Marc Billow cf09247e39 Add AC UV LED, ventilation alarm, and PM1 filter support (issue #270)
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.
2026-08-03 19:07:03 +00:00
Marc Billow 0994ca487a Restore CRLF line endings in cs.json
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.
2026-08-03 19:00:06 +00:00
Marc Billow 3ef64eae52 Add dehumidifier display switch and watertank lighting (issues #271, #231)
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.
2026-08-03 18:59:22 +00:00
kkqq9320 69844d9829 fix(air_purifier): record long-term statistics for the particulate sensors
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.
2026-08-03 14:34:57 +09:00
Marc Billow 51103fa341 Merge pull request #264 from mbillow/claude/ruff-pyright-ci-stage-gxph0t
Add ruff (lint + format) and ty (type checking) to the project
2026-08-02 20:30:08 -04:00
Marc Billow 9dc1facbfe Fix ty diagnostics from newer homeassistant/cryptography type stubs
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.
2026-08-03 00:25:46 +00:00
Marc Billow 24d48d70b9 Reformat after merging main
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.
2026-08-03 00:21:34 +00:00
Marc Billow 18596f23ec Merge remote-tracking branch 'origin/main' into claude/ruff-pyright-ci-stage-gxph0t 2026-08-03 00:20:59 +00:00
Marc Billow 450f8ca933 Add lint/format/type-check CI job
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.
2026-08-03 00:16:34 +00:00
Marc Billow 679c3d2bce Fix remaining pre-existing ty diagnostics in test files
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.
2026-08-03 00:14:58 +00:00
Marc Billow 36a642135b Fix pre-existing ty diagnostics in a second batch of test files
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.
2026-08-03 00:11:05 +00:00
Marc Billow 07061c734d Fix more pre-existing ty diagnostics in 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.
2026-08-03 00:03:08 +00:00
Marc Billow daf7e3787f Add ruff (lint + format) and ty (type checking) to the project
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.
2026-08-02 23:56:38 +00:00
Marc Billow d9c84e765b Merge pull request #263 from mbillow/claude/issue-254-diagnosis-fix-a2t1y1
Fix devices coming up entity-less after a Core restart (#254)
2026-08-02 19:50:02 -04:00
Marc Billow 00abfb9770 Tighten the #254 fix after review
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.
2026-08-02 23:44:38 +00:00
Marc Billow 9fc04e179e Fix devices coming up entity-less after a Core restart
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.
2026-08-02 23:26:50 +00:00
Marc Billow b0eab93b58 Merge pull request #262 from mbillow/claude/pr-256-it-translation
Complete Italian translation, fix es.json parity
2026-08-02 18:07:12 -04:00
Marc Billow 547388cc2b Fix es.json translation-catalog parity with en.json
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).
2026-08-02 22:04:32 +00:00
Marc Billow 81b83f4175 Complete Italian translation
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.
2026-08-02 22:04:04 +00:00
g1za c5f37ea280 Partial Italian translation
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).
2026-08-02 21:55:36 +00:00
Marc Billow b4550cc4b1 Merge pull request #246 from axelet85/feat/es-translation
feat: Spanish translation (es.json)
2026-08-02 17:43:21 -04:00
Marc Billow 42a1812967 Merge pull request #261 from mbillow/claude/pr-242-review-merge-nny8ug
feat: discover UUID-prefixed subdevices advertised only via /oic/res (Pattern C, #241)
2026-08-02 17:42:46 -04:00
Marc Billow e926905517 fix: dedupe Pattern C's UUID-prefix candidates against Pattern B (#242 review)
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.
2026-08-02 21:40:34 +00:00
Hyunook 6bcf8f3bec feat: discover UUID-prefixed subdevices advertised only via /oic/res (Pattern C, #241)
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.
2026-08-02 21:40:34 +00:00
Marc Billow 9737684c9f Merge pull request #253 from pookey/feat/ehs-water-heater
Add a water_heater platform for the EHS DHW loop
2026-08-02 17:32:22 -04:00
Ian P. Christian 6efee761d9 Add Samsung EHS (Eco Heating System) heat pump support
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.
2026-08-02 22:18:43 +01:00
Marc Billow e309d3e7b8 Merge pull request #225 from atc722/agent/qooker-support
Route Samsung Bespoke Qooker to microwave registry
2026-08-02 12:22:10 -05:00
Marc Billow 34b2291bef Merge pull request #255 from moKorean/autoclean-cycle-state
Report whether the auto-clean cycle is running, and how far through
2026-08-02 09:05:34 -05:00
Geunwon Mo 5c275753d4 Report whether the auto-clean cycle is running, and how far through
`/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).
2026-08-02 22:01:27 +09:00
galaxysj 71f2101d11 Bound subdevice discovery during setup 2026-08-02 12:29:55 +09:00
galaxysj ba5a3b529d Add dishwasher course labels 2026-08-02 11:53:09 +09:00
galaxysj f8849df8a6 Fix washer course enum display 2026-08-02 10:19:45 +09:00
axelet85 57eb3e80c7 feat: add Spanish translation (es.json)
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.
2026-08-01 20:31:56 +02:00
hoon d544644e0e Clarify resource override comment 2026-08-01 11:29:30 +09:00
hoon f9cd857ced Support Samsung Bespoke Qooker routing 2026-08-01 10:47:00 +09:00
Marc Billow 93744e80f4 Merge pull request #227 from blka/observe-grace-early-exit-upstream
Observe grace-period early-exit (first-refresh ~15s -> ~0.4s)
2026-07-31 20:20:22 -05:00
Marc Billow a74b92f758 i18n: add czech translations to merged pr 2026-07-31 20:16:56 -05:00
Marc Billow d639aa693f Merge pull request #218 from perseus177/ac-filter-reset
feat(airconditioner): filter dust alarm interval on legacy ARTIK051 boards
2026-07-31 20:14:31 -05:00
Marc Billow 3c6c7f246f Merge branch 'main' into ac-filter-reset 2026-07-31 20:13:40 -05:00
Marc Billow 0d0844ea7f Merge pull request #230 from moKorean/oic-type-hood
Add x.com.st.d.hood to _OIC_TYPE_TO_KEY; document why oic.d.cooktop is not mapped
2026-07-31 20:10:24 -05:00
Geunwon Mo 618fc5fa51 Add x.com.st.d.hood to _OIC_TYPE_TO_KEY, and document why oic.d.cooktop is not
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).
2026-08-01 09:02:49 +09:00
perseus177 9ee0dbb8f9 feat(airconditioner): filter alarm interval on legacy ARTIK051 boards
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.
2026-07-31 12:41:40 +02:00
blka f19abc7936 feat(observe): early-exit grace wait on success fraction
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.
2026-07-31 09:06:19 +02:00
270 changed files with 51798 additions and 11953 deletions
@@ -399,6 +399,24 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
**Don't write a test that just re-asserts a translation string.** Adding
labels is a data change, not a logic change, and `tests/test_translations.py`
already holds the invariants that matter for data (every descriptor has a
catalog entry, every language mirrors English key-for-key, no unresolved
`[%key:...%]`). A test that loads the catalog and asserts
`catalog["select"]["foo"]["state"]["16"] == "Cotton"` right after you just
wrote that exact line into `en.json` doesn't exercise any code path — it
re-states the JSON file in Python, passes by construction, and only ever
fails when someone *correctly* edits the label later (a wording fix, a
translator's improvement). It's not a regression test, because there's no
`select.py`/`adapter.py` logic between "the JSON says X" and "the test reads
X" for it to catch drift in. If a code/label mapping is worth locking in,
test it through the code that actually consumes it instead — a write
contract (`desc.write_fn(...)` returns the right raw code), a read contract
(`flatten()` produces the right raw value from a fixture rep), or a routing
decision — never a bare literal-string comparison against the catalog you
just edited.
## 8. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't
+26
View File
@@ -36,6 +36,32 @@ jobs:
with:
category: integration
lint:
name: Lint, format, and type check
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run ruff format check
run: ruff format --check custom_components tests
- name: Run ruff lint
run: ruff check custom_components tests
- name: Run ty type check
run: ty check custom_components tests
tests:
name: Pytest
runs-on: ubuntu-latest
+34
View File
@@ -19,6 +19,40 @@ file covers how changes get committed.
assistant, or tool that helped produce the change. The commit is
attributed entirely to the accountable human.
## Code comments
This codebase reverse-engineers undocumented device APIs, so comments
recording *why* a decision was made (a calibration, a rejected write, an
issue number a quirk was confirmed against) are genuinely valuable — more
valuable than in most codebases. That's exactly why comments here need
discipline: it's easy for "explain the reasoning" to slide into "narrate
the whole investigation," and a file where every line has a paragraph
under it is as hard to read as one with no comments at all. Keep the
conclusion; cut the journey.
- **Comment the "why," never the "what."** If a comment just restates what
the next line already says, delete it. Code should read clearly enough
on its own that comments are only needed for the non-obvious.
- **One or two sentences, not an essay.** State the conclusion and the one
piece of evidence that makes it credible (an issue number, a model name,
a single confirming observation). Don't reproduce the full
investigation — every dump checked, every attempt that failed, every
hypothesis considered and discarded. A future reader needs to trust the
conclusion and know where to look if they need to redo the work, not
relive it.
- **A pointer, not a re-derivation.** Cite the issue/model once; don't
re-explain a sibling function's already-documented reasoning. Reference
it (`same reasoning as X above`) instead of restating it.
- **Module/class docstrings are a short orientation, not a design doc.**
A few lines on purpose and any cross-cutting invariant is enough.
- **Failed-attempt logs don't belong inline.** If an investigation into an
unsolved problem produced real negative results worth preserving (e.g. a
reset mechanism nobody could find), put them in an issue or docs, not a
block comment several times longer than the code it sits above.
- **When in doubt, cut.** If deleting a comment wouldn't lose real
understanding, it's noise. Prefer trimming an existing comment over
adding a new one.
## For AI coding agents
See `AGENTS.md`.
+1 -1
View File
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
# repeats the install attempt on every container recreate. Baking
# smartthings-local into the image keeps the dev container usable
# offline and avoids relying on that runtime install path.
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.0"
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.8"
+90 -7
View File
@@ -63,7 +63,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
```
- 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 sweeps the whole range and auto-detects the live port, 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.**
---
@@ -82,10 +82,10 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
2. Restart HA.
3. **Settings > Devices & Services > Add Integration > LocalThings.**
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, sweeps the `49152-49160` range to find the live DTLS port, 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.)
---
@@ -95,6 +95,73 @@ Each device has its own **Configure** option in Settings > Devices & Services, u
- **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):
```json
{
"device_id": "abc123...",
"results": [
{"href": "/mode/vs/0", "actual_href": "/mode/vs/0", "code": "2.04", "raw_code": 68,
"accepted": true, "before": {...}, "after": {...}, "changed": true},
...
],
"verified": {
"/mode/vs/0": {"code": "2.05", "raw_code": 69, "rep": {...}, "held": false}
}
}
```
`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.
---
@@ -131,13 +198,15 @@ A large suite covering registry composition, discovery, entity descriptors, and
custom_components/localthings/
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
__init__.py async_setup_entry / async_unload_entry
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
config_flow.py ClientHello port probe, UUID fetch, leaf cert minting, identity resolution
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
services.py write_resource/read_resource actions (device resolution, href translation)
services.yaml Selectors/descriptions for the two services above
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
One module per HA platform
catalog.py Reads the shipped translation catalog (which keys/states exist)
translations/ Config-flow copy + entity name/state translations, one file per
@@ -168,10 +237,16 @@ 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
@@ -194,6 +269,14 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
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.
+341 -32
View File
@@ -1,69 +1,378 @@
"""Local Things — Samsung appliance local control integration."""
from __future__ import annotations
import inspect
import logging
import re
from typing import Any
from homeassistant import const as ha_const
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator, snapshot_store
from .registry.identity import resolve_serial
from .rekey import rekey_entry
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
# UnitOfDensity is the non-deprecated home for this value from whichever HA
# release introduces it; CONCENTRATION_MICROGRAMS_PER_CUBIC_METER logs a
# removal warning (2027.8) on those releases every time it's accessed.
# hacs.json's floor (2025.1.0) predates UnitOfDensity existing at all, so
# this is a runtime getattr rather than a static import ty could only ever
# resolve against one HA generation -- see _relabel_particulate_statistics'
# own new_unit_class feature-detection below for the same pattern. The
# getattr short-circuits before the deprecated name is ever touched on a
# release new enough to have UnitOfDensity.
_unit_of_density = getattr(ha_const, "UnitOfDensity", None)
PARTICULATE_UNIT = (
_unit_of_density.MICROGRAMS_PER_CUBIC_METER
if _unit_of_density is not None
else ha_const.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Services are process-global, registered once here rather than per
# config entry (issue #300) -- see services.async_setup_services.
async_setup_services(hass)
return True
def _serial_from_unique_id(entry: ConfigEntry) -> str:
"""The device identity a pre-v2 entry was created with.
The config flow has always keyed the entry's unique_id on the serial the
probe read (`localthings_<serial>`), so that string is the identity the
entry's registry entries were minted from -- no need to reach the device
to recover it. Anything unrecoverable resolves to the host, matching
what the coordinator seeded such an entry with anyway.
The recovered string goes back through resolve_serial rather than being
taken at face value: entries created before the placeholder rules
(issues #83/#189) were keyed on the placeholder itself, while the
coordinator has since resolved those same boards to the host.
Re-keying onto the placeholder would reintroduce the collision those
issues are about -- two units of a family sharing the same placeholder
would share entity unique_ids again. A later wrinkle, same root cause:
for a stretch the flow wrote `host:port` while the coordinator wrote
`host`; collapsed here to the coordinator's form too.
"""
host = entry.data[CONF_HOST]
prefix = f"{DOMAIN}_"
unique_id = entry.unique_id or ""
if not unique_id.startswith(prefix):
return host
serial = unique_id[len(prefix) :]
if serial == f"{host}:{entry.data.get(CONF_PORT)}":
return host
return resolve_serial(serial, host)
@callback
def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: str) -> None:
"""Re-key registry entries this entry minted from the placeholder identity.
Before the identity moved onto the config entry, the coordinator seeded
its device key with the host and only replaced it after the first poll.
Anything that registered in between -- the connection-mode sensor
especially, added unconditionally rather than from `bound` -- was
written into the registry keyed on the IP permanently, orphaned the
moment the serial-keyed identity appeared (issue #236). Deleting the
orphans by hand didn't help: the next restart that lost the same race
recreated them.
"""
host = entry.data[CONF_HOST]
if serial == host:
# A board with no usable serial resolves to the host, so its keys
# were never placeholders.
return
rekey_entry(hass, entry, host, serial)
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
# and a unit in the release that introduced entry version 3. Deliberately
# not every family reading /sensors/vs/0: range_hood and airconditioner
# still declare no unit for their identically-named sensors, and relabelling
# their statistics to µg/m³ would assert a unit those entities don't report
# -- creating the very mismatch this migration exists to prevent.
_PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"})
# unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor
# key, optionally carrying a subdevice prefix and a trailing `_<n>` instance
# (registry/adapter._key, discovery.instance_suffix). Matching the tail rather
# than rebuilding the whole id keeps this working for a renamed entity, whose
# entity_id -- and so its statistic_id -- no longer follows from the key.
_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)(?:_\d+)?$")
@callback
def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Point existing particulate statistics at the unit they always were.
These sensors recorded long-term statistics with no unit, and the
release carrying this migration gives them µg/m³. Home Assistant treats
that as a unit change it can't convert and *suppresses statistics
generation entirely* for the entity until someone resolves the repair
(sensor.recorder._update_issues -> UNITS_CHANGED_ISSUE, and the matching
`continue` in its compile path). Silently freezing the history we just
finished labelling is the worst of both outcomes, so the metadata is
corrected up front instead.
Only the metadata row is rewritten, never the recorded values. The
readings were always µg/m³ concentrations (issue #325); what was missing
was the label, so there is nothing to convert and no way for this to
distort history. That is also why it uses
`async_update_statistics_metadata` and not `change_statistics_unit`,
which would scale every stored value.
A no-op when this device family isn't one that gained the unit, or when
the device never recorded any statistics -- the underlying UPDATE simply
matches no rows.
Returns False only when the recorder wasn't loaded, meaning the caller
should leave the entry on its old version and try again next start.
"""
if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3:
return True
if "recorder" not in hass.config.components:
# after_dependencies orders the recorder ahead of us when it's
# configured, so this is either an install without it (nothing to
# relabel, and the retry costs one set lookup per start) or a boot
# where it failed to come up. Not distinguishable here, and burning
# the one-shot migration on the second case would leave the
# statistics suppressed for good.
_LOGGER.debug("recorder not loaded, deferring statistics relabel")
return False
from homeassistant.components.recorder.statistics import (
STATISTIC_UNIT_TO_UNIT_CONVERTER,
async_update_statistics_metadata,
)
kwargs: dict[str, Any] = {"new_unit_of_measurement": PARTICULATE_UNIT}
# `new_unit_class` only exists from HA 2025.11; hacs.json still supports
# 2025.1, where passing it is a TypeError -- which would propagate out of
# async_migrate_entry and fail the whole entry. Where it is supported it
# must be named, since omitting it is deprecated from HA 2026.11. µg/m³
# has a converter, so the value is 'concentration' rather than None.
if "new_unit_class" in inspect.signature(async_update_statistics_metadata).parameters:
converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT)
kwargs["new_unit_class"] = converter.UNIT_CLASS if converter is not None else None
ent_reg = er.async_get(hass)
for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
if registry_entry.domain != "sensor":
continue
if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id):
continue
_LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id)
try:
async_update_statistics_metadata(hass, registry_entry.entity_id, **kwargs)
except Exception:
# Relabelling is a convenience: without it the user gets Home
# Assistant's own units_changed repair, which is where they were
# before this migration existed. Never worth failing setup over,
# so no recorder-side surprise can cost them the integration.
_LOGGER.warning(
"Could not relabel statistics unit for %s; Home Assistant will "
"offer a units-changed repair for it instead",
registry_entry.entity_id,
exc_info=True,
)
return True
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an entry to the current version.
v1 -> v2 stores the device's identity on the entry so the coordinator
can key its registry entries before the first poll (issue #236), and
repairs whatever the old placeholder-keyed registration already
orphaned.
v2 -> v3 relabels the recorded statistics for the particulate sensors,
which gained a device_class/unit in the same release (issue #325).
v3 -> v4 moves the entry off the serialNum as its identity and onto the
OCF device UUID (issue #381). Deliberately almost a no-op here: the
UUID lives on the device, and this runs before any I/O -- and before
the device is even known to be reachable, since an entry can load
entirely from its snapshot while the appliance is off (issue #295).
So the migration only guarantees CONF_SERIAL is populated, which is
what the coordinator rewrites *from* once a live poll finally hands it
a UUID to rewrite *to*.
The version is still bumped now rather than at that point, because the
bump's real job is the `entry.version > 4` downgrade guard below: an
entry re-keyed onto a UUID and then loaded by a release that reads
CONF_SERIAL as the key would silently orphan every entity it has.
"""
if entry.version > 4:
return False # downgrade: this release doesn't know the newer shape
if entry.version == 1:
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_SERIAL: serial},
unique_id=f"{DOMAIN}_{serial}",
version=2,
)
_repair_placeholder_keys(hass, entry, serial)
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
if entry.version == 2 and _relabel_particulate_statistics(hass, entry):
hass.config_entries.async_update_entry(entry, version=3)
_LOGGER.debug("migrated entry %s to version 3", entry.entry_id)
if entry.version == 3:
# `or host` mirrors what the coordinator has always fallen back to,
# so the recorded legacy key is the one this entry's registry
# entries were actually minted under even if CONF_SERIAL never got
# written (a v1 entry whose migration predates it). Both being
# absent shouldn't happen for an entry the config flow created, but
# an exception raised here fails the whole entry -- so it bumps the
# version and leaves the data alone rather than taking that risk
# for a value the coordinator re-derives on its next poll anyway.
legacy_key = entry.data.get(CONF_SERIAL) or entry.data.get(CONF_HOST)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_SERIAL: legacy_key} if legacy_key else entry.data,
version=4,
)
_LOGGER.debug(
"migrated entry %s to version 4 (legacy key=%s, awaiting a poll to adopt "
"the OCF device id)",
entry.entry_id,
legacy_key,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
coordinator = LocalThingsCoordinator(hass, entry)
# Before the first refresh, so the coordinator keeps rescheduling even
# when that refresh fails and leaves nothing subscribed: the base class
# only re-arms its timer while it has listeners, and an offline load can
# legitimately have zero live entities (every one of them disabled, say).
# Without this the entry loads once and never polls again (issue #295).
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
except ConfigEntryNotReady:
# An entry that has polled successfully before comes up on its last
# known entity set and keeps retrying on the normal poll interval,
# rather than sitting in setup-retry with a device that reads as
# broken and entities that exist only as registry rows (issue #295).
#
# An entry that has never reached the device has no snapshot, so
# there is nothing to show and no device metadata to name it with --
# that case still fails, which is also what keeps the door open for
# setup flows that need to interact with the device (issue #168).
if not await coordinator.async_rehydrate():
await coordinator.async_close()
raise
hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
# #254): HA doesn't unload entries on a plain Core restart, so a restart
# left the previous run's association orphaned, making the next
# handshake time out. Complements the fixed source port, which covers
# the unclean-exit case this can't.
#
# A coroutine listener, not one that spawns its own task: the event bus
# runs it as a hass-tracked job, awaited by `async_block_till_done()`
# inside `hass.async_stop`. A detached task would likely be cancelled
# mid-shutdown -- the exact case this exists to prevent.
async def _async_close_on_stop(_event: Event) -> None:
await coordinator.async_close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
)
# Nothing else reacts to an options-flow save (issue #364): the cloud-
# courses Repair and canonical-view cache are only refreshed from
# _on_cloud_courses_changed, which only runs when a poll/observe
# actually reports new data. Without this, turning "Download cycles"
# off would leave an already-open Repair sitting there -- and an
# already-named program still offered as a cycle -- until the next
# unrelated change happened to fire it, indistinguishable from the
# toggle not working. Every other option (CONF_LEARN_MODES,
# CONF_BYPASS_REMOTE_CONTROL, ...) is read live on its own next use and
# has no comparable standing state to refresh, so this listener exists
# for cloud courses specifically rather than as a general
# options-changed hook. Must be a coroutine function -- HA wraps
# whatever an update listener returns in async_create_task, which raises
# on a plain callback's None.
async def _async_options_updated(_hass: HomeAssistant, _entry: ConfigEntry) -> None:
coordinator._on_cloud_courses_changed()
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry,
hass: HomeAssistant,
entry: ConfigEntry,
device: dr.DeviceEntry,
) -> bool:
"""Allow deleting a device this entry no longer provides (issue #214).
Defining this at all is what makes Home Assistant offer the "Delete
device" action for our devices; without it a device registry entry
belonging to a loaded config entry can never be removed from the UI. That
matters because a subdevice's HA device outlives the discovery that
created it: a candidate that materialized under an older release (issue
#214's phantom second air conditioner, born from an unused /device/1 slot
reporting the appliance's energy counter -- see
registry/subdevices.py's liveness gate) leaves a device entry behind that
nothing recreates and nothing cleans up once the gate stops materializing
it. Same for a sibling that a firmware update stops exposing.
Defining this at all is what makes HA offer the "Delete device" action;
without it, a device belonging to a loaded config entry can never be
removed from the UI. That matters because a subdevice's HA device
outlives the discovery that created it: a candidate materialized under
an older release (issue #214's phantom second air conditioner, born
from an unused slot reporting the appliance's energy counter -- see
registry/subdevices.py's liveness gate) leaves a device entry nothing
recreates or cleans up once the gate stops materializing it. Same for a
sibling a firmware update stops exposing.
Removal is refused for devices this entry *does* currently provide --
HA would recreate them on the next entity add, so allowing it would look
like the delete silently failed. Deliberately no automatic pruning at
discovery time: subdevice enumeration is one-shot and a sibling can fail
to answer for a poll (issue #205 is exactly that on the reference
hardware), so auto-removal would throw away a real subdevice's name,
area and automation references on a transient miss. The user gets the
button; the integration doesn't guess.
Removal is refused for devices this entry does currently provide -- HA
would recreate them on the next entity add. Deliberately no automatic
pruning at discovery time: a sibling can fail to answer for a single
poll (issue #205), so auto-removal would throw away a real subdevice's
name/area/automations on a transient miss. The user gets the button;
the integration doesn't guess.
"""
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(
entry.entry_id
)
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if coordinator is None:
# Entry not loaded (or already unloaded) -- nothing is claiming this
# device, so there's nothing to protect it from being removed.
return True
live = set(coordinator.device_info.get('identifiers') or set())
return True # entry not loaded -- nothing claims this device
live = set(coordinator.device_info.get("identifiers") or set())
for subdevice in coordinator.subdevices:
live |= set(coordinator.device_info_for(subdevice).get('identifiers') or set())
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
return not (device.identifiers & live)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Delete the discovery snapshot this entry accumulated (issue #295).
Nothing else would: the store is keyed on entry_id, so re-adding the same
appliance mints a new one and the old file would linger in .storage
forever.
"""
await snapshot_store(hass, entry).async_remove()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
@@ -1,16 +1,16 @@
"""Binary sensor platform for Local Things."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import BinarySensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import BinarySensorDesc
async def async_setup_entry(
@@ -27,11 +27,12 @@ async def async_setup_entry(
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: BinarySensorDesc = bound.desc
self._attr_device_class = desc.device_class
self._attr_device_class = (
BinarySensorDeviceClass(desc.device_class) if desc.device_class else None
)
@property
def is_on(self):
+2 -3
View File
@@ -1,4 +1,5 @@
"""Button platform for Local Things."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
@@ -6,11 +7,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ButtonDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import ButtonDesc
async def async_setup_entry(
@@ -27,7 +27,6 @@ async def async_setup_entry(
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._payload = bound.desc.payload
+4 -3
View File
@@ -20,6 +20,7 @@ states exist, which is something the Python side genuinely needs to know:
Reading it from the catalog instead of restating it in Python means adding a
state or a course table is a one-file change, and the two can't drift.
"""
from __future__ import annotations
import json
@@ -28,8 +29,8 @@ from pathlib import Path
# Read at import, not lazily: custom integrations are imported in an executor
# thread, so this stays off the event loop no matter who asks first.
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
).get('entity', {})
(Path(__file__).parent / "translations" / "en.json").read_text(encoding="utf-8")
).get("entity", {})
def has_entity_translation(platform: str, translation_key: str) -> bool:
@@ -46,4 +47,4 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
leave the device's value untouched.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get('state', ())) if entry else frozenset()
return frozenset(entry.get("state", ())) if entry else frozenset()
+389 -225
View File
@@ -1,176 +1,205 @@
"""Climate platform for Local Things.
The first composite entity in this integration: a single HA climate card that
unifies several OCF resources of a Samsung air conditioner. Unlike every other
platform here (one descriptor -> one resource field), a climate entity reads
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
direction) and the convenient-mode preset from *different* resources.
The first composite entity in this integration: a single HA climate card
that unifies several OCF resources of a Samsung air conditioner. Unlike
every other platform here (one descriptor -> one resource field), a climate
entity reads power, HVAC mode, current/target temperature, fan (wind)
strength, swing (wind direction) and the convenient-mode preset from
*different* resources.
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry
still tracks it, and reads the sibling resources straight from the coordinator
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
`number.py` (live range/unit) and `select.py` (options callable) already do.
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the
registry still tracks it, and reads the sibling resources straight from the
coordinator snapshot via `coordinator.resource(href)`.
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and
applies the optimistic value/settle guard to that same href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
back for, power, mode, temperature and wind resources alike.
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
the CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the
right `(path_segs, body)`, and `async_send_command` applies the optimistic
value/settle guard to that resource's own href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes across power, mode,
temperature and wind resources alike.
"""
from __future__ import annotations
import asyncio
import logging
from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
PRESET_NONE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ClimateDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.airconditioner import (
HREF_AIRFLOW as AIRFLOW_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_CONVENIENT as CONVENIENT_HREF,
)
# The AC's canonical resource hrefs live in the capability module (the single
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
# POWER_VS_FALLBACK.
from .registry.capabilities.airconditioner import (
HREF_MODE as MODE_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_POWER as POWER_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_POWER_VS as POWER_VS_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMPS_VS as TEMPS_VS_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
HREF_AIRFLOW as AIRFLOW_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
_temperature_step,
extend_option_code_bit,
has_extend_option_code,
has_option_code,
is_legacy_board,
option_code_bit,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import ClimateDesc
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# --- device code <-> HA value maps -----------------------------------------
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
# driven by the power resource).
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
'Cool': HVACMode.COOL,
'Dry': HVACMode.DRY,
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
# reverse write can't rely on this map alone (two codes, one HA value) --
# _device_code_for_hvac() resolves the code from the unit's own
# supportedModes, so this is only a fallback for a unit reporting no
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse
# comprehension below has 'Wind' win that fallback (last-key-wins),
# preserving the original single-spelling behavior rather than silently
# flipping it when 'Fan' was added.
'Fan': HVACMode.FAN_ONLY,
'Wind': HVACMode.FAN_ONLY,
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
# and implies a two-setpoint heat+cool range these single-setpoint units
# (including cool-only models) don't have.
'Auto': HVACMode.AUTO,
'Heat': HVACMode.HEAT,
"Cool": HVACMode.COOL,
"Dry": HVACMode.DRY,
# Fan-only is spelled 'Wind' on some boards and 'Fan' on others; both map
# to FAN_ONLY. _device_code_for_hvac() resolves the write-side code from
# the unit's own supportedModes, so this reverse map is only a fallback
# for a unit with no supportedModes at all. 'Fan' listed first so the
# {v: k} comprehension below has 'Wind' win that fallback (last-key-wins,
# preserving the original single-spelling behavior).
"Fan": HVACMode.FAN_ONLY,
"Wind": HVACMode.FAN_ONLY,
# A single-setpoint "device decides" mode -> HA AUTO, not HEAT_COOL
# (which implies a two-setpoint heat+cool range these units don't have).
"Auto": HVACMode.AUTO,
"Heat": HVACMode.HEAT,
}
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
# entered/left only through the preset, since there's no dedicated HVACMode
# value for it to write back to.
_AI_COMFORT_MODE = 'AIComfort'
PRESET_AI_COMFORT = 'ai_comfort'
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON): 'AIComfort'
# isn't a distinct thermodynamic operation like Cool/Dry/Heat, it's an AI
# overlay on the device's own 'Auto' -- the unit reports both as separate,
# mutually-exclusive supportedModes entries. hvac_mode reports AUTO (same as
# plain 'Auto') and a dedicated 'ai_comfort' preset carries the distinction.
# Not reachable via async_set_hvac_mode -- entered/left only through the
# preset, since there's no HVACMode value for it to write back to.
_AI_COMFORT_MODE = "AIComfort"
PRESET_AI_COMFORT = "ai_comfort"
# Codes in /mode/vs/0's supportedModes that are option/capability flags, not
# selectable thermodynamic operations -- dropped silently rather than
# tripping the issue #93 unmapped-code warning on every start.
#
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K) also appears in
# /configuration/vs/0's airconOptionList alongside other capability flags,
# and the unit's own current `modes` never reported it active -- consistent
# with an echoed capability flag, not a genuine mode. Unlike _AI_COMFORT_MODE,
# not modeled as a preset either: nothing confirms it's user-selectable.
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
# Seconds to let a legacy board settle into Cool before the WindFree token is
# written after it -- see _legacy_preset_needs_cool for the measurement.
_NANO_AFTER_MODE_DELAY = 3
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
'0': 'auto',
'1': 'low',
'2': 'medium',
'3': 'high',
'4': 'turbo',
"0": "auto",
"1": "low",
"2": "medium",
"3": "high",
"4": "turbo",
}
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
_DEVICE_TO_SWING: dict[str, str] = {
'Fix': 'off',
'All': 'both',
'Up_And_Low': 'vertical',
'Left_And_Right': 'horizontal', # issue #75
"Fix": "off",
"All": "both",
"Up_And_Low": "vertical",
"Left_And_Right": "horizontal", # issue #75
}
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
# above (off/vertical/horizontal/both), just read from/written to a pair
# of fields rather than a single one.
# Swing fallback via /wind/oscillation/vs/0 (issue #126): boards without
# WIND_DIRECTION_HREF report two independent Swing|Fix toggles instead of
# one combined code. Same HA vocabulary as _DEVICE_TO_SWING above.
def _oscillation_swing(rep: dict) -> str | None:
vertical = rep.get('vertical')
horizontal = rep.get('horizontal')
vertical = rep.get("vertical")
horizontal = rep.get("horizontal")
if vertical is None and horizontal is None:
return None
v = vertical == 'Swing'
h = horizontal == 'Swing'
v = vertical == "Swing"
h = horizontal == "Swing"
if v and h:
return 'both'
return "both"
if v:
return 'vertical'
return "vertical"
if h:
return 'horizontal'
return 'off'
return "horizontal"
return "off"
def _wind_strength_label(code, rep: dict) -> str:
"""Human label for a /wind/strength/vs/0 code from the device's own
modesName array (parallel-indexed with supportedModes), lowercased for
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
preset_mode's dynamic code->str resolution. Falls back to the raw code
lowercased when modesName is absent or misaligned."""
supported = rep.get('x.com.samsung.da.supportedModes') or []
names = rep.get('x.com.samsung.da.modesName') or []
modesName array (parallel-indexed with supportedModes), lowercased --
used only for codes _DEVICE_TO_FAN doesn't already cover (issue #155:
a board using codes "0"/"31"-"35" instead of the "0"-"4" scale
_DEVICE_TO_FAN was built from, with modesName giving the real labels).
Falls back to the raw code lowercased when modesName is absent or
misaligned."""
supported = rep.get("x.com.samsung.da.supportedModes") or []
names = rep.get("x.com.samsung.da.modesName") or []
if code in supported and len(names) == len(supported):
return str(names[supported.index(code)]).lower()
return str(code).lower()
# Preset (convenient mode): resolved dynamically from the device's own
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
# exposed as its lowercased self and labelled in translations
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>),
# so any board's convenient modes surface without code changes, and an
# unlabelled code just renders as its raw value until a label is added.
# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
# translation label, not a hard-coded mode.)
# /mode/convenient/vs/0 supportedModes -- no per-model table. Device 'Off'
# maps to PRESET_NONE; every other code is exposed lowercased and labelled
# in translations, so any board's convenient modes surface without code
# changes, and an unlabelled code renders as its raw value.
def _preset_to_ha(code) -> str:
return PRESET_NONE if code == 'Off' else str(code).lower()
return PRESET_NONE if code == "Off" else str(code).lower()
async def async_setup_entry(
@@ -204,14 +233,13 @@ def _num(value):
def _temps_vs_item(rep: dict) -> dict:
"""First item of the vendor `/temperatures/vs/0` items[] array.
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
/temperature/current/0 + /temperature/desired/0 pair; it reports current
and target under a single `/temperatures/vs/0` resource whose
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
increment/unit. Returns {} when absent, so callers fall through cleanly.
Newer AC firmware (Tizen Lite) doesn't expose the OCF-standard
/temperature/current/0 + /temperature/desired/0 pair; it packs current/
desired/minimum/maximum/increment/unit into this one resource's
items[0] instead. Returns {} when absent, so callers fall through
cleanly.
"""
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
@@ -220,16 +248,12 @@ def _temps_vs_item(rep: dict) -> dict:
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
"""Composite climate entity for a Samsung air conditioner."""
# translation_key comes from the ClimateDesc (base __init__ sets
# _attr_translation_key from bound.desc), resolving the state_attributes
# translations under entity.climate.airconditioner.
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
# Opts out of the deprecated auto-added TURN_ON/OFF backwards compat.
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# Primary/main entity for the device: no name suffix, just the device name.
self._attr_name = None
self._attr_name = None # primary entity: no name suffix
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
@@ -239,9 +263,8 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
| ClimateEntityFeature.TURN_OFF
)
# (href, raw device code) pairs already logged by _warn_unmapped --
# these properties are read on every coordinator refresh, so an
# un-deduped warning would spam the log for any device with a
# genuinely unrecognized code.
# these properties are read on every refresh, so an un-deduped
# warning would spam the log for a genuinely unrecognized code.
self._warned_unmapped: set[tuple[str, str]] = set()
# -- resource helpers ---------------------------------------------------
@@ -249,93 +272,199 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
# through its cloud integration and reading the local token back each time:
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
_LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed')
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
# Good Sleep occupies the same Comode_ slot as the presets above, so a unit
# running it reports Comode_Sleep -- or Comode_NanoSleep, which the board
# will produce by itself when nano wind is asked for while the timer runs.
# Neither is in the list above, and a preset_mode outside preset_modes is
# not a state HA allows, so they are added for boards that have the Sleep_
# token these codes come with.
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
# HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode
# this transcription has never seen, which is the same "cannot judge" case as
# a board that publishes no capability map -- and gets the same fallback.
_LEGACY_KNOWN_HVAC = frozenset(
{"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE}
)
# Which comfort modes a legacy board offers in which HVAC mode, and which of
# them it has at all. Both come from the appliance rather than from a table
# per model: the unit publishes its capabilities as two bit maps in
# /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates
# each item on a bit plus the current mode. _legacy_preset_codes() below is
# that logic, transcribed from the app's updateOptionsList() so the two can be
# compared line by line, with the bit each rule reads named in the comment.
#
# Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off
# the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model)
# Auto but never Heat, and Fast Turbo and Comfort in Heat because oc[12] is
# set. d'light Cool is gated the same way on oc[2], which is zero here -- and
# the appliance refuses the token locally too.
#
# Single User is deliberately not modelled, though the app does gate it on
# oc[3] / oc[11]: it has no token of its own. The app's own Single User
# command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded
# 24 desired alongside it, so there is nothing to write that would be
# distinguishable from the Smart preset below, and no name to give it that
# the appliance would recognise.
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
"""Comfort-mode codes this unit offers in this HVAC mode.
Derived only for boards that publish *both* capability maps. One map on
its own is not enough: every eoc-gated rule would then read None, and
None means "this board does not publish the map", never "the feature is
absent". The FAC/CAC boards on record carry only the older map, with
values small enough that RAC bit positions all read as zeros, so
requiring both also keeps these rules inside the family they were
documented for.
Within the derived path, a bit that cannot be read (a malformed or
over-wide token) is treated as permission rather than denial for the
codes the unconditional list already carried -- losing a working preset
to a parsing failure is worse than offering one too many. Codes that were
never in that list (d'light) still need their bit to be explicitly set.
"""
rep = self._rep(MODE_HREF)
if (
not has_option_code(rep)
or not has_extend_option_code(rep)
or hvac not in self._LEGACY_KNOWN_HVAC
):
return list(self._LEGACY_PRESET_CODES)
cool = hvac in ("Cool", _AI_COMFORT_MODE)
heat = hvac in ("Heat", "HeatClean")
codes = ["Off"]
# WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto
# unless this is an 18K model (eoc[30]), where the app switches to Cool for
# it instead -- which is what _legacy_preset_needs_cool does.
nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE
if hvac == "Auto":
nano_mode_ok = extend_option_code_bit(rep, 30) is not False
if extend_option_code_bit(rep, 31) is not False and nano_mode_ok:
codes.append("Nano")
if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo
codes.append("Speed")
if cool:
codes.append("2Step")
if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set
codes.append("DlightCool")
# Quiet reads oc[10] with no mode condition in the app, but the owner of
# the unit above sees it in Cool and Heat only, on the remote as well as
# in the app -- the observation wins over the reading.
if option_code_bit(rep, 10) is not False and (cool or heat):
codes.append("Quiet")
if cool or (heat and option_code_bit(rep, 12) is not False): # Comfort
codes.append("Comfort")
# Smart Saver has no bit of its own and the app hides it from every single
# RAC outright (showSaverOption = false), yet the appliance accepts it and
# behaves as Samsung documents -- so absence from the app is not absence
# from the hardware. Cool-only, per that documentation.
if hvac == "Cool":
codes.append("Smart")
# Good Sleep, which shares this slot: the app enables it in Cool, Heat and
# AIComfort only. Both codes, because the board turns Sleep into NanoSleep
# by itself when WindFree is running.
if (cool or heat) and any(
isinstance(option, str) and option.startswith("Sleep_") for option in options
):
codes += self._LEGACY_SLEEP_PRESET_CODES
return codes
def _legacy_convenient(self) -> dict:
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
/mode/vs/0's options, for boards that have no convenient resource."""
options = self._rep(MODE_HREF).get('x.com.samsung.da.options') or []
for option in options:
if isinstance(option, str) and option.startswith('Comode_'):
return {_MODES_FIELD: [option.split('_', 1)[1]],
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)}
return {}
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
active = next(
(o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")),
None,
)
if active is None:
return {}
codes = self._legacy_preset_codes(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options)
# Whatever the unit is actually running has to be listed whether the
# rules expect it there or not -- a preset_mode outside preset_modes is
# not a state HA allows, and the appliance has the last word on what it
# is doing (a remote can put it in a mode these rules would not offer).
if active not in codes:
codes.append(active)
return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes}
def _legacy_airflow(self) -> dict:
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to
use -- i.e. this board has no /wind/strength/vs/0.
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel
to use -- i.e. this board has no /wind/strength/vs/0.
Delegates the board-generation test to is_legacy_board (the same
test capabilities/airconditioner.py's token entities are gated on)
instead of re-implementing it. Uses self._resources (this unit's own
canonical view, issue #177 -- see LocalThingsEntity._resources)
rather than a two-key presence dict built from coordinator.resource()'s
test the token entities in capabilities/airconditioner.py use)
instead of re-implementing it, using self._resources (issue #177)
rather than a presence dict built from coordinator.resource()'s
truthiness -- resource() 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. A
presence dict built from truthiness alone would disagree with the
token entities on a board reporting a genuinely empty /airflow/vs/0,
silently reintroducing the drift this delegation exists to prevent.
Reads the actual href through self._rep rather than
coordinator.resource() directly -- on a subdevice (a legacy-board
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
canonical AIRFLOW_HREF must be translated through this bound
entity's own subdevice first, exactly like every other sibling read
below.
but empty" to the same falsy value, while is_legacy_board tests key
membership. Reads through self._rep, not coordinator.resource()
directly, so a subdevice's own /airflow/vs/1 gets translated first,
like every other sibling read below.
"""
if not is_legacy_board(self._resources):
return {}
return self._rep(AIRFLOW_HREF)
def _legacy_preset(self) -> bool:
"""Whether presets come from the Comode_* token rather than a resource.
"""Whether presets come from the Comode_* token rather than a
resource. Gated on the same board test as _legacy_airflow, not on
the convenient rep being empty alone: newer boards carry Comode
tokens too, so a momentarily empty /mode/convenient/vs/0 must not
silently switch the preset path over.
Gated on the same board test as _legacy_airflow, not on the convenient
rep being empty alone: newer boards carry Comode tokens too, so a
momentarily empty /mode/convenient/vs/0 there must not silently switch
the preset read (and write) over to the token path.
Deliberately reads the *raw* href (translated through this bound
entity's own subdevice, not through self._rep) rather than going
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
is exactly the legacy_convenient() rep this method is deciding
whether to use, so routing through it here would make the resource
never look empty and this always resolve to the wrong side.
Reads the raw href directly rather than through self._rep's own
CONVENIENT_HREF fallback -- that fallback IS the legacy_convenient()
rep this method is deciding whether to use, so routing through it
would make the resource never look empty.
"""
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
return (not self.coordinator.resource(convenient_href)
and bool(self._legacy_airflow()))
return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own subdevice (issue #177) to
the real, on-the-wire href before the single-href cache lookup
(identity for MAIN, so a device with no subdevices reads exactly the
href it always did)."""
"""`href` is one of this module's canonical HREF_* constants,
translated through this bound entity's own subdevice (issue #177)
to the real on-the-wire href -- identity for MAIN."""
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
return self._legacy_convenient()
return rep
def _is_on(self) -> bool:
# Prefer the vendor /power/vs/0 (present on every observed board and
# the resource writes target -- see airconditioner._climate_write).
# The OCF /power/0 is absent on many boards and a stale mirror on
# some, so reading it first showed pre-write state after a power
# toggle (issue #53: "can turn on but not off").
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
# Prefer the vendor /power/vs/0 -- the OCF /power/0 is absent on many
# boards and a stale mirror on some, so reading it first showed
# pre-write state after a power toggle (issue #53).
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
return str(power).lower() == "on"
return bool(self._rep(POWER_HREF).get("value"))
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
"""The resource's own supportedModes, plus any code this unit has
been seen in but never advertised (issue #327, learned.py).
Both the option lists (preset_modes, fan_modes, ...) and the write
paths resolve codes through here, so a learned code is selectable
and writable by virtue of appearing in one list.
"""
supported = list(self._rep(href).get(_SUPPORTED_FIELD) or [])
learned = self.coordinator.learned_modes(self._bound.subdevice.to_actual(href))
return supported + [code for code in learned if code not in supported]
def _warn_unmapped(self, href: str, code: str) -> None:
"""Log once per (href, code) when a device-reported mode has no
entry in the relevant device<->HA map, so a real device gap surfaces
in the log instead of silently vanishing (issue #93)."""
entry in the relevant device<->HA map, so a real gap surfaces in
the log instead of silently vanishing (issue #93).
Falls back to `unique_id` when `entity_id` is unset (issue #235):
this can fire during setup's first discovery pass, before the
entity is added to hass, when entity_id is still None."""
key = (href, code)
if key in self._warned_unmapped:
return
@@ -343,7 +472,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
_LOGGER.warning(
"%s: device mode %r on %s has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code, href,
self.entity_id or self.unique_id,
code,
href,
)
def _read_mode(self, href: str, mapping: dict):
@@ -365,11 +496,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _ocf_temp_authoritative(self) -> bool:
"""True when the OCF /temperature/{current,desired}/0 pair is the
authoritative temperature channel -- signalled by
/temperature/current/0 being present. Those boards honour reads/
writes on /temperature/desired/0 and ignore the vendor
/temperatures/vs/0; boards without the pair (only a desired stub, or
nothing) are the reverse. Confirmed on live units of both kinds."""
authoritative channel, signalled by /temperature/current/0 being
present. Those boards honor reads/writes on /temperature/desired/0
and ignore the vendor /temperatures/vs/0; boards without the pair
are the reverse. Confirmed on live units of both kinds."""
return bool(self._rep(TEMP_CURRENT_HREF))
def _temps_vs(self) -> dict:
@@ -378,18 +508,20 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMP_DESIRED_HREF).get('units')
raw = self._rep(TEMP_DESIRED_HREF).get("units")
if raw is None:
raw = self._temps_vs().get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
raw = self._temps_vs().get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
@property
def current_temperature(self):
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
v = _num(self._rep(TEMP_CURRENT_HREF).get("temperature"))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
v = _num(self._temps_vs().get("x.com.samsung.da.current"))
return v
@property
@@ -397,19 +529,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# Read from the same channel writes go to (see async_set_temperature):
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
# /temperatures/vs/0 otherwise -- with the other as fallback.
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
ocf = _num(self._rep(TEMP_DESIRED_HREF).get("temperature"))
vs = _num(self._temps_vs().get("x.com.samsung.da.desired"))
if self._ocf_temp_authoritative():
return ocf if ocf is not None else vs
return vs if vs is not None else ocf
def _range(self) -> list | None:
r = self._rep(TEMP_DESIRED_HREF).get('range')
r = self._rep(TEMP_DESIRED_HREF).get("range")
if isinstance(r, (list, tuple)) and len(r) == 2:
return r
item = self._temps_vs()
lo = _num(item.get('x.com.samsung.da.minimum'))
hi = _num(item.get('x.com.samsung.da.maximum'))
lo = _num(item.get("x.com.samsung.da.minimum"))
hi = _num(item.get("x.com.samsung.da.maximum"))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
@@ -424,10 +556,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def target_temperature_step(self) -> float:
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
or 1.0)
# Shared with the write path (airconditioner._climate_write) so a
# step read here always matches the step a write is quantized to --
# self._resources is this entity's own subdevice's canonical view
# (issue #177), the same shape _temperature_step expects.
return _temperature_step(self._resources) or 1.0
# -- hvac mode ----------------------------------------------------------
@@ -438,7 +571,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
if device == _AI_COMFORT_MODE:
return HVACMode.AUTO
if device is not None and device not in _DEVICE_TO_HVAC:
if (
device is not None
and device not in _DEVICE_TO_HVAC
and device not in _NON_HVAC_OPTION_CODES
):
self._warn_unmapped(MODE_HREF, device)
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
@@ -446,7 +583,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def hvac_modes(self) -> list[HVACMode]:
modes = [HVACMode.OFF]
for m in self._supported(MODE_HREF):
if m == _AI_COMFORT_MODE:
if m == _AI_COMFORT_MODE or m in _NON_HVAC_OPTION_CODES:
continue
mapped = _DEVICE_TO_HVAC.get(m)
if mapped is None:
@@ -462,7 +599,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def fan_mode(self):
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel')))
return _DEVICE_TO_FAN.get(str(airflow.get("x.com.samsung.da.speedLevel")))
rep = self._rep(WIND_STRENGTH_HREF)
code = _first(rep.get(_MODES_FIELD))
if code is None:
@@ -492,7 +629,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def swing_mode(self):
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction'))
return _DEVICE_TO_SWING.get(airflow.get("x.com.samsung.da.direction"))
if self._swing_via_direction():
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
@@ -534,40 +671,38 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
return _HVAC_TO_DEVICE.get(hvac_mode)
async def async_set_temperature(self, **kwargs) -> None:
# HA's set_temperature service forwards an optional hvac_mode here; honour
# it (set the mode first -- that also powers the unit on when it was off),
# matching the climate contract other integrations follow. Without this a
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
# Auto 24" button) set the setpoint but never changed mode or powered on.
hvac_mode = kwargs.get('hvac_mode')
# HA's set_temperature service can carry an optional hvac_mode;
# honor it (setting the mode also powers the unit on) so a dashboard
# "turn on to Auto 24" button doesn't set the setpoint alone.
hvac_mode = kwargs.get("hvac_mode")
if hvac_mode is not None:
await self.async_set_hvac_mode(hvac_mode)
if hvac_mode == HVACMode.OFF:
return
temp = kwargs.get('temperature')
temp = kwargs.get("temperature")
if temp is None:
return
# OCF-pair boards write /temperature/desired/0; vendor boards write
# /temperatures/vs/0 (see airconditioner._climate_write).
kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature'
kind = "temperature_ocf" if self._ocf_temp_authoritative() else "temperature"
await self.coordinator.async_send_command(self._bound, (kind, temp))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
return
device = self._device_code_for_hvac(hvac_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
async def async_turn_on(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ("power", True))
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
"""Map an HA fan/swing/preset value back to its device code and write it."""
@@ -579,18 +714,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
if self._legacy_airflow():
level = _FAN_TO_DEVICE.get(fan_mode)
if level is not None:
await self.coordinator.async_send_command(
self._bound, ('fan_legacy', level))
await self.coordinator.async_send_command(self._bound, ("fan_legacy", level))
return
supported = self._supported(WIND_STRENGTH_HREF)
device = _FAN_TO_DEVICE.get(fan_mode)
# A static hit is only trustworthy if this unit's own supportedModes
# actually includes that code -- a board can use non-standard codes
# (issue #155's "31"-"35") while still spelling a standard label
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
# return a plausible-looking code ('1'/'3') the device never
# advertised at all. Fall through to the live scan whenever the
# static guess isn't actually one of this unit's own codes.
# includes that code -- a board can use non-standard codes (issue
# #155) while still spelling a standard label in modesName, so the
# static guess could be a plausible code the device never
# advertised. Fall through to the live scan when it isn't one of
# this unit's own codes.
if device is None or (supported and device not in supported):
rep = self._rep(WIND_STRENGTH_HREF)
for code in supported:
@@ -598,33 +731,64 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
device = code
break
if device is not None:
await self.coordinator.async_send_command(self._bound, ('fan', device))
await self.coordinator.async_send_command(self._bound, ("fan", device))
async def async_set_swing_mode(self, swing_mode: str) -> None:
if self._legacy_airflow():
code = _SWING_TO_DEVICE.get(swing_mode)
if code is not None:
await self.coordinator.async_send_command(
self._bound, ('swing_legacy', code))
await self.coordinator.async_send_command(self._bound, ("swing_legacy", code))
return
if self._swing_via_direction():
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
await self._set_mapped("swing", _SWING_TO_DEVICE, swing_mode)
return
if self._rep(WIND_OSCILLATION_HREF):
await self.coordinator.async_send_command(
self._bound, ('oscillation', swing_mode))
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
async def _legacy_preset_needs_cool(self, code: str) -> None:
"""Switch a legacy board to Cool first when the preset needs it.
WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit
is in Auto is answered 2.04 Changed and dropped (measured, still
Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST
does not help -- the mode moves and the token is still dropped, so the
board judges the option against the mode it was in. Sent as its own write
first, it holds. The appliance's own app pairs `modes: Cool` with its nano
command for the same reason.
Auto only. The app's builder also covers AIComfort, but its
`updateOptionsList()` disables the WindFree button there outright, so that
pairing can never fire -- and `_legacy_preset_codes()` likewise does not
offer `Nano` in AIComfort, which would leave such a branch unreachable.
The pause is measured, not padding: back to back (same session, no gap at
all) the token was dropped again, two seconds apart it held. Three is that
with a little margin, and it only ever runs for this one preset in this
one HVAC mode.
"""
if not self._legacy_preset() or code != "Nano":
return
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto":
return
# Same resolver the rest of the platform uses -- the device code for an HA
# mode is read off the unit's own supportedModes rather than assumed.
await self.coordinator.async_send_command(
self._bound, ("mode", self._device_code_for_hvac(HVACMode.COOL))
)
await asyncio.sleep(_NANO_AFTER_MODE_DELAY)
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_AI_COMFORT:
# Writes the primary mode resource, not the convenient one --
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
await self.coordinator.async_send_command(self._bound, ("mode", _AI_COMFORT_MODE))
return
# Reverse-resolve against the unit's own supportedModes (codes aren't
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
for code in self._supported(CONVENIENT_HREF):
if _preset_to_ha(code) == preset_mode:
kind = 'preset_legacy' if self._legacy_preset() else 'preset'
await self._legacy_preset_needs_cool(code)
kind = "preset_legacy" if self._legacy_preset() else "preset"
await self.coordinator.async_send_command(self._bound, (kind, code))
return
@@ -0,0 +1,377 @@
"""Cloud "Download" programs on a laundry device (issue #342).
Some course tables carry a course whose recipe isn't fixed in firmware --
selecting it runs whichever program was last pushed down from the
SmartThings cloud ("Download" / "Downloaded" in the course catalog). Three
tokens on ``/course/vs/0``'s ``x.com.samsung.da.options`` array drive it:
``CloudExtraCourse_<slot><slot>...`` the device's own list of downloaded
program slots, one byte each -- the cloud counterpart of
``EditCourseList_`` for local courses.
``CloudCourse_<blob>`` the persisted default program.
``OneTimeCloudCourse_<blob>`` a this-run-only override.
A ``<blob>`` is an opaque fixed-width payload whose byte 2 is the slot id
it belongs to. Confirmed on two independent DA_WM_TP1_21_COMMON washers:
the issue #342 reporter's, whose ``CloudExtraCourse_0A5C286B2D0C55301A``
enumerates nine slots matching byte 2 of all nine of its programs exactly,
and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot
``CloudExtraCourse_5958`` likewise matches its ``CloudCourse`` blob's byte
2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one
reason nothing here ever synthesizes one.
``CloudExtraCourse_`` does not mean the same thing on every family, so
nothing keys off it directly -- see ``cloud_slots``, which is what the rest
of this module and its callers gate on. Even then, a device can advertise a
slot whose payload has never been observed, so ``cloud_slots`` answers
"which exist" while the store answers "which are usable"; the two are
deliberately allowed to disagree.
What this module does and deliberately does not do
--------------------------------------------------
The device advertises *which* slots exist but never what any of them is
called, and never the full blob for a slot other than the one currently
loaded. A blob is only observable while the device happens to be sitting on
that program, so the full payload is *learned by observation* and persisted
(same rationale as learned.py's mode store), and the human-readable name is
supplied by the user in the options flow. Nothing is hardcoded: no catalog
of program ids, no table of blobs, no assumed Download course code. A
hardcoded catalog was considered and rejected -- a blob is cloud-assigned
per account/region, so one user's captured payload is not evidence about
anyone else's device.
Blobs are replayed byte-for-byte, exactly as captured, and never
decomposed or rebuilt. (Bytes 5/7/9 of the reporter's blobs do decode
cleanly to that program's temperature/rinse/spin, but the same offsets
produce nonsense against the WA55A7700AV blob, so that decode is recorded
in docs/investigations/download-cycle.md rather than shipped.)
"""
from __future__ import annotations
import threading
from .const import CONF_CLOUD_COURSES
from .registry.capabilities.common import hex_pairs, option_value
COURSE_HREF = "/course/vs/0"
EXTRA_PREFIX = "CloudExtraCourse"
DEFAULT_PREFIX = "CloudCourse"
ONESHOT_PREFIX = "OneTimeCloudCourse"
COURSE_PREFIX = "Course"
# Synthetic, integration-owned field the coordinator merges onto
# /course/vs/0's rep so the registry's exists_fn/rep_fn/options/write_fn all
# reach this store through their existing signatures -- rep_fn in particular
# receives only its own href's rep, never the resource snapshot, so a
# sibling-resource lookup isn't available to it. Namespaced away from
# Samsung's own 'x.com.samsung.da.' fields so it can never collide with one,
# and merged at read time only: it is never written to the state cache, never
# sent to the device, and never part of a diagnostics dump.
FIELD = "x.localthings.cloudCourses"
# Raw-value namespace for a cloud program in the cycle select. A slot id is
# itself two hex chars, exactly like a local course code, so the two would be
# indistinguishable (and could collide outright) as bare select values.
RAW_PREFIX = "cloud:"
# A blob whose first two bytes are FFFF means "no program loaded" rather than
# naming one -- WA55A7700AV reports
# OneTimeCloudCourse_FFFF010049004D004A804C0037F0AC00 while sitting on a
# perfectly ordinary local course, and its byte 2 (01) is not one of the slots
# its own CloudExtraCourse_ advertises.
_SENTINEL_PREFIX = "FFFF"
# Distinct from None, which is a real observation that carried no payload.
# Only "never observed" suppresses a candidate; absent-then-loaded is a
# genuine transition and should count.
_UNOBSERVED = object()
# Byte offset within a blob that carries its slot id.
_SLOT_BYTE = 2
_MIN_BLOB_BYTES = 4
def _hex_bytes(blob):
if not isinstance(blob, str) or len(blob) % 2 or len(blob) < _MIN_BLOB_BYTES * 2:
return []
try:
int(blob, 16)
except ValueError:
return []
return hex_pairs(blob.upper())
def is_loaded(blob) -> bool:
"""True when `blob` names an actual program rather than 'none'."""
return _slot_and_loaded(blob)[1]
def slot_of(blob) -> str | None:
"""The slot id `blob` belongs to, or None if it names no program."""
slot, loaded = _slot_and_loaded(blob)
return slot if loaded else None
def _slot_and_loaded(blob) -> tuple[str | None, bool]:
"""Both answers off one parse -- the public pair above needs the same
byte split, and observe() asks for both about the same payload."""
parts = _hex_bytes(blob)
if not parts:
return None, False
if "".join(parts[:2]) == _SENTINEL_PREFIX:
return None, False
return parts[_SLOT_BYTE], True
def advertised_slots(rep) -> list[str]:
"""Slot ids this device says it has downloaded programs in, from its own
CloudExtraCourse_ token. The authority on *which* programs exist -- this
is never inferred from what has been learned so far, so "3 of 9
discovered" is answerable."""
raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX)
if not isinstance(raw, str) or len(raw) % 2:
return []
# Preserve the device's own order (first-seen wins) while dropping any
# repeat, so the flow lists slots the way the appliance does.
return list(dict.fromkeys(hex_pairs(raw.upper())))
def cloud_slots(rep, courses) -> list[str]:
"""Advertised slots that are not already selectable courses.
`CloudExtraCourse_` does not mean the same thing on every family. On the
washers its bytes are opaque payload slots sharing nothing with the
device's own course list, and selecting one needs the full payload. On
the DW5000C dishwasher all four of its bytes *are* course codes in that
device's own list (8E/8D/8F/02 -- Plastic, Pots and pans, Baby Care, and
one untranslated), so there it is tagging which of its ordinary courses
came from the cloud. Those are already selectable as plain `Course_`
writes and need nothing from this module.
Subtracting the course list tells the two apart without having to guess
the family: what remains is slots that cannot be selected any other way,
which is exactly the set this module exists for.
"""
known = {c.upper() for c in courses or ()}
return [slot for slot in advertised_slots(rep) if slot not in known]
def supports_cloud_courses(rep, courses) -> bool:
"""True for a device with downloaded programs it cannot otherwise run."""
return bool(cloud_slots(rep, courses))
def loaded_slot(rep) -> str | None:
"""The slot whose payload the appliance currently holds -- the one-time
override when one is set, else the saved default.
Deliberately not gated on the course being Download: the guided setup
flow watches this before the Download course has been confirmed, and
during that walk a change here *is* the signal that the user selected a
different program. A stale token can't produce a false positive because
the flow waits for a change from its own baseline, not for a value.
"""
options = rep.get("x.com.samsung.da.options")
return slot_of(option_value(options, ONESHOT_PREFIX)) or slot_of(
option_value(options, DEFAULT_PREFIX)
)
def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
"""Restore the persisted record, dropping anything not the shape this
module writes -- it round-trips through the config entry as plain JSON
and a hand-edited .storage file must not be able to crash setup (same
posture as learned._coerce)."""
if not isinstance(stored, dict):
return None, {}
download = stored.get("download_course")
if not isinstance(download, str) or not download:
download = None
slots: dict[str, dict[str, str]] = {}
raw_slots = stored.get("slots")
if isinstance(raw_slots, dict):
for slot, record in raw_slots.items():
if not isinstance(slot, str) or not isinstance(record, dict):
continue
blob = record.get("blob")
if not is_loaded(blob) or slot_of(blob) != slot.upper():
continue
name = record.get("name")
slots[slot.upper()] = {
"blob": blob.upper(),
"name": name if isinstance(name, str) and name.strip() else "",
}
return download, slots
def persist(hass, entry, record: dict) -> None:
"""Write `record` onto the entry. Runs on the event loop, which
async_update_entry requires."""
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_CLOUD_COURSES: record})
class CloudCourses:
"""Per-device store of discovered cloud programs.
Mutated from whichever thread applied the update (the DTLS reader for an
OBSERVE notify, an executor thread for a poll -- see ObserveManager.apply),
so every access takes the lock; persistence is the caller's job, on the
event loop.
"""
def __init__(self, stored_record=None) -> None:
self._lock = threading.Lock()
download, slots = _coerce(stored_record)
self._download_course = download
self._slots = slots
# Course codes seen at the moment a one-time override was *loaded* --
# candidates for "which course means Download on this board", pending
# user confirmation (see download_candidates).
self._candidates: dict[str, int] = {}
# Last one-time payload seen, so a load can be told from a poll that
# merely re-reports one. _UNOBSERVED until the first rep arrives.
self._last_oneshot: object = _UNOBSERVED
# -- learning ---------------------------------------------------------
def observe(self, rep: dict) -> bool:
"""Learn from one applied /course/vs/0 rep; True if anything changed.
Two facts are learnable here. A blob is recorded against the slot its
own byte 2 names, so a program only has to be sitting loaded once --
on either token -- to be replayable forever after.
The Download course code is only ever taken as a *candidate*, and
only at the moment the one-time payload actually *changes* to a
loaded value. That instant is the one the device is known to accept a
program on, so the course selected then is real evidence. Counting
every poll instead would rank by dwell time: tokens in this array are
replaced by prefix and never evicted, so a stale OneTimeCloudCourse_
outlives its run and sits there through however many polls the
appliance spends on some ordinary course afterwards -- which is
exactly the course that would then be suggested. A candidate is still
never applied without confirmation in the options flow, but a
confident wrong suggestion is most of the way to a wrong write, and a
wrong write here starts a real wash cycle.
"""
options = rep.get("x.com.samsung.da.options")
if not options:
return False
known_slots = advertised_slots(rep)
oneshot = option_value(options, ONESHOT_PREFIX)
with self._lock:
# Compared once, at the end, against where this pass started --
# not set per assignment. The two tokens can name the same slot
# with different payloads (a downloaded program with its settings
# tweaked for one run is exactly that shape), and a per-assignment
# flag would then report a change on every single poll forever:
# each pass writes the default's payload and then the one-shot's
# over it, so neither is ever "already stored". Every one of those
# reports rewrites the config entry, which on the SD-card installs
# this integration runs on is the one cost here that really bites.
# The end state is stable (the one-shot is written last and wins),
# so comparing start to end settles after the first pass.
before = {slot: record["blob"] for slot, record in self._slots.items()}
for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX):
blob = option_value(options, prefix)
slot = slot_of(blob)
# A blob whose slot the device doesn't advertise is not a
# program this appliance offers -- don't record it.
if slot is None or (known_slots and slot not in known_slots):
continue
record = self._slots.get(slot)
if record is None:
self._slots[slot] = {"blob": blob.upper(), "name": ""}
else:
record["blob"] = blob.upper()
changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()}
course = option_value(options, COURSE_PREFIX)
# Only a transition we actually watched happen counts. On the
# first observation there is nothing to compare against, so a
# payload sitting there is equally consistent with "just loaded"
# and "left over from last week" -- and on a board that doesn't
# clear the token when leaving Download, believing the former
# proposes whatever ordinary course the appliance happens to be
# on. Accepting that prefill would start a real wash cycle.
# (The two dumps in the corpus taken off the Download course both
# show the appliance clearing it to the FFFF sentinel, so this
# may never fire in practice -- which is not a reason to rely on
# it.) Restores don't persist _last_oneshot, so every restart
# re-enters this first-observation state deliberately.
first_ever = self._last_oneshot is _UNOBSERVED
if course and is_loaded(oneshot) and not first_ever and oneshot != self._last_oneshot:
self._candidates[course] = self._candidates.get(course, 0) + 1
self._last_oneshot = oneshot
return changed
# -- reads ------------------------------------------------------------
def download_candidates(self) -> list[str]:
"""Course codes seen at the moment a one-time program was loaded,
most-observed first -- what the options flow offers as the likely
Download course. Never used for a write on its own."""
with self._lock:
ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0]))
return [code for code, _ in ranked]
def named(self) -> dict[str, str]:
"""Slots that are both learned and named -- the only ones offerable
as a cycle option. An unnamed slot has no label that isn't either
invented or an opaque hex id, so it stays out of the UI until the
user supplies one."""
with self._lock:
return {slot: record["name"] for slot, record in self._slots.items() if record["name"]}
def snapshot(self) -> dict:
with self._lock:
return {
"download_course": self._download_course,
"slots": {slot: dict(record) for slot, record in self._slots.items()},
}
def view(self) -> dict:
"""What the registry sees under FIELD: only what a write or a label
can actually be built from, so a descriptor never has to re-apply
this module's rules."""
with self._lock:
if not self._download_course:
return {}
return {
"download_course": self._download_course,
"programs": {
slot: {"blob": record["blob"], "name": record["name"]}
for slot, record in self._slots.items()
if self._is_usable(record)
},
}
# -- writes -----------------------------------------------------------
def set_download_course(self, code: str | None) -> None:
with self._lock:
self._download_course = code or None
def set_name(self, slot: str, name: str) -> None:
with self._lock:
record = self._slots.get(slot.upper())
if record is not None:
record["name"] = name.strip()
@staticmethod
def _is_usable(record) -> bool:
"""A slot is offerable once it has a name. The device supplies the
payload; only the user can supply the label, so this is the whole
rule and it is stated once."""
return bool(record["name"])
def undiscovered(rep: dict, record: dict, courses) -> list[str]:
"""Cloud slots that aren't yet usable -- unlearned or unnamed. What the
Repairs issue counts, and what the options flow asks the user to walk the
appliance through. Counts against cloud_slots, not every advertised byte:
a slot that is already a selectable course is nothing to set up."""
programs = record.get("slots") or {}
return [s for s in cloud_slots(rep, courses) if not (programs.get(s) or {}).get("name")]
File diff suppressed because it is too large Load Diff
+116 -36
View File
@@ -1,63 +1,137 @@
DOMAIN = "localthings"
PLATFORMS = [
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan",
"sensor",
"binary_sensor",
"switch",
"number",
"select",
"button",
"time",
"climate",
"fan",
"water_heater",
]
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Options-flow key (entry.options, not entry.data): lets a user override the
# device-wide remote-control-off write block for a specific device (issue
# #54). Some devices report remote control off yet still accept certain
# writes (e.g. default detergent/softener dosing on a washer, applied even
# to the built-in programs) -- the block exists to give a clear error
# instead of a silent device-side rejection, but that assumption doesn't
# hold for every model. Defaults to False (block stays on) everywhere it's
# read, so devices this doesn't apply to see no behavior change.
# Device identity, resolved once by the config flow's probe and persisted
# on the entry (issue #236) -- what the coordinator mints registry keys
# from at __init__ time, before any poll has happened. Without them,
# anything registering before the first poll (e.g. the connection-mode
# sensor) got keyed on the IP address permanently.
#
# CONF_SERIAL is the resolved serial (registry.identity.resolve_serial's
# output, the host itself for a placeholder-serial board -- issues
# #83/#189), so it matches what _run_discovery computes on the first poll.
CONF_SERIAL = "serial"
# What this entry's devices and entities are keyed on -- normally the OCF
# device UUID (issue #381). CONF_SERIAL stays alongside it as the pre-v4
# key to re-key from, and as what corroborates a later change of UUID.
# Absent until the first live poll, since only the device can report it.
CONF_DEVICE_KEY = "device_key"
CONF_MODEL = "model"
CONF_MANUFACTURER = "manufacturer"
CONF_DEVICE_TYPE = "device_type"
# entry.data key: modes this device reported itself in but never advertised
# in the same resource's supportedModes (issue #327). Stored on the entry
# rather than kept in memory so a mode the device only names while it is
# active survives a restart -- see learned.py. Shape:
# {actual_href: [code, ...]}.
CONF_LEARNED_MODES = "learned_modes"
# Options-flow key: whether learned modes are remembered and offered.
# Defaults to on; turning it off stops both halves at once (nothing new is
# learned, nothing already learned is offered) without discarding what was
# already remembered -- the options flow's reset step does that.
CONF_LEARN_MODES = "learn_device_modes"
DEFAULT_LEARN_MODES = True
# entry.data key: cloud "Download" programs discovered on a laundry device
# (issue #342). Same rationale as CONF_LEARNED_MODES -- a program's full
# replay payload is only ever visible while the device happens to be sitting
# on it, so it has to survive a restart -- but a richer shape, because a
# cloud program also needs a user-supplied name and the device's own
# Download course code. See cloudcourse.py, which owns the shape. Shape:
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
CONF_CLOUD_COURSES = "cloud_courses"
# Options-flow key: whether downloaded programs are offered as selectable
# cycles and nagged about via the "not set up yet" Repair (issue #364).
# Defaults to on. Unlike CONF_LEARN_MODES this does not also stop passive
# observation -- a device that merely *advertises* download slots without
# the owner ever meaning to use them (SmartThings appears to seed one from
# the cloud automatically, per #364's reporters) is exactly the case this
# exists for, and turning it off is the fix. Guided/manual setup stay
# reachable and still record what they see either way: they are a deliberate
# per-session action, not the passive background behavior this silences, and
# leaving them working means flipping the option back on immediately surfaces
# anything set up in the meantime instead of asking the user to redo it. See
# coordinator.cloud_courses_enabled for exactly what it gates.
CONF_CLOUD_COURSES_ENABLED = "cloud_courses_enabled"
DEFAULT_CLOUD_COURSES_ENABLED = True
# Options-flow key (entry.options, not entry.data): lets a user override
# the device-wide remote-control-off write block for a specific device
# (issue #54). Some devices accept certain writes even while reporting
# remote control off (e.g. a washer's default detergent dosing), so the
# blanket-block assumption doesn't hold everywhere. Defaults to False
# (block stays on).
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
# to report a new value. Devices commonly revise their own remaining-time
# estimate by a minute or two throughout a cycle, and finish_time = now() +
# remaining drifts by the poll interval between those revisions -- both push
# a fresh state (and a recorder/logbook entry) far more often than the
# estimate is meaningfully different. 0 disables the gate (every computed
# change is reported, today's behavior).
# hysteresis-gated timestamp sensor (currently just finish_time) reports a
# new value. Devices commonly revise their remaining-time estimate by a
# minute or two throughout a cycle, and finish_time = now() + remaining
# drifts with the poll interval between revisions -- both push a fresh
# state far more often than the estimate is meaningfully different. 0
# disables the gate.
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
# been seen as low as 49153, so we sweep the whole range for a live UDP port
# before attempting the (expensive) DTLS handshake.
# The DTLS/CoAP local API binds somewhere in this ephemeral range,
# depending on firmware (newer builds answer on 49154/49155, older ones as
# low as 49153) -- swept for a live UDP port before the expensive DTLS
# handshake.
PROBE_PORT_RANGE = list(range(49152, 49161))
# Ports we've historically seen complete a DTLS handshake. When more than one
# port in the range looks live, these are tried first.
# Ports we've historically seen complete a DTLS handshake; tried first when
# more than one port in the range looks live.
PREFERRED_PROBE_PORTS = [49154, 49155]
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
# ICMP port-unreachable almost immediately; a live-but-silent port is only
# detected by this timeout elapsing, so keep it short.
# detected by this timeout elapsing, so keep it short. Only reached as the
# fallback for when the ClientHello probe below confirms nothing.
LIVENESS_PROBE_TIMEOUT_S = 1.5
# Deadline for the blockwise /device/0 GET during the config-flow probe. The
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
# without stalling setup; it matches the per-resource read timeout elsewhere.
# Per-port budget for the DTLS ClientHello probe (smartthings-local >=
# 0.1.2), the primary port-detection gate. A real server answers with a
# HelloVerifyRequest in ~1 RTT; the budget only bounds how long a silent
# port takes to give up. 3s covers two retransmits on a slow LAN.
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
CLIENTHELLO_PROBE_RETRIES = 2
# The whole port range is probed at once: each stateless probe is bounded
# by CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the
# sweep costs one probe's wall clock, not the sum of the range. Capped so a
# widened PROBE_PORT_RANGE can't spawn an unbounded thread pool.
PROBE_MAX_WORKERS = 12
# Deadline for the blockwise /device/0 GET during the config-flow probe.
# The slowest device observed returns a full dump in ~8s.
PROBE_GET_TIMEOUT_S = 10.0
# Base for the local (client-side) DTLS source port, distinct from the
# destination probe ports above. See coordinator._local_source_port for why a
# fixed per-device source port matters and how the per-device offset is
# derived. Base mirrors the upstream smartthings-local reference bridge.
# Requires smartthings-local >= 0.1.1.
# destination probe ports above -- see coordinator._local_source_port for
# why a fixed per-device source port matters. Mirrors the upstream
# smartthings-local reference bridge. Requires smartthings-local >= 0.1.1.
DTLS_LOCAL_PORT_BASE = 49700
SUMMARY_INTERVAL_S = 30.0
@@ -65,3 +139,9 @@ SUMMARY_INTERVAL_S = 30.0
DEVICE_SUPPORT_ISSUE_URL = (
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
)
# Service names (services.py), shared with config_flow.py so the
# options-flow debug panel calls the exact same service a user could call
# from an automation (issue #300) -- one code path performs a raw write.
SERVICE_WRITE_RESOURCE = "write_resource"
SERVICE_READ_RESOURCE = "read_resource"
File diff suppressed because it is too large Load Diff
+79 -73
View File
@@ -6,6 +6,7 @@ device > the menu > Download diagnostics. This is what the Repairs issue
users at: a redacted snapshot of the device's raw /device/0 state, plus
enough version/coverage metadata to reproduce and diagnose the gap.
"""
from __future__ import annotations
from importlib.metadata import version as pkg_version
@@ -15,8 +16,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.loader import async_get_integration
from . import cloudcourse
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.capabilities.laundry import cycle_options
from .registry.redact import redact_resources
from .registry.subdevices import MAIN
@@ -31,50 +34,41 @@ async def async_get_config_entry_diagnostics(
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
cloud_courses = coordinator.cloud_courses.snapshot()
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
# is OCF's standard device-type declaration; /oic/res is OCF's
# discovery endpoint, listing every href/Collection the connection
# hosts -- relevant to the "Composite Device" model (issue #177) where
# a single physical device exposes more than one logical subdevice. See
# is OCF's device-type declaration; /oic/res is OCF's discovery
# endpoint, relevant to the "Composite Device" model (issue #177). See
# registry/identity.py.
identity = coordinator._identity
def _seed_diag(su) -> dict:
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
# Collection, so its state comes from individually-polled hrefs
# instead) has no meaningful seed_path; report the flat_hrefs list
# in its place rather than the misleading bare "/" a joined empty
# tuple would otherwise produce.
# A flat-mode subdevice (issue #205: no working /<uuid>/device/0
# Collection, state comes from individually-polled hrefs instead)
# has no meaningful seed_path; report flat_hrefs in its place.
return {
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
"flat_hrefs": list(su.flat_hrefs),
}
def _subdevice_diag(su) -> dict:
# One pass over coordinator.bound for both fields below (count and
# the distinct hrefs), and one redaction of this subdevice's canonical
# view -- `model` reads modelNum off the already-redacted `resources`
# rather than redacting /information/vs/0 a second time. modelNum
# itself never matches redact.py's substring rules, so which side of
# redact_resources it's read from doesn't change the value.
# `model` reads modelNum off the already-redacted `resources` rather
# than redacting /information/vs/0 again -- modelNum never matches
# redact.py's substring rules, so the value is the same either way.
matching = [b for b in coordinator.bound if b.subdevice == su]
res = redact_resources(coordinator.canonical_resources(su))
res = redact_resources(coordinator.device_resources(su))
return {
"kind": su.kind,
"key": su.key,
**_seed_diag(su),
"bound_entity_count": len(matching),
"hrefs": sorted({b.href for b in matching}),
"model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''),
# Keyed by this subdevice's *canonical* hrefs, not the real ones
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
# '/<uuid>/mode/vs/0'. That's the form the registry and every
# capability are written against, so a sibling's block can be
# read (or pasted into the skill's standalone-discovery
# recipe) exactly like the master's `resources` above,
# instead of having to be de-indexed by hand first.
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
# Keyed by this subdevice's canonical hrefs ('/mode/vs/0'), not
# the real ones it answers on ('/mode/vs/1', '/<uuid>/mode/vs/0')
# -- the form the registry is written against, so a sibling's
# block reads exactly like the master's `resources` below.
"resources": res,
}
@@ -86,73 +80,85 @@ async def async_get_config_entry_diagnostics(
"model": identity.model,
"device_types": list(identity.device_types),
"resources": redact_resources(identity.raw),
} if identity is not None else None,
}
if identity is not None
else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
# This subdevice's own resources, and only this subdevice's -- what
# the module docstring and the adding-device-support skill have
# always described it as ("the parsed /device/0 snapshot"). On a
# composite device (issue #177) `last_resources` is the union across
# every live subdevice keyed by real hrefs, so reporting it raw here
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
# under no attribution at all. Each sibling reports its own
# resources in its own `subdevices` entry below instead. For a
# device with no subdevices -- almost every device -- this is
# byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
# This subdevice's own resources, and only this subdevice's. On a
# composite device (issue #177) `last_resources` is the union
# across every live subdevice keyed by real hrefs, so reporting it
# raw here would mix a sibling's /mode/vs/1 with the master's
# /mode/vs/0 under no attribution. Each sibling reports its own
# resources in `subdevices` below instead. For a device with no
# subdevices, this is byte-identical to `last_resources`.
"resources": redact_resources(coordinator.device_resources(MAIN)),
# Sibling indoor subdevices discovered on this connection (issue
# #177) -- per-subdevice kind/key/seed path plus what actually bound
# to it, so a report shows whether a composite device's subdevice
# was found at all and what it resolved to. subdeviceIdList (the
# UUID a prefixed subdevice's key comes from) is deliberately NOT
# redacted here even
# though the field matches redact.py's 'deviceid' substring rule
# elsewhere in `resources` above -- it's an appliance-internal
# pairing id, not account data, and reporting the key is what makes
# this block actionable.
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
# from) is deliberately NOT redacted here, unlike elsewhere in
# `resources` -- it's an appliance-internal pairing id, not account
# data, and reporting it is what makes this block actionable.
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
# Candidates that answered their seed but that discover_partitioned's
# entity-level liveness gate rejected -- an unused SmartThings slot
# (the issue #177 reporter's /device/2) that still answers a
# same-shaped batch, not a real second subdevice. Reported alongside
# subdevices above so a report shows what was found *and* why it
# didn't become an entity, not just silence where a third climate
# card might otherwise be expected.
# liveness gate rejected -- an unused SmartThings slot, not a real
# second subdevice. Reported alongside subdevices above so a report
# shows what was found and why it didn't become an entity.
"subdevices_skipped": [
{
"kind": skip.subdevice.kind,
"key": skip.subdevice.key,
**_seed_diag(skip.subdevice),
"hrefs": list(skip.hrefs),
# The reps the liveness gate actually judged, canonicalized
# like the materialized subdevices above. These are the one
# thing a reader needs to second-guess a skip ("is my second
# subdevice really absent, or did the gate get it wrong?"),
# and they exist nowhere else in this dump: a rejected
# candidate is never polled again and never enters the state
# cache, so `resources` above cannot contain them by
# construction.
"resources": redact_resources({
canon: rep
for href, rep in coordinator._skipped_subdevice_resources.items()
if (canon := skip.subdevice.to_canonical(href)) is not None
}),
# The reps the liveness gate actually judged -- the one
# thing a reader needs to second-guess a skip, and they
# exist nowhere else in this dump: a rejected candidate is
# never polled again or entered into the state cache.
"resources": redact_resources(
{
canon: rep
for href, rep in coordinator._skipped_subdevice_resources.items()
if (canon := skip.subdevice.to_canonical(href)) is not None
}
),
}
for skip in coordinator._skipped_subdevices
],
# What each enumeration probe returned ({} vs a batch), keyed by the
# seed href attempted -- lets a report distinguish "checked, nothing
# there" from "never checked", the same posture the speculative
# /device/1 //device/2 probe this replaced used to document directly
# in identity.py before it moved to registry/subdevices.py.
# there" from "never checked".
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
# Reported on its own rather than inside `resources` because it is
# metadata about the connection rather than state of any one
# subdevice -- and because nothing polls it after discovery, so it
# would go stale in there. Its numofsubdevice count is what
# independently corroborates the subdevices/subdevices_skipped split
# above.
# Reported on its own, not inside `resources`, since it's metadata
# about the connection rather than one subdevice's state, and
# nothing polls it after discovery so it would go stale in there.
"multidevice": redact_resources(coordinator._multidevice),
# Modes this unit reported itself in but never advertised (issue
# #327). Reported separately from `resources` on purpose: the dump
# above stays exactly what the device said, so a triager can still
# see the gap these codes were inferred from. Keyed by actual href,
# like the store itself.
"learned_modes": {
"enabled": coordinator.learning_enabled,
"codes": coordinator.learned_snapshot(),
},
# Cloud "Download" programs discovered on this device (issue #342),
# reported separately from `resources` for the same reason as
# learned_modes above -- the dump there stays exactly what the device
# said, and this is what the integration made of it.
#
# Reported in full, names included. Half of what can go 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. The names are the user's own
# words, so this is the one place they appear; they reach a dump only
# because its owner chose to download and share it.
"cloud_courses": {
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()),
"cloud_slots": cloudcourse.cloud_slots(
coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN))
),
**cloud_courses,
},
"integration_version": integration.version,
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
+47 -59
View File
@@ -1,51 +1,50 @@
"""Base entity for Local Things."""
from __future__ import annotations
import re
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.adapter import _key
from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> bool:
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
"""Return False if the entity should not be registered for this device.
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
require that field to be present in the resource rep so that optional
fields on shared resources don't create phantom entities.
Explicit exists_fn takes priority. Otherwise, if the entity has a
field, require that field to be present in the resource rep so that
optional fields on shared resources don't create phantom entities.
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
yet" marker) is included anyway so it can be populated by sub-polls. A
genuinely empty {} rep is included too by this default gate -- whether
empty means "not populated yet" or "permanently unsupported" needs
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
{} is fridge.py's documented *normal* no-alarm state (see
_active_alarm_codes), not an absence signal, and it's far from the only
resource like that. Only a capability whose author has actually verified
a field is genuinely never populated on unsupported hardware opts into
stricter gating with its own is_stub_rep-based exists_fn (see
common.ENERGY_METER, issue #127) -- this default stays permissive.
A stub rep (is_stub_rep) is included anyway so it can be populated by
sub-polls. A genuinely empty {} rep is included too by this default
gate: whether empty means "not populated yet" or "permanently
unsupported" needs per-field domain knowledge this generic gate
doesn't have (e.g. /alarms/vs/0's {} is fridge.py's documented normal
no-alarm state, not an absence signal). Only a capability whose author
has verified a field is genuinely never populated opts into stricter
gating with its own exists_fn (see common.ENERGY_METER, issue #127).
`bound.href` is already the *actual* href (issue #177 -- see
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
raw snapshot, same rule as everywhere else a whole-resources-dict scan
happens (coordinator.canonical_resources) -- this is a free function, not
an LocalThingsEntity method, so it can't use self._resources.
`bound.href` is already the actual href (issue #177); `exists_fn` gets
`bound`'s own subdevice's canonical view instead of the raw snapshot,
same rule as everywhere else a whole-resources-dict scan happens --
this is a free function, so it can't use self._resources.
Reads `discovery_resources`, not `last_resources`: on an offline load
(issue #295) the live cache is still empty, and judging existence
against it would filter every rehydrated entity away.
"""
rep = coordinator.last_resources.get(bound.href)
rep = coordinator.discovery_resources.get(bound.href)
if rep is None:
return False
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
return bound.desc.exists_fn(rep, coordinator.discovery_canonical(bound.subdevice))
if bound.desc.field:
if not rep or is_stub_rep(rep):
return True
@@ -63,7 +62,7 @@ def _derive_name(state_key: str) -> str:
builds the {instance_name} placeholder those translations interpolate,
for a device that named its own compartments/ice makers.
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
name = re.sub(r"(\d+)$", lambda m: f" {m.group()}" if int(m.group()) > 0 else "", state_key)
return _snake_to_title(name).strip()
@@ -74,9 +73,9 @@ def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[:-len(suffix)]
source = source[: -len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
source = source[: -len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
@@ -89,22 +88,18 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
super().__init__(coordinator)
self._bound = bound
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_{self._state_key}"
if bound.desc.translation_placeholders is not None:
self._attr_translation_placeholders = dict(
bound.desc.translation_placeholders
)
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
elif bound.desc.use_instance_name:
self._attr_translation_placeholders = {
"instance_name": _instance_display_name(bound, self._state_key)
}
# _attr_name is deliberately left unset: Home Assistant gives an
# explicitly-set name precedence over the translation catalog, so
# setting it here would make every entity untranslatable. Every
# descriptor resolves to a catalog entry (see translation_key below);
# a platform that wants the bare device name instead sets
# _attr_name = None itself, as fan.py does for the hood's main entity.
# _attr_name is deliberately left unset: HA gives an explicitly-set
# name precedence over the translation catalog, so setting it here
# would make every entity untranslatable. A platform that wants the
# bare device name sets _attr_name = None itself (see fan.py).
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
@@ -114,18 +109,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
def translation_key(self) -> str | None:
"""The descriptor's catalog key, defaulting to its own `key`.
Overrides Entity.translation_key (a property upstream, not a plain
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
table-id-gated resolver -- is re-evaluated against live coordinator
data on every access, not resolved once at construction time.
Discovery runs on the first /device/0 poll, which the entity
registry already documents can hand a sibling resource an empty
stub rep before it's actually been fetched (see _is_included's
docstring) -- a static one-time resolution here would risk baking
in a permanent None (no translation) for the entity's whole
lifetime if that stub hadn't populated yet, even once the real
value arrives on a later poll.
Overrides Entity.translation_key so a callable descriptor (e.g.
laundry.cycle_select's table-id-gated resolver) is re-evaluated
against live coordinator data on every access, not resolved once
at construction time -- a static resolution would risk baking in
a permanent None if the first poll handed a sibling an empty stub
rep (see _is_included's docstring) before it populated.
"""
tk = self._bound.desc.translation_key
if callable(tk):
@@ -135,12 +124,11 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@property
def _resources(self) -> dict:
"""This entity's own subdevice's canonical resources view (issue
#177) -- see coordinator.canonical_resources. Every platform
property that needs the *whole* resources dict, as opposed to one
href via `coordinator.resource(href)`, must read through this
instead of `coordinator.last_resources`, or a sibling subdevice's own
actual hrefs would leak into (or be missing from) this entity's
view. For MAIN (every device with no subdevices) this is exactly
#177) -- see coordinator.canonical_resources. Any platform property
needing the whole resources dict, not one href via
`coordinator.resource(href)`, must read through this instead of
`coordinator.last_resources`, or a sibling subdevice's own hrefs
could leak into this entity's view. For MAIN this is exactly
`coordinator.last_resources`."""
return self.coordinator.canonical_resources(self._bound.subdevice)
+92 -104
View File
@@ -4,15 +4,14 @@ Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
below since each needs different HA fan semantics: the range hood's fan
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
Medium/High (issue #56) are both an ordered set of numeric levels
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
air_purifier.py's module docstring, with no named-mode list to preserve
since this board never self-reports one. The TP1X air-purifier family's
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
both hrefs, the only difference being whether the label comes straight
from supportedModes or from a parallel modesName array (see
_label_for_code)."""
(SET_SPEED), confirmed monotonic in capabilities/air_purifier.py's module
docstring, with no named-mode list since this board never self-reports one.
The TP1X air-purifier family's modes (Smart/Max/Mid/WindFree/Sleep, issue
#130) and the A-VTWW-TP2-21 family's /wind/strength/vs/0 modes (issue #151)
are both named behaviors with no linear order (PRESET_MODE) --
LocalThingsAirPurifierFan handles both hrefs, the only difference being
whether the label comes from supportedModes or a parallel modesName array
(see _label_for_code)."""
from __future__ import annotations
@@ -32,22 +31,24 @@ from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.air_purifier import HREF_AIRFLOW
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF
from .registry.capabilities.air_purifier import (
HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF,
)
from .registry.entities import FanDesc
_LOGGER = logging.getLogger(__name__)
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
_MAX_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMaxFanSpeed'
_OFF_SPEED_CODE = '0'
POWER_HREF = "/power/0"
POWER_VS_HREF = "/power/vs/0"
_FAN_SPEED_FIELD = "x.com.samsung.da.hood.fanSpeed"
_SUPPORTED_FAN_SPEED_FIELD = "x.com.samsung.da.hood.supportedFanSpeed"
_MIN_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMinFanSpeed"
_MAX_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMaxFanSpeed"
_OFF_SPEED_CODE = "0"
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_NAME_FIELD = 'x.com.samsung.da.modesName'
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_MODES_FIELD = "x.com.samsung.da.supportedModes"
_MODES_NAME_FIELD = "x.com.samsung.da.modesName"
async def async_setup_entry(
@@ -74,19 +75,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
Some boards that reuse this capability (built-in microwave vent fans,
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
resource at all -- fan speed 0 is itself the off state there, with no
separate power toggle to write. `_speed_zero_is_off` detects that
shape from the hood resource's own settableMinFanSpeed/
supportedFanSpeed fields and switches every method below to drive
off/on purely through the fanSpeed field, including '0' in the
ordered speed codes as the off step instead of assuming every
advertised code is an active speed.
resource at all -- fan speed 0 is itself the off state there.
`_speed_zero_is_off` detects that shape and switches every method
below to drive off/on purely through the fanSpeed field.
This is deliberately not the same question as `_has_separate_power`,
which only proves *some* power resource exists on the device -- on a
combi appliance (e.g. an over-the-range microwave) that resource can
belong to the cavity, not the vent fan, and toggling it from here
would turn off the whole appliance instead of just the fan.
Deliberately not the same question as `_has_separate_power`, which
only proves some power resource exists on the device -- on a combi
appliance that resource can belong to the cavity, not the vent fan,
and toggling it from here would turn off the whole appliance.
"""
_enable_turn_on_off_backwards_compatibility = False
@@ -110,13 +106,12 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _speed_zero_is_off(self) -> bool:
"""Whether fan speed '0' is itself this hood's off step, with no
separate power resource to toggle. The board says so directly:
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
standalone hood's codes start at 14 and it carries a real /power
resource instead, so this is False there."""
separate power resource to toggle -- settableMinFanSpeed '0', or
'0' inside supportedFanSpeed. False for the standalone hood, whose
codes start at 14 and which carries a real /power resource."""
rep = self._rep(self._bound.href)
return (
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
or _OFF_SPEED_CODE in self._all_speed_codes()
)
@@ -138,31 +133,27 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _active_speed_codes(self) -> list[str]:
codes = self._all_speed_codes()
if self._speed_zero_is_off():
# No separate power resource: '0' is the off step, not a speed.
return [code for code in codes if code != _OFF_SPEED_CODE]
# Power is carried by the separate /power resource. fanSpeed
# retains the selected setting while power is off (as the
# lamp's `current` field does), so every advertised code is an
# active ordered speed.
# Power is carried by the separate /power resource; fanSpeed
# retains the selected setting while power is off, so every
# advertised code is an active ordered speed.
return codes
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
if self._speed_zero_is_off():
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
return current not in ('', _OFF_SPEED_CODE)
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, "0"))
return current not in ("", _OFF_SPEED_CODE)
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
return str(
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
).lower() == 'on'
if "value" in rep:
return bool(rep.get("value"))
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
@property
def speed_count(self) -> int:
@@ -173,13 +164,15 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
if not self.is_on:
return 0
codes = self._active_speed_codes()
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ""))
if not codes or current not in codes:
return None
return ordered_list_item_to_percentage(codes, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
if self._speed_zero_is_off():
@@ -192,20 +185,22 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
return
codes = self._active_speed_codes()
if codes:
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
await self.coordinator.async_send_command(self._bound, ("speed", codes[0]))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
self._bound,
self._power_payload(True),
)
if percentage is not None:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs) -> None:
if self._speed_zero_is_off():
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
await self.coordinator.async_send_command(self._bound, ("speed", _OFF_SPEED_CODE))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(False),
self._bound,
self._power_payload(False),
)
async def async_set_percentage(self, percentage: int) -> None:
@@ -217,10 +212,11 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
return
if not self._speed_zero_is_off() and not self.is_on:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
self._bound,
self._power_payload(True),
)
code = percentage_to_ordered_list_item(codes, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', code))
await self.coordinator.async_send_command(self._bound, ("speed", code))
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
@@ -230,9 +226,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
@@ -253,27 +247,24 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
correctly."""
resources = self._resources
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
return str(power).lower() == "on"
return bool(self._rep(POWER_HREF).get("value"))
def _label_for_code(self, code) -> str:
"""Lowercased HA preset label for a device mode code.
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
wind-strength codes ('87'/'89'/...) with a separate modesName array
(parallel-indexed with supportedModes) giving the actual names --
same shape as climate.py's _wind_strength_label, and coincidentally
the same word set (Smart/Max/WindFree/Sleep), so both board
generations land on identical HA preset values without needing
their own translation catalog entry."""
The TP1X_DA-AC-AIR board (issue #130) reports named modes directly
as supportedModes, so the code IS the label. The A-VTWW-TP2-21
board (issue #151) instead reports numeric wind-strength codes with
a separate modesName array giving the real names -- same shape as
climate.py's _wind_strength_label, and coincidentally the same word
set, so both generations land on identical HA preset values."""
rep = self._mode_rep()
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
names = rep.get(_MODES_NAME_FIELD)
@@ -284,8 +275,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
@property
def preset_modes(self) -> list[str]:
return [
self._label_for_code(code)
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
]
@property
@@ -295,7 +285,9 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
return self._label_for_code(code) if code is not None else None
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
@@ -311,22 +303,21 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
# modesName-labelled board), not the lowercased HA value.
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
if self._label_for_code(code) == preset_mode:
await self.coordinator.async_send_command(self._bound, ('mode', code))
await self.coordinator.async_send_command(self._bound, ("mode", code))
return
_LOGGER.warning(
"%s: %r is not a valid preset mode (supported: %s)",
self.entity_id, preset_mode, self.preset_modes,
self.entity_id,
preset_mode,
self.preset_modes,
)
_AIRFLOW_SPEED_FIELD = 'speed'
_AIRFLOW_SPEED_FIELD = "speed"
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
# as plain strings, same as _all_speed_codes above, so
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
# it exactly like the range hood's numeric levels -- no named-preset table
# needed since this board never reports mode names to hang one off of.
_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Treated
# as plain ordered strings, same as the range hood's numeric levels.
_AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
@@ -335,9 +326,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
@@ -350,24 +339,21 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
harmless for the TP1X board because it never reports /power/0 at
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
(the power_switch entity) is unconditionally bound to /power/0
whenever it's present, so writing here to /power/vs/0 first would
leave power_switch and this fan reading/writing two different
resources -- disagreeing until the next poll refreshes the other
one (the same optimistic-apply lag coordinator.py's own comments
warn about)."""
harmless for the TP1X board because it never reports /power/0.
This family's dumps carry both hrefs, and common.POWER_GENERIC is
unconditionally bound to /power/0 when present, so writing to
/power/vs/0 first would leave power_switch and this fan
disagreeing until the next poll."""
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_HREF)
if 'value' in power:
return bool(power.get('value'))
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
if "value" in power:
return bool(power.get("value"))
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
@property
def speed_count(self) -> int:
@@ -377,13 +363,15 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
def percentage(self) -> int | None:
if not self.is_on:
return 0
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ""))
if current not in _AIRFLOW_SPEED_CODES:
return None
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
@@ -400,4 +388,4 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
if not self.is_on:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
await self.coordinator.async_send_command(self._bound, ("speed", int(code)))
+128
View File
@@ -0,0 +1,128 @@
"""Modes a device reports itself in but never advertises as supported.
Some firmwares report a current mode that is missing from the same
resource's own supported list (issue #327: an ARTIK051 air conditioner
sitting in 'Quiet' with supportedModes [Off, Sleep, Speed, Nano,
NanoSleep]). The mode is real -- the remote and the SmartThings app select
it, and the unit accepts it written back -- so once the device has been
seen in it, it is remembered and offered alongside the advertised ones.
Learning is deliberately not global. A current value that isn't a
selectable option is common across this corpus -- an oven idling in
'NoOperation', a fridge's /mode/vs/0 carrying capability tokens like
'WATERFILTER_DISABLE' -- and remembering one of those permanently would
put an option in the UI that the device can only reject. LEARNABLE names
the canonical hrefs where a reported mode is known to be genuinely
selectable, and the coordinator narrows it further to the hrefs this
device actually binds a climate entity to (see _refresh_learnable_hrefs):
the same href is declared explicitly unmodeled on a dehumidifier and
empty on an air purifier, and learning for those would persist a code
nothing ever offers.
This module also owns the entry key the store persists under, so the
shape lives in exactly one place.
"""
from __future__ import annotations
import threading
from .const import CONF_LEARNED_MODES
from .registry.capabilities.airconditioner import HREF_CONVENIENT
MODES_FIELD = "x.com.samsung.da.modes"
SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# Convenient (preset) mode: firmware omits an active preset (e.g. Quiet)
# from its own supportedModes -- a reporting gap, not a capability
# difference (issue #327).
LEARNABLE: frozenset[str] = frozenset({HREF_CONVENIENT})
def _codes(value) -> list[str]:
"""Mode codes from a `modes`-style field, which some firmwares send as
a bare string rather than an array."""
if isinstance(value, str):
return [value]
if isinstance(value, (list, tuple)):
return [v for v in value if isinstance(v, str)]
return []
def _coerce(stored) -> dict[str, list[str]]:
"""Restore the persisted map, dropping anything that isn't the shape
this module writes. It round-trips through the config entry as plain
JSON, and a hand-edited .storage file shouldn't be able to crash
setup."""
if not isinstance(stored, dict):
return {}
restored = {}
for href, codes in stored.items():
if isinstance(href, str) and (valid := [c for c in _codes(codes) if c]):
restored[href] = valid
return restored
def stored(entry) -> dict[str, list[str]]:
"""What `entry` has persisted, coerced -- for a reader that can't go
through a coordinator (the options flow, on an unloaded entry)."""
return _coerce(entry.data.get(CONF_LEARNED_MODES))
def persist(hass, entry, codes: dict[str, list[str]]) -> None:
"""Write `codes` onto the entry. Runs on the event loop, which
async_update_entry requires."""
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_LEARNED_MODES: codes})
class LearnedModes:
"""Per-device store of learned codes, keyed by actual (on-the-wire)
href so two subdevices of one composite appliance learn separately.
Mutated from whichever thread applied the update (the DTLS reader for
an OBSERVE notify, an executor thread for a poll -- see
ObserveManager.apply), so every access takes the lock; persistence is
the caller's job, on the event loop.
"""
def __init__(self, stored=None) -> None:
self._lock = threading.Lock()
self._learned = _coerce(stored)
def observe(self, actual_href: str, rep: dict) -> list[str]:
"""Learn from one applied rep; returns the codes newly learned, so
an empty list means there is nothing to persist.
A rep that carries no supported list teaches nothing: "missing from
the list" is only meaningful against a list that exists, and
inventing one for a device that publishes none would offer options
nothing ever said were selectable. `rep` is the merged rep
ObserveManager.apply stores, so a partial notify carrying `modes`
alone (issue #27) still sees the supported list from the last full
poll.
"""
supported = _codes(rep.get(SUPPORTED_FIELD))
if not supported:
return []
with self._lock:
known = self._learned.get(actual_href, [])
new = [
code
for code in _codes(rep.get(MODES_FIELD))
if code and code not in supported and code not in known
]
if new:
self._learned[actual_href] = [*known, *new]
return new
def codes(self, actual_href: str) -> list[str]:
with self._lock:
return list(self._learned.get(actual_href, ()))
def snapshot(self) -> dict[str, list[str]]:
with self._lock:
return {href: list(codes) for href, codes in self._learned.items()}
def clear(self) -> None:
with self._lock:
self._learned = {}
+3 -2
View File
@@ -1,6 +1,7 @@
{
"domain": "localthings",
"name": "LocalThings",
"after_dependencies": ["recorder"],
"codeowners": ["@mbillow"],
"config_flow": true,
"dependencies": [],
@@ -10,7 +11,7 @@
"requirements": [
"cbor2>=5.4.6",
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.1"
"smartthings-local>=0.1.8"
],
"version": "0.18.0"
"version": "0.24.0"
}
+18 -15
View File
@@ -1,16 +1,18 @@
"""Number platform for Local Things."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from typing import cast
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import NumberDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import NumberDesc
async def async_setup_entry(
@@ -27,14 +29,15 @@ async def async_setup_entry(
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
_attr_mode = NumberMode.SLIDER
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: NumberDesc = bound.desc
desc = cast(NumberDesc, bound.desc)
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_device_class = (
NumberDeviceClass(desc.device_class) if desc.device_class else None
)
if desc.native_min is not None:
self._attr_native_min_value = desc.native_min
if desc.native_max is not None:
@@ -44,13 +47,13 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_unit_of_measurement(self):
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
def _range_from_resource(self) -> list | None:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if not desc.range_field:
return None
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
@@ -58,34 +61,34 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_min_value(self) -> float:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.native_min_fn is not None:
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[0])
if hasattr(self, '_attr_native_min_value'):
if hasattr(self, "_attr_native_min_value"):
return self._attr_native_min_value
return super().native_min_value
@property
def native_max_value(self) -> float:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.native_max_fn is not None:
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[1])
if hasattr(self, '_attr_native_max_value'):
if hasattr(self, "_attr_native_max_value"):
return self._attr_native_max_value
return super().native_max_value
@property
def native_step(self) -> float:
desc: NumberDesc = self._bound.desc
def native_step(self) -> float | None:
desc = cast(NumberDesc, self._bound.desc)
if desc.step_fn is not None:
return desc.step_fn(self.coordinator.resource(self._bound.href))
if hasattr(self, '_attr_native_step'):
if hasattr(self, "_attr_native_step"):
return self._attr_native_step
return super().native_step
+160 -36
View File
@@ -8,23 +8,24 @@ are an external pip dependency we don't own, so behavior that would
naturally live inside StateCache.apply_rep lives here instead, gating
whether apply_rep is called at all.
"""
from __future__ import annotations
import logging
import threading
import time
from collections.abc import Callable
import cbor2
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from smartthings_local.ocf.state_cache import StateCache
_LOGGER = logging.getLogger(__name__)
REFRESH_INTERVAL_S = 6 * 3600.0
MODE_OBSERVE = 'observe'
MODE_POLL = 'poll'
MODE_OBSERVE = "observe"
MODE_POLL = "poll"
DEFAULT_SETTLE_S = 4.0
GRACE_PERIOD_S = 15.0
@@ -55,6 +56,26 @@ SUCCESS_FRACTION = 0.8
PUSH_HEALTH_WINDOW_S = 60.0
def _is_alarms_href(href: str) -> bool:
"""True for /alarms/vs/<index> in any subdevice-translated shape --
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
(registry/subdevices.py) only ever rewrites the trailing index
segment or prepends a prefix -- it never touches the 'alarms/vs'
stem -- so matching that fixed segment plus a wildcard tail catches
every shape without this module needing to be subdevice-aware.
See `ObserveManager.apply`'s use of this for why the href matters:
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
is a complete snapshot of every currently-active alarm, not a
possibly-partial field update -- so it must never be merged onto a
stale prior rep (issue #348).
"""
head, _, _ = href.rpartition("/")
return head.endswith("/alarms/vs")
class ObserveManager:
"""Per-device observe-mode state: mode, write-settle guard, and (later)
subscription/staleness tracking. Pure sync logic — safe to call from
@@ -73,11 +94,32 @@ class ObserveManager:
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
# have notified. Guards `_notified` mutations, the `wait_for`, and
# fallback_hrefs (enter_observe_mode assignment, on_notification
# discard).
self._notify_cond = threading.Condition()
# Idle while polling, except after downgrade_to_poll (every href
# that was subscribed). While in observe mode this is the set of
# subscribed hrefs that have not yet notified (issue #92) -- they
# stay on the hot/warm sub-poll cadence. on_notification discards
# an href once it pushes, so a late first notify self-corrects.
self.fallback_hrefs: set[str] = set()
self._on_applied: Callable[[str, dict, str], None] | None = None
self._refresh_task: ObserveRefreshTask | None = None
self._refresh_stop: threading.Event | None = None
self._refresh_thread: threading.Thread | None = None
def set_on_applied(self, callback: Callable[[str, dict, str], None]) -> None:
"""Hook run after every accepted rep, on the applying thread.
Unlike StateCache.set_on_change it carries the href and rep, and
fires even when the rep is unchanged -- which learned.py needs, a
device sitting in an unadvertised mode re-sending the same rep
every poll.
"""
self._on_applied = callback
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
with self._settle_lock:
self._settle_until[href] = time.monotonic() + settle_s
@@ -107,6 +149,20 @@ class ObserveManager:
comes through, even though nothing about the device's actual
supported options changed.
`_is_alarms_href` is the one exception to that merge (issue #348):
/alarms/vs/0's `items` array is always sent as a complete
snapshot of every currently-active alarm, never a partial delta
-- confirmed by a live `read_resource` GET returning `{}` (no
`items` key at all) the moment a washer's board actually clears
an alarm, which entity.py already documents as this resource's
normal no-alarm shape. Merging that `{}` onto the prior rep the
same way as everywhere else silently kept the stale `items`
entry forever: an absent key merges as "unchanged" everywhere
else, but on this href absent specifically means "cleared".
Every family that exposes an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so this is a full
replace for all of them, not a washer-specific carve-out.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
@@ -130,12 +186,19 @@ class ObserveManager:
happened to expire, i.e. the exact symptom this guard exists to
prevent, just relocated to whichever write loses the race.
"""
if source != 'optimistic' and self._is_settling(href):
if source != "optimistic" and self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
return self.cache.apply_rep(href, merged, source=source)
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
changed = self.cache.apply_rep(href, merged, source=source)
# Outside the cache lock -- the hook takes locks of its own and
# never reads the cache back. `source` is passed along rather than
# filtered here: which sources are worth acting on is the hook's
# policy, not this manager's.
if self._on_applied is not None:
self._on_applied(href, merged, source)
return changed
def on_notification(self, href: str, payload: bytes) -> None:
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
@@ -147,10 +210,16 @@ class ObserveManager:
return
if not isinstance(rep, dict):
return
self._notified.add(href)
self._last_notify_ts = time.monotonic()
with self._notify_cond:
self._notified.add(href)
# Snapshot in enter_observe_mode is at the 80% quorum, not the
# full grace period -- a late first push (blockwise refetch)
# must drop the href so we don't keep GET-polling a live one.
self.fallback_hrefs.discard(href)
self._last_notify_ts = time.monotonic()
self._notify_cond.notify_all()
self.log.debug("observe notify: %s", href)
self.apply(href, rep, source='observe')
self.apply(href, rep, source="observe")
def recently_notified(self, window_s: float = PUSH_HEALTH_WINDOW_S) -> bool:
"""True if any OBSERVE notify has arrived within `window_s`.
@@ -163,45 +232,95 @@ class ObserveManager:
channel has been perfectly healthy").
"""
return (
self._last_notify_ts is not None
and time.monotonic() - self._last_notify_ts < window_s
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
)
def try_enter_observe_mode(
self, session, hrefs: list[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — subscribes to every href then sleeps for the whole
grace period. Caller must run this in an executor, never on the
event loop."""
self._notified.clear()
def subscribe_hrefs(self, session, hrefs: list[str]) -> set[str]:
"""Register OBSERVE on every href; returns the ones that took.
Blocking — run in an executor.
Split from the grace wait below (issue #294) so the coordinator can
hold its session lock for just these sends -- each is a fire-and-
forget UDP datagram (DtlsCoapSession.subscribe doesn't wait for the
device's ack), unlike the wait, which can block for the whole grace
period and must not hold a lock a command write is also waiting on.
"""
with self._notify_cond:
self._notified.clear()
subscribed: set[str] = set()
for href in hrefs:
segs = [s for s in href.strip('/').split('/') if s]
segs = [s for s in href.strip("/").split("/") if s]
try:
session.subscribe(segs)
subscribed.add(href)
except Exception as e:
self.log.warning("subscribe %s failed: %s", href, e)
return subscribed
def await_observe_notifies(
self,
subscribed: set[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — waits up to `grace_period_s`, returning early once
`success_fraction` of `subscribed` have notified. Touches no
session; safe to run without holding a session lock."""
if not subscribed:
self._stop_refresh_task()
self._set_mode(MODE_POLL)
self.subscribed_hrefs = set()
return False
time.sleep(grace_period_s)
def _fraction_reached() -> bool:
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
fraction = len(set(self._notified) & subscribed) / len(subscribed)
if fraction >= success_fraction:
self.subscribed_hrefs = subscribed
self._set_mode(MODE_OBSERVE)
self.start_refresh_task(session)
return True
with self._notify_cond:
return self._notify_cond.wait_for(_fraction_reached, timeout=grace_period_s)
def enter_observe_mode(self, session, subscribed: set[str]) -> None:
"""Commit a successful attempt. Caller must have re-confirmed
`session` is still the live one under its session lock (issue
#294) -- committing against a session a reconnect already replaced
would claim observe mode with nothing left to notice it's dead."""
self.subscribed_hrefs = set(subscribed)
# Issue #92: subscribed-but-silent hrefs are counted as covered by
# push if we drop this, but they never emit a notify. Keep them on
# the poll cadence via fallback_hrefs (otherwise idle in observe).
# Same lock as on_notification's discard so a notify in this window
# cannot land on a set object that is about to be replaced.
with self._notify_cond:
self.fallback_hrefs = set(subscribed) - self._notified
self._set_mode(MODE_OBSERVE)
self.start_refresh_task(session)
def abandon_observe_attempt(self) -> None:
"""Drop a failed or stale attempt: no subscriptions worth keeping."""
self._stop_refresh_task()
self.subscribed_hrefs = set()
self._set_mode(MODE_POLL)
def try_enter_observe_mode(
self,
session,
hrefs: list[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — subscribes to every href then waits up to
`grace_period_s`, returning early once `success_fraction` of hrefs
have notified. Caller must run this in an executor, never on the
event loop.
Single-threaded convenience wrapper around the phase split above
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
abandon_observe_attempt) for callers -- direct and most existing
tests -- that don't need the lock-scoping those phases exist for."""
subscribed = self.subscribe_hrefs(session, hrefs)
if not subscribed:
self.abandon_observe_attempt()
return False
if self.await_observe_notifies(subscribed, grace_period_s, success_fraction):
self.enter_observe_mode(session, subscribed)
return True
self.abandon_observe_attempt()
return False
def _set_mode(self, mode: str) -> None:
@@ -256,7 +375,8 @@ class ObserveManager:
found = True
self.log.debug(
"observe missed a change on %s (sweep disagrees with cache): %s",
href, diff,
href,
diff,
)
return found
@@ -268,15 +388,19 @@ class ObserveManager:
def start_refresh_task(self, session) -> None:
self._stop_refresh_task()
paths = [tuple(h.strip('/').split('/')) for h in self.subscribed_hrefs]
paths = [tuple(h.strip("/").split("/")) for h in self.subscribed_hrefs]
self._refresh_task = ObserveRefreshTask(
session, paths, interval_s=REFRESH_INTERVAL_S, logger=self.log,
session,
paths,
interval_s=REFRESH_INTERVAL_S,
logger=self.log,
)
self._refresh_stop = threading.Event()
self._refresh_thread = threading.Thread(
target=self._refresh_task.run_forever,
args=(self._refresh_stop,),
daemon=True, name='localthings-observe-refresh',
daemon=True,
name="localthings-observe-refresh",
)
self._refresh_thread.start()
@@ -6,8 +6,9 @@ to the matching capabilities, and flattens the result into HA-ready entity
state. The DTLS/CoAP transport itself lives in the smartthings-local
package, not here.
"""
from .adapter import flatten, _key
from .adapter import _key, flatten
from .discovery import BoundEntity, discover
from .registry import CAPABILITIES
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', '_key']
__all__ = ["CAPABILITIES", "BoundEntity", "_key", "discover", "flatten"]
@@ -1,4 +1,5 @@
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
from __future__ import annotations
from typing import Any
@@ -1,4 +1,5 @@
"""OCF /device/0 batch response parser."""
from __future__ import annotations
@@ -11,22 +12,27 @@ def is_stub_rep(rep: dict) -> bool:
never populate. Conflating the two used to make every field-gated entity
on a permanently-empty resource look like a not-yet-fetched stub forever,
creating phantom always-"unknown" entities (issue #127)."""
return isinstance(rep, dict) and set(rep.keys()) == {'href'}
return isinstance(rep, dict) and set(rep.keys()) == {"href"}
def parse_device0_batch(device0: list) -> dict[str, dict]:
"""Extract {href: rep} from a /device/0 CBOR list response.
Most devices put a collection representation without an ``href`` at
index 0, while some firmware starts directly with resource entries.
Iterate the whole list and let the existing href check ignore collection
metadata so the first real resource is preserved in either shape.
A stub rep is passed through unchanged rather than collapsed to {} --
downstream code (entity._is_included, capability exists_fns) uses
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
"""
out = {}
for entry in device0[1:]: # skip [0] (device-level rep)
for entry in device0:
if not isinstance(entry, dict):
continue
href = entry.get('href')
rep = entry.get('rep')
href = entry.get("href")
rep = entry.get("rep")
if not href:
continue
if isinstance(rep, dict):
@@ -1,123 +1,139 @@
"""Per-device-type registries."""
import re
from typing import Optional, Sequence
from ._base import DeviceRegistry
import re
from collections.abc import Sequence
from . import (
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
dehumidifier, dishwasher, dryer, induction_cooktop, microwave, oven,
range as _range, range_hood, refrigerator, vacuum_station, washer,
air_dresser,
air_monitor,
air_purifier,
airconditioner,
cooktop,
dehumidifier,
dishwasher,
dryer,
ehs,
induction_cooktop,
microwave,
oven,
range_hood,
refrigerator,
vacuum_station,
washer,
water_purifier,
)
from . import (
range as _range,
)
from ._base import DeviceRegistry
__all__ = [
'DeviceRegistry', 'resolve', 'for_device_by_oic_type', 'for_device_by_model',
'for_device_by_resources', '_board_tokens',
"DeviceRegistry",
"_board_tokens",
"for_device_by_model",
"for_device_by_oic_type",
"for_device_by_resources",
"resolve",
]
# One entry per registry, no aliases: every key here is reachable from
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_monitor': air_monitor.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dehumidifier': dehumidifier.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'range': _range.REGISTRY,
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'vacuum_station': vacuum_station.REGISTRY,
'washer': washer.REGISTRY,
'water_purifier': water_purifier.REGISTRY,
"air_dresser": air_dresser.REGISTRY,
"air_monitor": air_monitor.REGISTRY,
"air_purifier": air_purifier.REGISTRY,
"airconditioner": airconditioner.REGISTRY,
"cooktop": cooktop.REGISTRY,
"dehumidifier": dehumidifier.REGISTRY,
"dishwasher": dishwasher.REGISTRY,
"dryer": dryer.REGISTRY,
"ehs": ehs.REGISTRY,
"induction_cooktop": induction_cooktop.REGISTRY,
"microwave": microwave.REGISTRY,
"oven": oven.REGISTRY,
"range": _range.REGISTRY,
"range_hood": range_hood.REGISTRY,
"refrigerator": refrigerator.REGISTRY,
"vacuum_station": vacuum_station.REGISTRY,
"washer": washer.REGISTRY,
"water_purifier": water_purifier.REGISTRY,
}
# Consumer-model prefix (first two letters of the '_'-delimited token in
# `description` right before any '/board-info' suffix) -> registry key.
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
# internal board-family prefix there, and dishwasher's modelNum contains
# the substring 'WW', so a modelNum-only rule misroutes both.
# NOT derived from `modelNum`: washer and dryer share the same 'DA_WM_'
# board-family prefix there, and dishwasher's modelNum contains the
# substring 'WW', so a modelNum-only rule misroutes both.
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'WW': 'washer',
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
'DV': 'dryer',
'DW': 'dishwasher',
"WW": "washer",
"WD": "washer",
"WF": "washer",
"WV": "washer", # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
"WA": "washer", # Top-load washers (e.g. WA8000T) -- issue #106
"DV": "dryer",
"DW": "dishwasher",
}
# Board-family token -> registry key, matched against whole tokens of
# `modelNum`/`description` (see `_board_tokens`).
#
# Tokenizing instead of substring-matching is what keeps this a table rather
# than a ladder of hand-written rules. Samsung spells the same board family
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
# same RAC family -- so a substring rule has to be written once per spelling
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
# every one of those as a single entry.
# Tokenizing instead of substring-matching keeps this a table rather than a
# ladder of hand-written rules: Samsung spells the same board family with
# either delimiter ('TP1X_DA-AC-RAC-01001' vs 'TP2X_RAC_20K', both RAC), so
# a substring rule would need writing once per spelling, and a token with
# no trailing delimiter ('ARTIK051_DONGLE_REF') would match neither.
#
# Entries must name the *specific* device type, never the board family that
# Entries must name the specific device type, never the board family that
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
# would swallow the dehumidifier and the air purifier. Where two families
# genuinely share a resource surface they share a registry (all the
# air-conditioner spellings below), which is a statement about the hardware,
# not a shortcut.
# genuinely share a resource surface they share a registry (the
# air-conditioner spellings below), which is a statement about the
# hardware, not a shortcut.
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
'REF': 'refrigerator',
# Air conditioners. Every one of these is a distinct board family with
# the same resource surface: room (issues #37, #91), package, Korean
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
'RAC': 'airconditioner',
'PRAC': 'airconditioner',
'KRAC': 'airconditioner',
'WAC': 'airconditioner',
'FAC': 'airconditioner',
'CAWW': 'airconditioner',
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
'ARA': 'airconditioner',
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a
# next-gen BESPOKE Cube Air board; same
# lineage as VTWW above but the '-WW-'
# delimiter now falls one letter to the
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
# into a token the existing entry can't see
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
'WATERPURIFIER': 'water_purifier', # issue #90
'ADW': 'dishwasher',
'AHD': 'range_hood',
'RANGE': 'range', # issue #44 -- cooktop+oven combo
'OVEN': 'oven', # issue #55 -- wall oven, no burners
'MICROWAVE': 'microwave', # issues #66, #121
'COOKTOP': 'induction_cooktop', # issue #86 -- standalone, no oven
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
# token, and so the loosest entry in this table -- it is only ever
# reached by a device that matched nothing more specific, and its
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
# for the field ordering that makes that resolve correctly.
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
'VSWW': 'vacuum_station', # issue #219
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
"REF": "refrigerator",
# Air conditioners: distinct board families sharing one resource
# surface -- room, package, Korean (#136), window (#87), 2-in-1
# floor+wall (#150/#153), system/commercial (#52), cassette (#191), and
# ARA-WW wall-mount (#115-120).
"RAC": "airconditioner",
"PRAC": "airconditioner",
"KRAC": "airconditioner",
"WAC": "airconditioner",
"FAC": "airconditioner",
"CAWW": "airconditioner",
"CAC": "airconditioner", # issue #191
"ARA": "airconditioner",
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
"EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water
"TVTL": "air_purifier", # issue #56 (ARTIK051)
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
# issue #190: same lineage as VTWW, but the '-WW-' delimiter falls one
# letter left ('A-VTWW-' -> 'AVT-WW-'), splitting into a different token.
"AVT": "air_purifier",
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
"WATERPURIFIER": "water_purifier", # issue #90
"ADW": "dishwasher",
"AHD": "range_hood",
"RANGE": "range", # issue #44 -- cooktop+oven combo
"OVEN": "oven", # issue #55 -- wall oven, no burners
"MICROWAVE": "microwave", # issues #66, #121
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'): burner state
# lives in /mode/vs/0's options array. Deliberately the loosest entry
# here -- reached only when nothing more specific matched, since its
# description ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# induction cooktop via COOKTOP above (see for_device_by_model's field
# ordering).
"CT": "cooktop",
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
"DF": "air_dresser", # issue #162
"VSWW": "vacuum_station", # issue #219
"ASM": "air_monitor", # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
_TOKEN_SPLIT_RE = re.compile(r"[^A-Z0-9]+")
def _board_tokens(value: str, cut_at: str) -> list[str]:
@@ -128,31 +144,27 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
contain anything) and after description's first '/' (a '/DC92-...' board
part number).
"""
head = (value or '').split(cut_at, 1)[0].upper()
head = (value or "").split(cut_at, 1)[0].upper()
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
def _board_family_key(value: str, cut_at: str) -> Optional[str]:
def _board_family_key(value: str, cut_at: str) -> str | None:
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
No known modelNum or description yields two *conflicting* board keys, so
which token is found first doesn't matter within one field -- the table is
a flat lookup, not a priority list. Adding an entry that could co-occur
with another (a family token, or one short enough to collide by accident)
would break that property; see this table's comment.
No known modelNum or description yields two conflicting board keys, so
which token is found first doesn't matter within one field -- the
table is a flat lookup, not a priority list.
One documented exception (issue #196): AILITE water-purifier boards
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
shared cooling-subsystem board, not the refrigerator device type --
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
or rename either entry (both are correct on their own for the model
strings that exist today), this one known co-occurrence resolves to
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
matching carve-out for this exact pair.
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names
the shared cooling-subsystem board, not the refrigerator type --
'WATERPURIFIER' is the actual, more specific type. This one known
co-occurrence resolves to 'water_purifier'; TestBoardTokenAmbiguity
carries a matching carve-out for this exact pair.
"""
tokens = _board_tokens(value, cut_at)
if 'REF' in tokens and 'WATERPURIFIER' in tokens:
return 'water_purifier'
if "REF" in tokens and "WATERPURIFIER" in tokens:
return "water_purifier"
for token in tokens:
key = _BOARD_TOKEN_TO_KEY.get(token)
if key is not None:
@@ -160,32 +172,26 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
return None
def _consumer_model_key(description: str) -> Optional[str]:
def _consumer_model_key(description: str) -> str | None:
"""Registry key from the consumer-model token in `description`, or None.
Usually that token is the last '_'-delimited segment before any
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
But issue #79's dryer pairs two model numbers in one description --
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token
('DVE50A8800') sits one segment *before* the actual last segment
('8600', a bare second model number with no recognizable prefix). Scan
segments from the end and take the first one that resolves, rather
than assuming the last segment is always it.
But issue #79's dryer pairs two model numbers in one description, so
the true consumer token sits one segment before the actual last
segment -- scan from the end and take the first segment that resolves.
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
prefixes matched against the *start* of a segment, so widening the split
to '-' as well would start reading board-family segments as consumer
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
'WW' segment and route to washer.
Splits on '_' only, unlike `_board_tokens` above: widening the split to
'-' would start reading board-family segments as consumer models (the
dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare 'WW' and
route to washer).
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
board-family token, issue #87) also starts with 'WA' (the top-load-washer
prefix, issue #106) at this granularity. for_device_by_model() consults
the board-family table first and this function only as a fallback, so
that ambiguity resolves correctly without this function needing to know
about unrelated device families.
Only a 2-letter prefix match, so e.g. 'WAC' (Window AC, issue #87) also
matches 'WA' (top-load washer, issue #106) at this granularity --
for_device_by_model() consults the board-family table first and this
only as a fallback, so that ambiguity resolves correctly.
"""
segments = (description or '').split('/', 1)[0].split('_')
segments = (description or "").split("/", 1)[0].split("_")
for segment in reversed(segments):
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
if key is not None:
@@ -194,49 +200,50 @@ def _consumer_model_key(description: str) -> Optional[str]:
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
# -> registry key. This is the device naming its own type -- no board-part
# guessing involved -- so it's consulted before modelNum/description at all.
# -> registry key. The device naming its own type, no board-part guessing --
# consulted before modelNum/description.
#
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list
# stops well short of the full OCF/SmartThings device-type vocabulary: a
# compiled list of `x.com.st.d.*` types will include plenty of device
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy
# meters, ...) no Samsung DA appliance dump could ever report and this
# integration has no registry for -- and 'oic.d.robotcleaner' names an
# actual robot vacuum, a different product from the clean/auto-empty
# *station* `vacuum_station` covers (see that registry's own module
# docstring); mapping it there would misroute a genuine robot-vacuum dump
# into a registry with no vacuum-body capabilities at all. Add a row only
# once there's a real registry key on the right-hand side to point at.
# `test_every_oic_type_resolves_to_a_real_registry`) -- this deliberately
# stops short of the full OCF/SmartThings vocabulary, since most of it (lights,
# locks, cameras, TVs, ...) has no registry here to point at, and
# 'oic.d.robotcleaner' names an actual robot vacuum, a different product from
# the clean/auto-empty *station* `vacuum_station` covers.
#
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
# device-type vocabulary (used for categories with no `oic.d.*` equivalent),
# same prefix convention as the `x.com.samsung.da.*` resource fields
# elsewhere in this codebase.
# device-type vocabulary, for categories with no `oic.d.*` equivalent.
#
# `oic.d.cooktop` is deliberately absent: a TP1X_DA-KS-COOKTOP induction
# reports it, but `cooktop` and `induction_cooktop` are unrelated registries
# sharing the English word (see by_type/cooktop.py's docstring) -- the OCF
# type doesn't distinguish them, and as the primary signal it would override
# a correct `COOKTOP`/`CT` board token. No unambiguous key to point at, so no
# row.
_OIC_TYPE_TO_KEY: dict[str, str] = {
'oic.d.airconditioner': 'airconditioner',
'oic.d.airpurifier': 'air_purifier',
'oic.d.dishwasher': 'dishwasher',
'oic.d.dryer': 'dryer',
'oic.d.oven': 'oven',
'oic.d.refrigerator': 'refrigerator',
'oic.d.washer': 'washer',
'x.com.st.d.airqualitysensor': 'air_monitor',
'x.com.st.d.stickcleaner': 'vacuum_station',
'x.com.st.d.steamcloset': 'air_dresser',
"oic.d.airconditioner": "airconditioner",
"oic.d.airpurifier": "air_purifier",
"oic.d.dishwasher": "dishwasher",
"oic.d.dryer": "dryer",
"oic.d.oven": "oven",
"oic.d.range": "range", # issue #324 -- oven+cooktop combo, no /information/vs/0
"oic.d.refrigerator": "refrigerator",
"oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator
"oic.d.washer": "washer",
"x.com.st.d.airqualitysensor": "air_monitor",
"x.com.st.d.dehumidifier": "dehumidifier",
"x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON
"x.com.st.d.stickcleaner": "vacuum_station",
"x.com.st.d.steamcloset": "air_dresser",
"x.com.st.d.winecellar": "refrigerator", # issue #328 -- same TP1X_REF_21K board
}
def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegistry]:
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
declaration.
The primary path when a dump carries it: the device names its own type,
so there's nothing to infer from board part numbers. Most hardware still
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
this only ever helps a minority of dumps, and `for_device_by_model`/
`for_device_by_resources` remain load-bearing for everything else.
declaration. The primary path when a dump carries it, since the device
names its own type. Most hardware still doesn't populate `/oic/d`
usefully, so `for_device_by_model`/`for_device_by_resources` remain
load-bearing for everything else.
"""
for device_type in device_types:
key = _OIC_TYPE_TO_KEY.get(device_type)
@@ -245,7 +252,7 @@ def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegist
return None
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | None:
"""Device-type detection from /information/vs/0's model strings.
The primary path: the board named in `modelNum` determines the resource
@@ -275,90 +282,89 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
known type, None otherwise.
"""
key = (
_board_family_key(model_num, '|')
or _board_family_key(description, '/')
_board_family_key(model_num, "|")
or _board_family_key(description, "/")
or _consumer_model_key(description)
)
return _REGISTRY_BY_KEY.get(key) if key else None
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
"""Detect a device family from a distinctive local-resource signature.
For boards that ship no ``/information/vs/0`` at all, leaving
`for_device_by_model` nothing to read. Some newer cooktops are the
original case: their mode resource still identifies them, carrying a
DeviceType option and multiple per-burner OperationState options.
Runs first as an override path for non-standard devices -- not because
resource signatures are more trustworthy than OIC/model metadata, but
because it also types boards with no ``/information/vs/0`` at all.
Some newer cooktops were the original case: their mode resource still
identifies them via a DeviceType option and multiple per-burner
OperationState options.
Require two independent shapes for every signature here, never one, so
an unrelated family's ``/mode/vs/0`` isn't misclassified.
Every signature here requires two independent shapes, never one, so
running this ahead of OIC/model metadata can't let a common resource
misclassify an unrelated family.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
mode = resources.get("/mode/vs/0", {})
options = mode.get("x.com.samsung.da.options") or ()
has_device_type = any(
isinstance(option, str) and option.startswith('DeviceType_')
for option in options
isinstance(option, str) and option.startswith("DeviceType_") for option in options
)
operation_states = sum(
1 for option in options
if isinstance(option, str) and option.startswith('OperationState')
1 for option in options if isinstance(option, str) and option.startswith("OperationState")
)
if has_device_type and operation_states >= 2:
return _REGISTRY_BY_KEY['cooktop']
if (
'/hood/fanspeed/vs/0' in resources
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
return _REGISTRY_BY_KEY["cooktop"]
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
return _REGISTRY_BY_KEY["range_hood"]
# Oven/range/microwave boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
# absent from the dump, not just empty) can't be matched via
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
# the oven cavity resource (/oven/vs/0) is a safe signature.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources:
# (issues #74, #172) can't be matched via modelNum tokens either. Mode
# vocabulary alongside the oven cavity resource (/oven/vs/0) is a safe
# two-resource signature; it also corrects Qooker's generic oic.d.oven
# metadata (PR #225) since resource detection runs before it.
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
if not isinstance(supported_modes, (list, tuple)):
supported_modes = ()
cavity = resources.get("/oven/vs/0")
if isinstance(cavity, dict):
if any(
m in supported_modes
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
m in supported_modes for m in ("MicroWave", "MicroWaveGrill", "MicroWaveConvection")
):
return _REGISTRY_BY_KEY['microwave']
if 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
return _REGISTRY_BY_KEY["microwave"]
if "Bake" in supported_modes:
if "/cooktopmonitoring/vs/0" in resources or "/cooktop/status/vs/0" in resources:
return _REGISTRY_BY_KEY["range"]
return _REGISTRY_BY_KEY["oven"]
return None
def resolve(
resources: dict[str, dict], device_types: Sequence[str] = (),
) -> Optional[DeviceRegistry]:
resources: dict[str, dict],
device_types: Sequence[str] = (),
) -> DeviceRegistry | None:
"""Device type for a parsed /device/0 dump, or None if unrecognized.
The single entry point for detection -- the coordinator, the config
flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert.
`device_types` (/oic/d's `rt`, read separately from the /device/0 dump --
see registry/identity.py) is the primary signal when present: the device
naming its own type beats parsing board part numbers. Falls back to model
strings (`for_device_by_model`), then a distinctive resource signature
(`for_device_by_resources`) for boards that report no /information/vs/0
at all -- both unchanged from before /oic/d was ever consulted, since most
hardware still doesn't populate it usefully.
Distinctive resource signatures run first, since they describe the
live capability surface a registry must bind; `for_device_by_resources`
is deliberately strict (multiple independent details required) so this
can correct misleading metadata without a common href overriding an
unrelated family. When no signature matches, `/oic/d`'s `rt` wins over
model-string parsing.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
reads like the obvious signal -- the device naming its own type, e.g.
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
device that does is already typed by its modelNum board token, and no
device-support issue has ever been fixed by adding a mapping for it. It
is still reported in diagnostics as a firmware-generation marker.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted:
only a minority of hardware populates it, every device that does is
already typed by its modelNum board token, and no device-support issue
has ever needed it. Still reported in diagnostics as a firmware
marker.
"""
info = resources.get('/information/vs/0', {})
info = resources.get("/information/vs/0", {})
return (
for_device_by_oic_type(device_types)
for_device_by_resources(resources)
or for_device_by_oic_type(device_types)
or for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
info.get("x.com.samsung.da.modelNum", ""),
info.get("x.com.samsung.da.description", ""),
)
or for_device_by_resources(resources)
)
@@ -1,4 +1,5 @@
"""Base DeviceRegistry dataclass and builder."""
from __future__ import annotations
from dataclasses import dataclass, field
@@ -9,6 +10,7 @@ from ..capability import Capability
@dataclass(frozen=True)
class DeviceRegistry:
"""Registry of capabilities for a specific device type."""
name: str
capabilities: dict[str, list[Capability]]
pattern_capabilities: list[Capability] = field(default_factory=list)
@@ -31,17 +33,19 @@ def _build(caps: list[Capability]) -> dict[str, list[Capability]]:
for cap in caps:
if cap.href is None:
raise ValueError(f"Use pattern_capabilities for href=None caps")
raise ValueError("Use pattern_capabilities for href=None caps")
if cap.href_prefix is not None:
raise ValueError(
f"href_prefix is only valid for pattern caps (href=None); "
f"cap with href={cap.href!r} must not set href_prefix")
f"cap with href={cap.href!r} must not set href_prefix"
)
out.setdefault(cap.href, []).append(cap)
# Validate that multi-cap hrefs have proper discrimination
for href, cs in out.items():
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
raise ValueError(
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn")
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn"
)
return out
@@ -8,21 +8,24 @@ expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_dresser',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
name="air_dresser",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
)
@@ -5,19 +5,22 @@ board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_monitor',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]),
name="air_monitor",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]
),
)
@@ -22,36 +22,46 @@ match_fn discriminators that keep them from colliding):
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
needed.
AIR_LEVEL_CHECK ("AI Purify" -- the periodic air-quality sensing engine on
/airlevelcheck/vs/0) is shared by the last three of those: their dumps all
carry the resource with the same field names, and only the TVTL family has no
such href. It was covered as opaque plumbing until two AVT-WW-TP1 dumps
(issues #84 and #190) showed it drives a real user-facing feature.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
contract).
"""
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.WIND_STRENGTH_FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]),
name="air_purifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.AIR_LEVEL_CHECK,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.WIND_STRENGTH_FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]
),
)
@@ -16,31 +16,59 @@ uses unconditionally.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
"""
from ..capabilities import airconditioner, common, dishwasher, ignored
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
airconditioner.ENERGY_METER_GENERIC,
airconditioner.ENERGY_METER_LEGACY,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
*airconditioner.COVERAGE,
]),
name="airconditioner",
capabilities=_build(
[
*ignored.IGNORED,
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
airconditioner.ENERGY_METER_GENERIC,
airconditioner.ENERGY_METER_LEGACY,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_FILTER_PM1,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.UV_LED,
airconditioner.VENTILATION_ALARM,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
# TP1X_DA-AC-FAC-class (issue #319): shares its /display/vs/0,
# /settings/sound/output/vs/0 and /settings/sound/volume/vs/0
# shape with the sibling TP1X_DA-AC-AIR board in air_purifier.py.
air_purifier.DISPLAY,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.SOUND_MODE,
airconditioner.ABSENCE_CLEAN,
airconditioner.MDS_ABSENCE_CLEAN,
airconditioner.ENERGY_SAVING,
airconditioner.EDGE_LIGHTING,
airconditioner.LIGHT_STATEFUL,
# System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000):
# WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/
# DEVICE_ACTIVE reuse air_purifier.py's identical shapes.
# AIR_LEVEL_CHECK is not this-device-specific -- see its
# removal from _AC_IGNORED above.
airconditioner.WINDFREE,
airconditioner.WINDSLEEP,
air_purifier.HEPA_FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIR_LEVEL_CHECK,
*airconditioner.COVERAGE,
]
),
)
@@ -12,15 +12,22 @@ key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
from ..capabilities import common, cooktop, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='gas_cooktop',
capabilities=_build([
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]),
name="gas_cooktop",
capabilities=_build(
[
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
# issue #314: /alarms/vs/0 and /kidslock/vs/0 are the same
# generic shapes common.UNIVERSAL already models elsewhere --
# picked individually rather than pulling in all of UNIVERSAL,
# matching this registry's existing hand-picked-common style.
common.ALARMS,
common.KIDS_LOCK_VS_FALLBACK,
]
),
)
@@ -2,22 +2,30 @@
Shares the DA_AC_ board family with airconditioner.py (power, air filter,
auto-clean, mute-once all use the identical resource shapes), so those three
Capability objects are reused directly rather than duplicated.
Capability objects are reused directly rather than duplicated. The
TP1X_DA_AC_DHM_01001_0000 revision (issues #271/#231) also reports
air_purifier.py's screen-on/off resource on the identical href/shape, so
that's reused too rather than re-defined.
"""
from ..capabilities import airconditioner, common, dehumidifier, ignored
from ..capabilities import air_purifier, airconditioner, common, dehumidifier, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dehumidifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
*dehumidifier.COVERAGE,
]),
name="dehumidifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
dehumidifier.WATERTANK_LIGHTING,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
air_purifier.DISPLAY,
*dehumidifier.COVERAGE,
]
),
)
@@ -1,23 +1,26 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dishwasher',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]),
name="dishwasher",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]
),
)
@@ -9,22 +9,25 @@ objects the washer registry uses -- washer and dryer expose the same
DA_WM_-family surface, so they stay consistent instead of each carrying a
bespoke variant.
"""
from ..capabilities import common, dryer, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dryer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]),
name="dryer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]
),
)
@@ -0,0 +1,32 @@
"""EHS (Eco Heating System) air-to-water heat pump device registry
(Samsung TP1X_DA_AC_EHS-class).
Shares the DA_AC_ board prefix with the room-AC family in
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
are its own shape (two independent loops: zone1 space heating/cooling and
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
pattern -- so nothing from that module is reused here except MUTE_ONCE,
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
family's dump.
"""
from ..capabilities import airconditioner, common, ehs, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="ehs",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
airconditioner.MUTE_ONCE,
ehs.ZONE_POWER,
ehs.ZONE_MODE,
ehs.ZONE_TEMPERATURE,
ehs.DHW,
*ehs.DHW_CONSUMED,
ehs.AWAY_MODE,
*ehs.COVERAGE,
]
),
)
@@ -11,20 +11,24 @@ units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
embedded in /mode/vs/0's options array, a completely different OCF
surface that happens to share the English word "cooktop").
"""
from ..capabilities import common, cooktop as cooktop_caps, ignored
from ..capabilities import common, ignored
from ..capabilities import cooktop as cooktop_caps
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='induction_cooktop',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]),
name="induction_cooktop",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]
),
)
@@ -16,22 +16,25 @@ has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
case (see its `_speed_zero_is_off` check).
"""
from ..capabilities import common, ignored, microwave, oven, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='microwave',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_SETPOINT,
microwave.MICROWAVE_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]),
name="microwave",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_SETPOINT,
microwave.MICROWAVE_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]
),
)
@@ -1,20 +1,26 @@
"""Oven device registry."""
from ..capabilities import common, ignored, oven
from ..capabilities import common, dishwasher, ignored, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='oven',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
]),
name="oven",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
# issue #300: /diagnosis/vs/0 is the same diagnosisStart shape
# dishwasher.py and airconditioner.py already reuse.
dishwasher.DIAGNOSIS,
]
),
)
@@ -5,26 +5,29 @@ connected capabilities wholesale (a range's oven half is the same OCF
surface as a standalone oven) and adds the cooktop-specific capabilities
for the burner half.
"""
from ..capabilities import common, ignored, oven
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]),
name="range",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]
),
)
@@ -3,21 +3,22 @@
from ..capabilities import common, ignored, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range_hood',
capabilities=_build([
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]),
name="range_hood",
capabilities=_build(
[
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]
),
)
@@ -1,37 +1,46 @@
"""Refrigerator device registry."""
from ..capabilities import common, dishwasher, fridge, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]),
name="refrigerator",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
fridge.AIR_FILTER,
fridge.DEODOR_FILTER,
fridge.AUTO_DOOR_TIMER,
fridge.WINECELLAR_PANTRY_ZONE,
fridge.WINECELLAR_INFO,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DEFINITE_TEMPERATURE_FREEZER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]
),
pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC,
fridge.TEMP_SETPOINT,
@@ -39,5 +48,6 @@ REGISTRY = DeviceRegistry(
fridge.DOOR_GENERIC,
fridge.KIMCHI_ZONE,
fridge.KIMCHI_DOOR_GENERIC,
fridge.AUTO_DOOR_VARIANT,
],
)
@@ -1,22 +1,24 @@
"""Stick-vacuum clean/auto-empty station device registry (issue #131).
"""Stick-vacuum clean/auto-empty station device registry (issues #131 / #219).
See capabilities/vacuum_station.py's module docstring for why this only
covers the station's own dustbag/dustbin/UV-sanitize state and not any
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
dump this was built from reports none of that.
Station dustbag/dustbin/UV-sanitize state plus, when present (VS9700),
wand battery/charging via `/status/stick/vs/0`. No suction/room-map control.
"""
from ..capabilities import common, ignored, vacuum_station
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='vacuum_station',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
vacuum_station.DUSTBAG,
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
]),
name="vacuum_station",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
vacuum_station.DUSTBAG,
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
vacuum_station.STICK_BODY,
]
),
)
@@ -1,19 +1,22 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='washer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
name="washer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
)
@@ -1,25 +1,27 @@
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90).
"""
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90)."""
from ..capabilities import common, ignored, water_purifier
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='water_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
common.WATER_FILTER,
water_purifier.DISPENSE,
water_purifier.STATUS,
water_purifier.FAVORITE_CAPACITY,
water_purifier.FAVORITE_HOTWATER,
water_purifier.COFFEE,
water_purifier.LOCK,
water_purifier.CUP_STATE,
water_purifier.SOUND_MODE,
water_purifier.SOUND_OUTPUT,
water_purifier.SOUND_VOLUME,
water_purifier.STATISTIC_POUR,
*water_purifier.COVERAGE,
]),
name="water_purifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
common.WATER_FILTER,
water_purifier.DISPENSE,
water_purifier.STATUS,
water_purifier.FAVORITE_CAPACITY,
water_purifier.FAVORITE_HOTWATER,
water_purifier.COFFEE,
water_purifier.LOCK,
water_purifier.CUP_STATE,
water_purifier.SOUND_MODE,
water_purifier.SOUND_OUTPUT,
water_purifier.SOUND_VOLUME,
water_purifier.STATISTIC_POUR,
*water_purifier.COVERAGE,
]
),
)
@@ -1,8 +1,12 @@
from . import (
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
range_hood,
)
from ..capability import Capability
from . import (
common,
fridge,
ignored,
laundry,
operational,
oven,
)
def _is_capability(v):
@@ -18,5 +22,13 @@ _OVEN_GLOBAL_CAPS = [
oven.OVEN_CAVITY,
]
ALL = [v for mod in (common, operational, laundry, fridge)
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
ALL = (
[
v
for mod in (common, operational, laundry, fridge)
for v in vars(mod).values()
if _is_capability(v)
]
+ _OVEN_GLOBAL_CAPS
+ ignored.IGNORED
)
@@ -32,49 +32,59 @@ machinery:
/airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only;
#162's board doesn't report this resource at all)
"""
from ..capability import Capability
from ..entities import SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
def _sanitize_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p}
return ["airdresseroption", "sanitize", "vs", "0"], {"x.com.samsung.da.sanitize": p}
AIR_DRESSER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
href="/washer/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
icon="mdi:iron",
value_fn=lambda v: v == "On",
write_fn=_wrinkle_write,
),
),
)
AIR_DRESSER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew',
table_href='/st/airdressercourse/vs/0'),
cycle_select(
translation_key="air_dresser_cycle",
icon="mdi:tshirt-crew",
table_href="/st/airdressercourse/vs/0",
),
),
)
AIR_DRESSER_SANITIZE = Capability(
href='/airdresseroption/sanitize/vs/0',
poll_tier='warm',
href="/airdresseroption/sanitize/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:weather-sunny',
value_fn=lambda v: v == 'On',
write_fn=_sanitize_write),
SwitchDesc(
key="sanitize",
field="x.com.samsung.da.sanitize",
icon="mdi:weather-sunny",
value_fn=lambda v: v == "On",
write_fn=_sanitize_write,
),
),
)
@@ -7,30 +7,31 @@ only); this registry deliberately doesn't include common.POWER.
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
device shares those capabilities' catalog entries. This board reports a
CO2 reading; air_purifier.AIR_QUALITY now models the same type when a
purifier lists it (issue #387).
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board (no `supportedGrades`/similar field, no repeated dump to compare
against) confirms what its scale means -- left unbound rather than guessed,
per the adding-device-support skill's "still never invent... from nothing"
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
shape; index 0 is the only slot any family has ever read.
A second `value` list element on the particulate-matter types (Dust's
`['31', '2']`) is the device's own graded air-quality level for that
reading -- see common.sensor_item_value. Still unbound here: the grade's
floor differs by board family, and CleanLevel already carries the
aggregate. This board's own readings are load-bearing evidence for the
PM mapping, though: 23 grading one step above the floor as FineDust is
what rules out a PM10-width band for that field.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
ug/m3 particulate readings in a physically consistent order (coarser
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
three-tier naming doesn't confirm where the extra tier or a PM1 reading
actually fits. The adding-device-support skill's read-side rule says
leave unit/device_class unset when the dump gives no field that
nominates one -- a wrong guess would silently mislabel every reading
forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
Dust/FineDust/SuperFineDust carry the same HA `device_class`/`unit` as the
purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 /
SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this
board shares rather than on anything purifier-specific, so typing one
family and not the other would have been an inconsistency, not caution.
These sensors have recorded *unitless* long-term statistics since issue
#210, though, and Home Assistant suppresses statistics generation outright
for an entity whose unit no longer matches its recorded metadata -- so
stamping a unit on would have silently stopped the history it was meant to
label. __init__.py's v2->v3 entry migration relabels that metadata first.
"""
from datetime import time as dt_time
from ..capability import Capability
@@ -38,54 +39,89 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
# device_class/unit are taken from the shared rows; state_class deliberately
# is not. air_purifier leaves Odor/CleanLevel unstamped because they read as
# graded indices on that family, while this board has stamped all five as
# `measurement` since it was added (issue #210) -- consuming that column
# would silently drop long-term statistics for two sensors on shipped
# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on
# device-side grading this board shares (see the module docstring), and
# __init__.py's v2->v3 entry migration relabels the unitless statistics
# these five have been recording so the new unit doesn't suppress them.
SENSORS = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
state_class='measurement',
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
) + (
SensorDesc(key='co2', field='x.com.samsung.da.items',
device_class='carbon_dioxide', state_class='measurement',
unit='ppm',
value_fn=lambda items: sensor_item_value(items, 'CO2')),
href="/sensors/vs/0",
poll_tier="warm",
entities=(
*(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement",
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, _state_class, device_class, unit in _AIR_QUALITY_SENSORS
),
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
value_fn=lambda items: sensor_item_value(items, "CO2"),
),
),
)
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', state_class='measurement',
unit='%', value_fn=int_or_none),
SensorDesc(
key="humidity",
field="x.com.samsung.da.humidity",
device_class="humidity",
state_class="measurement",
unit="%",
value_fn=int_or_none,
),
),
)
BATTERY = Capability(
href='/energy/battery/vs/0',
poll_tier='warm',
href="/energy/battery/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='battery', field='x.com.samsung.da.battery',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic',
value_fn=int_or_none),
BinarySensorDesc(key='battery_charging', field='x.com.samsung.da.charging',
device_class='battery_charging',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(
key="battery",
field="x.com.samsung.da.battery",
device_class="battery",
state_class="measurement",
unit="%",
entity_category="diagnostic",
value_fn=int_or_none,
),
BinarySensorDesc(
key="battery_charging",
field="x.com.samsung.da.charging",
device_class="battery_charging",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
),
)
AIR_QUALITY_STANDARD = Capability(
href='/airqualitystandard/vs/0',
poll_tier='cold',
href="/airqualitystandard/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='air_quality_standard', field='x.com.samsung.da.standard',
entity_category='diagnostic'),
SensorDesc(
key="air_quality_standard",
field="x.com.samsung.da.standard",
entity_category="diagnostic",
),
),
)
@@ -96,7 +132,7 @@ def _parse_hms(v):
if not v:
return None
try:
parts = v.split(':')
parts = v.split(":")
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
@@ -104,38 +140,46 @@ def _parse_hms(v):
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ['dnd', 'vs', '0'], {field: f'{p.hour:02d}:{p.minute:02d}:00'}
return ["dnd", "vs", "0"], {field: f"{p.hour:02d}:{p.minute:02d}:00"}
return _write
# Issue #210: no idle-vs-active dump pair exists for this href (only one
# dump total, DND never toggled in it), so this write contract is an
# educated guess, not a confirmed one -- symmetric with the read side
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
# device itself reports back) rather than invented from nothing, but still
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
# Issue #210: only one dump exists (DND never toggled in it), so this write
# contract is an educated guess -- symmetric with the read side's own
# 'true'/'false' and 'HH:MM:SS' formats, but still needs a reporter to
# confirm it on real hardware.
DND = Capability(
href='/dnd/vs/0',
poll_tier='cold',
href="/dnd/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='dnd', field='x.com.samsung.da.value',
icon='mdi:sleep',
entity_category='config',
value_fn=lambda v: v == 'true',
write_fn=lambda p, rep, href=None: (
['dnd', 'vs', '0'],
{'x.com.samsung.da.value': 'true' if p == 'On' else 'false'})),
TimeDesc(key='dnd_start', field='x.com.samsung.da.startTime',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.startTime')),
TimeDesc(key='dnd_end', field='x.com.samsung.da.endTime',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.endTime')),
SwitchDesc(
key="dnd",
field="x.com.samsung.da.value",
icon="mdi:sleep",
entity_category="config",
value_fn=lambda v: v == "true",
write_fn=lambda p, rep, href=None: (
["dnd", "vs", "0"],
{"x.com.samsung.da.value": "true" if p == "On" else "false"},
),
),
TimeDesc(
key="dnd_start",
field="x.com.samsung.da.startTime",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.startTime"),
),
TimeDesc(
key="dnd_end",
field="x.com.samsung.da.endTime",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.endTime"),
),
),
)
@@ -146,10 +190,10 @@ DND = Capability(
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
'/keepnormalstate/vs/0',
"/keepnormalstate/vs/0",
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
'/sensordatasinks/vs/0',
"/sensordatasinks/vs/0",
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,16 +13,15 @@ from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
_INACTIVE_OPERATION_STATES = {"Off", "Ready"}
def _option_value(options, prefix):
"""Return the value from the first ``<prefix>_<value>`` option."""
marker = prefix + '_'
marker = prefix + "_"
for option in options or ():
if isinstance(option, str) and option.startswith(marker):
return option[len(marker):]
return option[len(marker) :]
return None
@@ -32,7 +31,7 @@ def _operation_slots(options) -> tuple[int, ...]:
for option in options or ():
if not isinstance(option, str):
continue
match = re.match(r'^OperationState(\d+)_', option)
match = re.match(r"^OperationState(\d+)_", option)
if match:
slots.add(int(match.group(1)))
return tuple(sorted(slots))
@@ -40,10 +39,7 @@ def _operation_slots(options) -> tuple[int, ...]:
def _any_burner_active(options):
"""True when any advertised burner slot is not idle."""
states = [
_option_value(options, f'OperationState{slot}')
for slot in _operation_slots(options)
]
states = [_option_value(options, f"OperationState{slot}") for slot in _operation_slots(options)]
states = [state for state in states if state is not None]
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
@@ -56,15 +52,15 @@ def _int_or_none(value):
COOKTOP_POWER = Capability(
href='/power/vs/0',
poll_tier='hot',
href="/power/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='power_state',
field='x.com.samsung.da.power',
device_class='power',
icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on',
key="power_state",
field="x.com.samsung.da.power",
device_class="power",
icon="mdi:stove",
value_fn=lambda value: str(value).lower() == "on",
),
),
)
@@ -77,109 +73,107 @@ COOKTOP_POWER = Capability(
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
COOKTOP_MODE = Capability(
href='/mode/vs/0',
poll_tier='hot',
href="/mode/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='any_burner_active',
field='x.com.samsung.da.options',
device_class='running',
icon='mdi:fire',
key="any_burner_active",
field="x.com.samsung.da.options",
device_class="running",
icon="mdi:fire",
value_fn=_any_burner_active,
),
*(
SensorDesc(
key=f'burner_{slot}_state',
field='x.com.samsung.da.options',
translation_key='burner_state',
translation_placeholders={'number': str(slot)},
icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}'
),
key=f"burner_{slot}_state",
field="x.com.samsung.da.options",
translation_key="burner_state",
translation_placeholders={"number": str(slot)},
icon="mdi:gas-burner",
value_fn=lambda options, slot=slot: _option_value(options, f"OperationState{slot}"),
exists_fn=lambda rep, resources, slot=slot: (
is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
is_stub_rep(rep)
or _option_value(
rep.get("x.com.samsung.da.options"),
f"OperationState{slot}",
)
is not None
),
)
for slot in _SUPPORTED_OPERATION_SLOTS
),
SensorDesc(
key='main_timer_state',
field='x.com.samsung.da.options',
icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'),
key="main_timer_state",
field="x.com.samsung.da.options",
icon="mdi:timer-outline",
value_fn=lambda options: _option_value(options, "MainTimerState"),
),
SensorDesc(
key='main_timer_current',
field='x.com.samsung.da.options',
icon='mdi:timer-sand',
key="main_timer_current",
field="x.com.samsung.da.options",
icon="mdi:timer-sand",
enabled_default=False,
value_fn=lambda options: _int_or_none(
_option_value(options, 'MainTimerCurrent')
),
value_fn=lambda options: _int_or_none(_option_value(options, "MainTimerCurrent")),
),
),
)
COOKTOP_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
href="/connected/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='cloud_connected',
field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
),
),
)
PAIRED_HOOD_STATUS = Capability(
href='/bluetooth/hood/status/vs/0',
poll_tier='hot',
href="/bluetooth/hood/status/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='paired_hood_connected',
field='connectionState',
device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected',
key="paired_hood_connected",
field="connectionState",
device_class="connectivity",
value_fn=lambda value: str(value).lower() == "connected",
),
BinarySensorDesc(
key='paired_hood_power',
field='power',
device_class='running',
value_fn=lambda value: str(value).lower() == 'on',
key="paired_hood_power",
field="power",
device_class="running",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='paired_hood_fan_speed',
field='fanSpeed',
icon='mdi:fan',
key="paired_hood_fan_speed",
field="fanSpeed",
icon="mdi:fan",
value_fn=_int_or_none,
),
BinarySensorDesc(
key='paired_hood_light',
field='lampState',
device_class='light',
value_fn=lambda value: str(value).lower() == 'on',
key="paired_hood_light",
field="lampState",
device_class="light",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='paired_hood_model',
field='micomModelId',
icon='mdi:information-outline',
entity_category='diagnostic',
key="paired_hood_model",
field="micomModelId",
icon="mdi:information-outline",
entity_category="diagnostic",
enabled_default=False,
),
SensorDesc(
key='paired_hood_firmware',
field='firmwareVersion',
icon='mdi:chip',
entity_category='diagnostic',
key="paired_hood_firmware",
field="firmwareVersion",
icon="mdi:chip",
entity_category="diagnostic",
enabled_default=False,
),
),
@@ -7,79 +7,141 @@ humidity -- not temperature -- is this device's primary control, and there
is no climate composite: power, mode, and humidity are exposed as separate
entities rather than folded into one card.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none
def _first_mode(rep):
"""Representative scalar for the operating-mode select. `modes` is a
single-element list on every dump seen so far, mirroring
airconditioner._first_mode's handling of the same field shape."""
modes = rep.get('x.com.samsung.da.modes')
single-element list on every dump seen, mirroring
airconditioner._first_mode."""
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(key='operating_mode', rep_fn=_first_mode,
icon='mdi:tune-variant',
options_field='x.com.samsung.da.supportedModes',
write_fn=lambda p, rep, href=None: (
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
SelectDesc(
key="operating_mode",
rep_fn=_first_mode,
icon="mdi:tune-variant",
options_field="x.com.samsung.da.supportedModes",
write_fn=lambda p, rep, href=None: (
["mode", "vs", "0"],
{"x.com.samsung.da.modes": [p]},
),
),
),
)
# Target humidity is this device's primary control (the issue-#88 dump's
# equivalent of a thermostat setpoint). No min/max range field is present in
# any dump seen so far -- native_min/native_max are deliberately left unset
# so the number entity falls back to HA's own 0-100 default, the natural
# bound for a percentage field, rather than a bound guessed from one unit's
# spec sheet (see the adding-device-support skill's "never hard-code the one
# dump's values" section). Step comes live from the device's own `increment`
# field.
# Target humidity is this device's primary control (issue #88's equivalent
# of a thermostat setpoint). No min/max range field is present in any dump
# seen, so native_min/native_max are left unset, falling back to HA's own
# 0-100 default rather than a bound guessed from one unit's spec sheet.
# Step comes live from the device's own `increment` field.
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', unit='%', state_class='measurement',
value_fn=int_or_none),
NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity',
device_class='humidity', unit='%',
icon='mdi:water-percent',
entity_category='config',
value_fn=int_or_none,
step_fn=lambda rep: int_or_none(rep.get('increment')) or 1,
write_fn=lambda p, rep, href=None: (
['humidity', 'vs', '0'],
{'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})),
SensorDesc(
key="humidity",
field="x.com.samsung.da.humidity",
device_class="humidity",
unit="%",
state_class="measurement",
value_fn=int_or_none,
),
NumberDesc(
key="target_humidity",
field="x.com.samsung.da.desiredHumidity",
device_class="humidity",
unit="%",
icon="mdi:water-percent",
entity_category="config",
value_fn=int_or_none,
step_fn=lambda rep: int_or_none(rep.get("increment")) or 1,
write_fn=lambda p, rep, href=None: (
["humidity", "vs", "0"],
{"x.com.samsung.da.desiredHumidity": str(round(float(p)))},
),
),
),
)
# ---------------------------------------------------------------------------
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state
# or no documented write contract, following the same 'don't guess' rule as
# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in
# the global ignored.IGNORED since some of these hrefs collide with other
# families' schemas.
# ---------------------------------------------------------------------------
# Water-tank ambient light (issues #271/#231): on/off, color, and
# brightness are three independent controls on this one resource.
# `waterfullAlarmStatus` differs between the two dumps that reported this
# href, so it's a real live flag, but its exact meaning (tank full vs. the
# chime feature merely enabled) isn't confirmed, and /alarms/vs/0 already
# surfaces a live WaterTankFull condition -- exposed read-only as a plain
# diagnostic rather than guessed at as a binary_sensor.
WATERTANK_LIGHTING = Capability(
href="/watertank/lighting/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="watertank_light",
field="status",
icon="mdi:led-on",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="watertank_light_color",
field="colorOption",
icon="mdi:palette",
entity_category="config",
options_field="colorSupportedList",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"colorOption": p},
),
),
SelectDesc(
key="watertank_light_brightness",
field="mode",
icon="mdi:brightness-6",
entity_category="config",
options_field="modeSupportedList",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"mode": p},
),
),
SensorDesc(
key="watertank_full_alarm_status",
field="waterfullAlarmStatus",
entity_category="diagnostic",
),
),
)
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable
# state, following the same rule as airconditioner._AC_IGNORED (same
# DA_AC_ board family). Not in the global ignored.IGNORED since some hrefs
# collide with other families' schemas.
_DHM_IGNORED = [
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM)
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/keepnormalstate/vs/0', # internal keep-normal flag
'/personality/presence/vs/0', # presence-personalization plumbing (empty item value)
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/sensors/vs/0', # empty {} on this dump
'/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert)
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/keepnormalstate/vs/0", # internal keep-normal flag
"/personality/presence/vs/0", # presence-personalization plumbing (empty item value)
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/sensors/vs/0", # empty {} on this dump
"/welcome/humidity/vs/0", # welcome-mode plumbing (requestId/operatingStatus, inert)
# Only supportedModes ([Off, Sleep]) is present -- no live "current
# value" field on this dump to confirm the read/write contract, so per
# the 'don't guess' rule this is left unmodeled rather than assumed.
'/mode/convenient/vs/0',
"/mode/convenient/vs/0",
]
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
@@ -6,49 +6,86 @@ The /course/vs/0 cycle select and its options-array machinery are shared with
washer and dryer in laundry.py; only the dishwasher-specific options (storm
wash, auto release dry) are read locally here.
"""
from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import bool_option_switch, cycle_select
from .common import diagnosis_status
from .laundry import (
bool_option_switch,
cycle_select,
drum_clean_cycles_remaining,
)
# ---------------------------------------------------------------------------
# /dishwasher/vs/0 — cycle wash/dry settings
# ---------------------------------------------------------------------------
DISHWASHER_SETTINGS = Capability(
href='/dishwasher/vs/0',
href="/dishwasher/vs/0",
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:bacteria',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
icon='mdi:heat-wave',
options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.heatedDry': p})),
SwitchDesc(
key="sanitize",
field="x.com.samsung.da.sanitize",
icon="mdi:bacteria",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.sanitize": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="heated_dry",
field="x.com.samsung.da.heatedDry",
icon="mdi:heat-wave",
options_field="x.com.samsung.da.supportedHeatedDry",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.heatedDry": p},
),
),
),
)
# ---------------------------------------------------------------------------
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
# same options array (shared laundry.bool_option_switch, same options[]
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
# use). Course display names live in translations under
# entity.select.dishwasher_cycle (see laundry.cycle_select).
# ---------------------------------------------------------------------------
# /course/vs/0 -- cycle selection (shared laundry.cycle_select) plus the
# dishwasher-only StormWashZone / AutoDoorRelease toggles riding in the same
# options array (shared laundry.bool_option_switch). Course display names
# live in translations under entity.select.dishwasher_cycle.
#
# '83'/'86' were transposed in that catalog until issue #226: the original
# fixture's own live editCourseList puts them back to back, exactly the
# kind of adjacent pair a manual screenshot transcription slips on. The
# reporter's live confirmation (selecting 'Normal' ran the physical Express
# 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
#
# Drum Clean+ maintenance tracking reuses washer.py/dryer.py's (issues #9,
# #258) DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens riding on this
# same options[] array -- a live dump confirmed the dishwasher reports the
# identical trio (WashingTimes_18/DrumCleanProposal_20, plus a '|'-joined
# DrumCleanLog_ history matching the dryer's multi-entry shape), so
# laundry.drum_clean_cycles_remaining applies unchanged.
#
# laundry.drum_clean_last_cleaned (DrumCleanLog_'s own newest entry) is
# deliberately NOT wired up here (issue #398): a live dishwasher dump
# showed it moving every 30-90s on its own, including well after a cycle
# had already finished -- unlike the washer/dryer reports this reader was
# built from (issues #9, #258), it never settles on a value worth showing.
CYCLE_OPTIONS = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
cycle_select(translation_key="dishwasher_cycle", icon="mdi:dishwasher"),
bool_option_switch("storm_wash", "mdi:weather-lightning-rainy", "StormWashZone"),
bool_option_switch(
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:dishwasher-alert",
state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
rep_fn=drum_clean_cycles_remaining,
),
),
)
@@ -57,26 +94,38 @@ CYCLE_OPTIONS = Capability(
# ---------------------------------------------------------------------------
DIAGNOSIS = Capability(
href='/diagnosis/vs/0',
poll_tier='cold',
href="/diagnosis/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
icon='mdi:stethoscope',
entity_category='diagnostic'),
ButtonDesc(key='diagnosis_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
SensorDesc(
key="diagnosis_status",
field="x.com.samsung.da.diagnosisStart",
icon="mdi:stethoscope",
entity_category="diagnostic",
device_class="enum",
options=("ready",),
value_fn=diagnosis_status,
),
ButtonDesc(
key="diagnosis_start",
field="",
payload="Start",
icon="mdi:play-circle-outline",
entity_category="diagnostic",
write_fn=lambda p, rep, href=None: (
["diagnosis", "vs", "0"],
{"x.com.samsung.da.diagnosisStart": p},
),
),
),
)
OPERATION_ORIGIN = Capability(
href='/operation/origin/vs/0',
poll_tier='cold',
href="/operation/origin/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='operation_origin', field='origin',
icon='mdi:remote',
entity_category='diagnostic'),
SensorDesc(
key="operation_origin", field="origin", icon="mdi:remote", entity_category="diagnostic"
),
),
)
@@ -8,59 +8,112 @@ the /course/vs/0 cycle select -- lives in laundry.py.
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
"""
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select
from .common import diagnosis_status
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
DRYER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
href="/washer/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:water-percent'),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
icon='mdi:tumble-dryer'),
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
SensorDesc(
key="dryer_type",
field="x.com.samsung.da.dryerType",
icon="mdi:tumble-dryer",
device_class="enum",
# Only 'Electricity' confirmed across shipped fixtures (#366); an
# unrecognized value still passes through raw via sensor.py's
# options property rather than breaking the entity.
options=("electricity",),
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
icon="mdi:iron",
value_fn=lambda v: v == "On",
write_fn=_wrinkle_write,
),
),
)
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
# an RMW on the options array). Course display names live in translations
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code
# until named. Codes '01' Normal and '06' Time dry were confirmed on a
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
# appliance and reading back the raw code from the entity's state (issue
# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
# laundry.cycle_select. Course display names live in translations under
# entity.select.dryer_cycle (Table_03, DV5000-class). Codes '01' Normal and
# '06' Time dry were confirmed on a DVE50A8600V/A3 by selecting each cycle
# on the appliance and reading back the raw code (issue #80); '51' Eco
# Cotton, '53' AI Dry+, and '4e' Self Dry the same way on a DV90DG6845LHU5
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
#
# dryer_cycle_table_00 is a separate, older course-code family reported by
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
# selected each cycle on the appliance and read back the resulting raw
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
# A DV6800N -- same DA_WM_A51_20_COMMON board, also Table_00 -- confirmed
# 14 more courses the same way (issue #394); its /course/vs/0 supportedOptions
# only advertises a different subset of this same table (each model exposes
# whichever courses its hardware supports), not a conflicting code family --
# the one code both reporters confirmed, 'a5', means Bedding on both. Folded
# into the same catalog entry below rather than a new one.
#
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
# options[] array -- see laundry.drum_clean_cycles_remaining/
# drum_clean_last_cleaned. No separate heat-exchanger-clean tracking was
# found on either dump #258 supplied, so if the app surfaces that reminder,
# it isn't computed from anything this integration can read locally.
DRYER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0'),
cycle_select(
translation_key="dryer_cycle",
icon="mdi:tumble-dryer",
table_href="/st/dryercourse/vs/0",
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:tumble-dryer-alert",
state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
rep_fn=drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
rep_fn=drum_clean_last_cleaned,
),
),
)
DRYER_DIAGNOSIS = Capability(
href='/diagnosis/vs/0',
poll_tier='warm',
href="/diagnosis/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
entity_category='diagnostic'),
SensorDesc(
key="diagnosis",
field="x.com.samsung.da.diagnosisStart",
entity_category="diagnostic",
device_class="enum",
options=("ready",),
value_fn=diagnosis_status,
),
),
)
@@ -0,0 +1,210 @@
"""Capabilities for the Samsung EHS (Eco Heating System) air-to-water heat
pump family (TP1X_DA_AC_EHS-class, model TP1X_DA_AC_EHS_01001_0000).
An EHS unit runs two independently-controlled loops off one outdoor unit:
space heating/cooling ("zone1", through /mode/vs/0, /power/vs/0,
/temperatures/indoor/vs/0) and domestic hot water ("dhw", through
/mode/dhw/vs/0, /power/dhw/vs/0, /temperatures/dhw/vs/0). There's no shared
vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
zone1 has no HA platform with matching semantics (a leaving-water-
temperature setpoint, not a thermostat with HVAC modes), so it stays
switch/select/number/sensor -- same shape as dehumidifier.py's power/mode/
humidity split. dhw is a real HA water_heater.py entity (see DHW below),
following the same primary-resource-plus-sibling-reads pattern as
airconditioner.py's CLIMATE/climate.py.
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
from .common import normalize_temp_unit
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _first_mode(rep):
"""Representative scalar for a mode select -- `modes` is a single-element
list on every dump seen, mirroring airconditioner._first_mode."""
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _temp_unit(rep):
return normalize_temp_unit(rep.get("x.com.samsung.da.unit"), "°C")
def _bounds(rep, default_min, default_max):
"""The resource's own (minimum, maximum) pair, or the defaults. Both
ends together or neither -- a board reporting only one would otherwise
pair a real bound with an invented default, silently wrong."""
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
def _step(rep, default):
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
step = _num(rep.get("x.com.samsung.da.increment"))
return default if step is None else step
ZONE_POWER = Capability(
href="/power/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="zone_power",
field="x.com.samsung.da.power",
icon="mdi:radiator",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
),
),
),
)
ZONE_MODE = Capability(
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="zone_mode",
rep_fn=_first_mode,
icon="mdi:sun-snowflake-variant",
options_field="x.com.samsung.da.supportedModes",
write_fn=lambda p, rep, href=None: (
["mode", "vs", "0"],
{"x.com.samsung.da.modes": [p]},
),
),
),
)
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
# room setpoint, not a literal water temperature -- EHS zone control is
# leaving-water-temperature-based, same convention as the dhw loop below.
ZONE_TEMPERATURE = Capability(
href="/temperatures/indoor/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="zone_temperature",
field="x.com.samsung.da.current",
device_class="temperature",
unit_fn=_temp_unit,
state_class="measurement",
value_fn=_num,
),
NumberDesc(
key="zone_target_temperature",
field="x.com.samsung.da.desired",
device_class="temperature",
unit_fn=_temp_unit,
entity_category="config",
value_fn=_num,
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
step_fn=lambda rep: _step(rep, 0.5),
write_fn=lambda p, rep, href=None: (
["temperatures", "indoor", "vs", "0"],
{"x.com.samsung.da.desired": str(float(p))},
),
),
),
)
# Canonical dhw resource hrefs. water_heater.py binds HREF_DHW_MODE via DHW
# below and reads the sibling power/temperature hrefs off the coordinator
# snapshot -- same primary-plus-siblings shape as airconditioner.py's
# HREF_MODE/CLIMATE_CONSUMED_HREFS.
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
def _dhw_write(payload, rep, href=None):
"""Map a (kind, value) command from the water_heater platform to the
(path_segs, body) for that one sub-write -- same contract as
airconditioner._climate_write, across the dhw loop's three resources."""
kind, value = payload
if kind == "power":
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
if kind == "mode":
return (["mode", "dhw", "vs", "0"], {"x.com.samsung.da.modes": [value]})
if kind == "temperature":
return (["temperatures", "dhw", "vs", "0"], {"x.com.samsung.da.desired": str(float(value))})
return None
DHW = Capability(
href=HREF_DHW_MODE,
poll_tier="warm",
entities=(
WaterHeaterDesc(
key="water_heater", translation_key="dhw", rep_fn=_first_mode, write_fn=_dhw_write
),
),
)
# Power and temperature are read by the composite DHW entity above, not
# given their own entities -- coverage-only caps so discover() reports no
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS).
DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS]
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
# HA core's smartthings integration wires this same Samsung capability up
# to WaterHeaterEntityFeature.AWAY_MODE, so the divergence is worth
# stating: /option/outgoing/vs/0 is device-wide (one `away` flag covering
# zone1 too, with no dhw-scoped sibling href). Hanging it off the DHW card
# would present a device-wide setting as hot-water-only.
AWAY_MODE = Capability(
href="/option/outgoing/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="away_mode",
field="x.com.samsung.da.away",
icon="mdi:home-export-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "outgoing", "vs", "0"],
{"x.com.samsung.da.away": "On" if p == "On" else "Off"},
),
),
),
)
# EHS-scoped coverage: opaque vendor plumbing or resources with no
# confirmed write contract on this dump. Not in the global ignored.IGNORED
# since these are EHS-only shapes needing their own verification elsewhere.
_EHS_IGNORED = [
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/diagnosis/vs/0", # empty {} on this dump
"/ehscycle/vs/0", # opaque hex-encoded indoor/outdoor cycle log
"/ehsfsv/vs/0", # opaque hex-encoded factory setting values
"/option/dhwdisplay/vs/0", # front-panel DHW-display show/hide, cosmetic only
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/sac/installationinfo/vs/0", # static outdoor/indoor installation info, diagnostic only
"/actions/zone1/vs/0", # zone1 schedule/timer program -- unmodeled for now
"/actions/dhw/vs/0", # DHW schedule/timer program -- unmodeled for now
]
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
File diff suppressed because it is too large Load Diff
@@ -19,133 +19,109 @@ here would silently do nothing on that path. Enumerate each known href
instead; it's a short, stable list.
This list is maintainer-curated only; there is no per-installation
override. Grow it as real /device/0 dumps surface more universal noise —
do not add a href here on a guess. If a href's relevance is unclear, leave
it unbound so it surfaces as a gap for a human to look at.
override. Grow it as real /device/0 dumps surface more universal noise --
never on a guess. If a href's relevance is unclear, leave it unbound so it
surfaces as a gap for a human to look at.
"""
from ..capability import Capability
IGNORED: list[Capability] = [
# Device serial/model is read directly by the coordinator for HA device
# identity, not modeled as an entity capability.
Capability(href='/information/vs/0'),
Capability(href="/information/vs/0"),
# Bixby voice assistant: feature negotiation, account provisioning
# (Samsung account email, access tokens), terms-of-service state, and
# enable/disable status.
Capability(href='/voice/feature/vs/0'),
Capability(href='/voice/provisioning/vs/0'),
Capability(href='/bixby/vs/0'),
Capability(href='/bixby/status/vs/0'),
Capability(href='/bixbyuservalidate/vs/0'),
Capability(href='/bixbyterms/vs/0'),
Capability(href="/voice/feature/vs/0"),
Capability(href="/voice/provisioning/vs/0"),
Capability(href="/bixby/vs/0"),
Capability(href="/bixby/status/vs/0"),
Capability(href="/bixbyuservalidate/vs/0"),
Capability(href="/bixbyterms/vs/0"),
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
# types, no controllable or observable appliance state.
Capability(href='/wirelessinfo/vs/0'),
Capability(href='/connectionconfig/vs/0'),
Capability(href="/wirelessinfo/vs/0"),
Capability(href="/connectionconfig/vs/0"),
# Static or internal-protocol metadata, not entity-worthy.
Capability(href='/quickcontrol/info/vs/0'),
Capability(href='/realtimenotiforclient/vs/0'),
Capability(href='/file/information/vs/0'),
Capability(href='/configuration/vs/0'), # region/countryCode
Capability(href='/setting/vs/0'), # supported/selected UI language
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
Capability(href="/quickcontrol/info/vs/0"),
Capability(href="/realtimenotiforclient/vs/0"),
Capability(href="/file/information/vs/0"),
Capability(href="/configuration/vs/0"), # region/countryCode
Capability(href="/setting/vs/0"), # supported/selected UI language
Capability(href="/timezone/vs/0"), # redundant with HA's own timezone
Capability(href="/wm/setinfo/vs/0"), # model/manufacturing metadata
# Resource-monitoring poll-interval config (a bare minPeriod in
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
# plumbing, not appliance state.
Capability(href='/rm/control/vs/0'),
Capability(href="/rm/control/vs/0"),
# Demand Response Load Control — utility-company grid signals; requires
# cloud registration with a utility program we don't support locally.
Capability(href='/drlc/vs/0'),
Capability(href="/drlc/vs/0"),
# Redundant with capabilities already declared elsewhere.
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
Capability(href='/speakersound/vs/0'),
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
# editCourseList is read directly out of the resource snapshot by
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
# (options=_cycle_options) to build that device's actual supported
# course list, rather than exposing this href's raw byte string
# through its own entity.
Capability(href='/wm/editcourse/vs/0'),
Capability(href="/speakersound/vs/0"),
# No entities of its own -- editCourseList is read directly out of the
# resource snapshot by dishwasher.CYCLE_OPTIONS/washer.WASHER_COURSE's
# cycle selects to build the device's supported course list.
Capability(href="/wm/editcourse/vs/0"),
# Bixby audio feedback (chime + volume played when Bixby starts/stops
# listening) — only meaningful with Bixby enabled, which this
# integration has no local path to configure or use.
Capability(href='/sec/networkaudio/audio/vs/0'),
Capability(href="/sec/networkaudio/audio/vs/0"),
# Static Bespoke-product-line flag, not appliance state.
Capability(href='/bespoke/vs/0'),
Capability(href="/bespoke/vs/0"),
# Empty resource on every dump seen so far — nothing to expose.
Capability(href='/defrost/prediction/vs/0'),
Capability(href="/defrost/prediction/vs/0"),
# Seasonal defrost schedule (start/period/end per season). Automating
# this cleanly would need a multi-field schedule editor; the practical
# on/off control is fridge.DEFROST_DELAY.
Capability(href='/defrost/reservation/vs/0'),
Capability(href="/defrost/reservation/vs/0"),
# Warranty/service-plan enrollment status — every field reads "Unknown"
# on hardware not enrolled in a Samsung Care+ style program.
Capability(href='/dginformation/vs/0'),
Capability(href="/dginformation/vs/0"),
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
# expose yet. Revisit if a device surfaces it toggled on.
Capability(href='/mode/0'),
Capability(href="/mode/0"),
# Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone.
Capability(href='/runningmode/vs/0'),
Capability(href="/runningmode/vs/0"),
# Demand-response energy planner — same utility-program dependency as
# /drlc/vs/0 above; every dump seen so far is inert (plan: 'none').
Capability(href='/energy/planner/vs/0'),
Capability(href="/energy/planner/vs/0"),
# Temperature-unit display preference, redundant with HA's own units.
Capability(href='/wm/submode/vs/0'),
Capability(href="/wm/submode/vs/0"),
# Read-only re-encoding of the course already exposed by
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
# is literally "Table_02_Course_<same hex code>").
Capability(href='/st/washercourse/vs/0'),
# Dryer counterpart of the above: re-encoding of the course already
# exposed by dryer.DRYER_COURSE at /course/vs/0
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
Capability(href='/st/dryercourse/vs/0'),
# AirDresser counterpart of the above (issue #157): read only for its
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no
# entity of its own -- same "no entity, just the table id" role as
# /st/washercourse/vs/0 and /st/dryercourse/vs/0.
Capability(href='/st/airdressercourse/vs/0'),
# washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed
# "Table_02_Course_").
Capability(href="/st/washercourse/vs/0"),
# Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0.
Capability(href="/st/dryercourse/vs/0"),
# AirDresser counterpart (issue #157): read only for its courseTable id
# (air_dresser.AIR_DRESSER_COURSE's table_href), no entity of its own.
Capability(href="/st/airdressercourse/vs/0"),
# Empty on every washer dump seen so far.
Capability(href='/wm/welcomemsg/vs/0'),
Capability(href="/wm/welcomemsg/vs/0"),
# User-saved custom course slots (F1-FA). No controllable/observable
# state without a multi-slot editor; revisit if that becomes valuable.
Capability(href='/wm/personalcourse/vs/0'),
Capability(href="/wm/personalcourse/vs/0"),
# OCF-native energy resource is empty ({}) on washer hardware seen so
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source for this control.
Capability(href='/energy/consumption/0'),
# far -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source.
Capability(href="/energy/consumption/0"),
# Empty ({}) on every washer dump seen so far -- nothing to expose.
Capability(href='/cycleinterface/vs/0'),
Capability(href="/cycleinterface/vs/0"),
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
# dependency this integration doesn't support locally.
Capability(href='/drlc/0'),
# OCF-native duplicate of /operational/state/vs/0, which is already
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
# capability with start/pause/stop buttons and a delay-start control)
# used by washer, dishwasher, dryer, and oven. This generic href only
# carries read-only overlapping data (current job state, remaining
# time, progress percentage) with no write path -- not worth building a
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
# a textData.menu string -- every field empty/zero on the only dump
# seen so far (device idle, no guided-cooking program active). Same
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
# Revisit if a dump with an active recipe surfaces.
Capability(href='/cooktop/recipe/status/vs/0'),
Capability(href="/drlc/0"),
# OCF-native duplicate of /operational/state/vs/0, already modeled by
# operational.OPERATIONAL_STATE (richer, write-capable, used by washer,
# dishwasher, dryer, oven). This generic href is read-only overlapping
# data with no verified write contract worth building around.
Capability(href="/operational/state/0"),
# Cooktop guided-cooking/recipe status (issue #86): every field
# empty/zero on the only dump seen (device idle). Same "don't guess"
# treatment as the microwave family's /recipe/cook/vs/0.
Capability(href="/cooktop/recipe/status/vs/0"),
]
@@ -19,33 +19,37 @@ Resource hrefs seen across laundry dumps:
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
"""
from datetime import UTC, datetime
from datetime import time as dt_time
from ... import cloudcourse
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
from .common import hex_pairs, option_value
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
_LED_LEVELS = ("Low", "High")
_SOUND_MODES = ("voice", "tone", "mute")
def _led_brightness_write(p, rep, href=None):
if p not in _LED_LEVELS:
return None
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
return ["doorled", "light", "vs", "0"], {"setBrightness": p}
def _led_night_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
return ["doorled", "light", "vs", "0"], {"setNightLight": p}
def _parse_hm(v):
if not v:
return None
try:
h, m = v.split(':')
h, m = v.split(":")
return dt_time(int(h), int(m))
except Exception:
return None
@@ -54,66 +58,95 @@ def _parse_hm(v):
def _sound_mode_write(p, rep, href=None):
if p not in _SOUND_MODES:
return None
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
return ["settings", "sound", "mode", "vs", "0"], {"mode": p}
DOOR_LED = Capability(
href='/doorled/light/vs/0',
href="/doorled/light/vs/0",
entities=(
SelectDesc(key='led_brightness', field='setBrightness',
icon='mdi:brightness-6',
entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
icon='mdi:brightness-4',
entity_category='config',
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeEnd': f'{p.hour:02d}:{p.minute:02d}'})),
SelectDesc(
key="led_brightness",
field="setBrightness",
icon="mdi:brightness-6",
entity_category="config",
options=_LED_LEVELS,
write_fn=_led_brightness_write,
),
SwitchDesc(
key="led_night_light",
field="setNightLight",
icon="mdi:weather-night",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_led_night_write,
),
SelectDesc(
key="led_night_brightness",
field="setNightLightBrightness",
icon="mdi:brightness-4",
entity_category="config",
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightBrightness": p},
),
),
TimeDesc(
key="led_night_start",
field="setNightLightTimeStart",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeStart": f"{p.hour:02d}:{p.minute:02d}"},
),
),
TimeDesc(
key="led_night_end",
field="setNightLightTimeEnd",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeEnd": f"{p.hour:02d}:{p.minute:02d}"},
),
),
),
)
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
href="/settings/sound/mode/vs/0",
entities=(
SelectDesc(key='sound_mode', field='mode',
icon='mdi:volume-high',
entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write),
SelectDesc(
key="sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options=_SOUND_MODES,
write_fn=_sound_mode_write,
),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
href="/settings/sound/volume/vs/0",
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min=0,
native_max=15,
step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
@@ -125,120 +158,123 @@ SOUND_VOLUME = Capability(
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
href="/buzzersound/vs/0",
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
SelectDesc(
key="buzzer_sound",
field="setBuzzerSound",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedBuzzerSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setBuzzerSound": p}),
),
SelectDesc(
key="finish_sound",
field="setFinishSound",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=lambda rep, resources: "supportedFinishSound" in rep,
options_field="supportedFinishSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setFinishSound": p}),
),
),
)
# ---------------------------------------------------------------------------
# Cycle selection over /course/vs/0.
#
# The selected course and every other user-tunable option ride in the
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens.
# Confirmed on real hardware (issue #54): a write only needs to carry the one
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
# the device matches by prefix, evicts the stale token, and merges the result
# into the array itself. No read-modify-write of the whole array needed (see
# option_write). The set of *selectable* courses is not hardcoded -- it's read
# live from
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
# never show a course a given model doesn't have or hide one it does. Course
# codes are uppercase hex; display names live in translations under
# entity.select.<translation_key>.state.<id lowercased> so they can be
# localized -- every device-enum select in this integration works this way.
# washer.py's course comment has the byte-level evidence for why the options[]
# MostUsed_* entry is *not* a trustworthy second source.
# x.com.samsung.da.options array as `<Prefix>_<value>` tokens. Confirmed on
# real hardware (issue #54): a write only needs to carry the one changed
# token -- the device matches by prefix, evicts the stale token, and merges
# the result itself (see option_write). The set of selectable courses is
# read live from editCourseList on /wm/editcourse/vs/0 (cycle_options), not
# hardcoded. Course codes are uppercase hex; display names live in
# translations under entity.select.<translation_key>.state.<id lowercased>.
#
# Some boards populate /wm/editcourse/vs/0 without ever filling in
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
# the same list from /course/vs/0's own supportedOptions in that case; see
# _course_codes_from_supported_options for the byte-level evidence.
# editCourseList itself (issue #1) -- cycle_options() falls back to
# deriving the list from /course/vs/0's own supportedOptions in that case;
# see _course_codes_from_supported_options.
#
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
# expose the same /course/vs/0 options contract.
# ---------------------------------------------------------------------------
def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family
# boards expose the same /course/vs/0 options contract.
def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or '_' not in raw:
if not isinstance(raw, str) or "_" not in raw:
return []
return hex_pairs(raw.split('_', 1)[1])
return hex_pairs(raw.split("_", 1)[1])
def cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
rep = resources.get("/wm/editcourse/vs/0") or {}
codes = parse_edit_course_list(rep.get("x.com.samsung.da.editCourseList"))
if codes:
return codes
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
def option_value(options, prefix):
"""Find `<prefix>_<value>` in the options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
# DrumCleanProposal_<N> is the cycle interval between recommended cleans;
# WashingTimes_<N> is the count since the last one -- their difference is
# the "N cycles until due" figure the app shows (verified: 40 - 3 == 37,
# matching a live app screenshot).
def drum_clean_cycles_remaining(rep):
opts = rep.get("x.com.samsung.da.options") or []
proposal = option_value(opts, "DrumCleanProposal")
washed = option_value(opts, "WashingTimes")
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
# datetime (the last clean); a dryer (issue #258) instead reports a
# '|'-joined history of every past clean in increasing order. Splitting on
# '|' and taking the last element handles both shapes identically. No
# timezone accompanies either shape, so it's treated as UTC.
def drum_clean_last_cleaned(rep):
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
if not raw:
return None
last = raw.rsplit("|", 1)[-1]
try:
return datetime.fromisoformat(last).replace(tzinfo=UTC)
except ValueError:
return None
def _course_codes_from_supported_options(course_rep):
"""Fallback for an empty/missing editCourseList: derive the selectable
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
course list from /course/vs/0's own supportedOptions instead (issue #1:
some boards populate /wm/editcourse/vs/0 but never fill in
editCourseList itself).
supportedOptions is a 1-hex-nibble header followed by one fixed-width
record per selectable course, self-indexed rather than positional --
the first byte of every record is that course's own hex code, just in
the firmware's own internal order, not editCourseList's. Confirmed
against six independent real-world washer/dryer/dishwasher dumps: every
one divides evenly into `header + N * K bytes` with fully unique first
bytes across all N records, at the record's true byte width. (What the
rest of each record encodes is still unconfirmed -- this only uses the
course-code byte.)
the first byte of every record is that course's own hex code.
Confirmed against six independent real-world dumps: every one divides
evenly into `header + N * K bytes` with fully unique first bytes across
all N records, at the record's true byte width.
Two guards, deliberately conservative rather than guessing further: the
derived codes must (a) all be distinct -- a real course table, not
noise -- and (b) include whatever course is currently selected
(x.com.samsung.da.options' Course_<code> token), which must always be a
member of its own device's valid list. If no split satisfies both, this
returns [] rather than guess.
Two conservative guards rather than guessing further: the derived codes
must all be distinct, and must include whatever course is currently
selected. If no split satisfies both, this returns [].
Among splits that satisfy both, the *smallest* passing K wins, rather
than requiring a single unambiguous one -- more than one K reliably
does pass on real data (e.g. the shipped dishwasher fixture: true
K=7 passes, but so do 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 is enough on its own to satisfy the current-course guard
for several unrelated splits). Smallest-K-wins is a heuristic, not a
proof: it matches the confirmed answer on every one of six independent
real-world dumps this was checked against, but a coincidentally
unique, current-course-inclusive *smaller* K is not mathematically
impossible on some future device, and would be picked silently. Not
guarded against further here, since course tables are typically large
enough (double digits) that colliding by chance on both checks is
unlikely, and no device seen so far actually needs it.
Among splits that satisfy both, the smallest passing K wins -- more
than one K reliably passes on real data, and smallest-K-wins matches
the confirmed answer on all six dumps checked, though it's a heuristic
rather than a proof. Not guarded further: course tables are typically
large enough that colliding by chance on both checks is unlikely, and
no device seen so far needs it.
"""
raw = course_rep.get('x.com.samsung.da.supportedOptions')
raw = course_rep.get("x.com.samsung.da.supportedOptions")
hexstr = raw[0] if isinstance(raw, list) and raw else raw
if not isinstance(hexstr, str) or len(hexstr) < 3:
return []
@@ -246,14 +282,14 @@ def _course_codes_from_supported_options(course_rep):
if len(body) % 2:
return []
total_bytes = len(body) // 2
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
current = option_value(course_rep.get("x.com.samsung.da.options"), "Course")
for k in range(1, total_bytes + 1):
if total_bytes % k:
continue
n = total_bytes // k
if n < 2:
continue
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
firsts = [body[i * k * 2 : i * k * 2 + 2] for i in range(n)]
if len(set(firsts)) != n:
continue
if current is not None and current not in firsts:
@@ -262,121 +298,291 @@ def _course_codes_from_supported_options(course_rep):
return []
def option_tokens(*pairs):
"""[(prefix, value), ...] -> ['<prefix>_<value>', ...] -- the general
form of option_write, for the one write that needs two tokens to land in
the same options[] array together (see cycle_write's cloud branch)."""
return [f"{prefix}_{value}" for prefix, value in pairs]
def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above cycle_options for why this doesn't read/rewrite the whole array."""
return [f'{prefix}_{new_value}']
above for why this doesn't read/rewrite the whole array."""
return option_tokens((prefix, new_value))
# ---------------------------------------------------------------------------
# Cloud "Download" programs, folded into this same cycle select (issue #342).
#
# A device that has downloaded programs advertises them on the same
# /course/vs/0 options array; cloudcourse.py owns the token shapes, the
# learned store, and the reasoning for all of it. Everything below is just
# how that store reaches the select: the coordinator merges it onto this
# href's rep under cloudcourse.FIELD, so the option list, current value,
# label, and write path each read it from the rep or snapshot they already
# receive.
#
# They ride in the cycle select rather than a select of their own because
# that is what they are to a user -- on the appliance's own controls,
# "Download" occupies one position among the ordinary courses, and picking a
# downloaded program is picking a cycle. Their raw values are namespaced
# ('cloud:<slot>') so they can never be confused with, or collide with, a
# two-hex-char local course code.
#
# Bound by whichever families declare it. Washers are where this was worked
# out, but a DW5000C dishwasher advertises the same token (see
# cloudcourse.py), so nothing below is washer-specific.
#
# Confirmed on hardware before any of this was written (issue #342): writing
# the program token alone, while some other course is selected, is silently
# ignored -- the course token has to switch to Download in the *same* write.
# Hence the two-token write, the only one in this module.
def _cloud_state(rep):
return rep.get(cloudcourse.FIELD) or {}
def cloud_options(rep):
"""Namespaced raw values for every named, learned cloud program."""
return [
f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {}))
]
def cloud_label(value, resources):
"""The user's own name for a 'cloud:<slot>' value.
Cloud program names are user-supplied, never translated: the appliance
reports only an opaque slot id, and inventing an English label for one
is exactly what this module refuses to do for unrecognized local course
codes (see washer_cycle_fallback).
"""
if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX):
return None
slot = value[len(cloudcourse.RAW_PREFIX) :]
rep = resources.get(cloudcourse.COURSE_HREF) or {}
program = _cloud_state(rep).get("programs", {}).get(slot)
return program["name"] if program else None
def cloud_current(rep):
"""'cloud:<slot>' when a named cloud program is the live selection.
Gated on the course actually being this device's confirmed Download
course: tokens in this array are replaced by prefix and never evicted, so
a one-time program token outlives the run it belonged to and would
otherwise report "Jeans" while an ordinary cotton cycle runs.
"""
state = _cloud_state(rep)
download = state.get("download_course")
options = rep.get("x.com.samsung.da.options")
if not download or option_value(options, "Course") != download:
return None
blob = option_value(options, cloudcourse.ONESHOT_PREFIX)
slot = cloudcourse.slot_of(blob)
if slot is None:
# No one-time override loaded: the appliance falls back to whatever
# the persisted default holds (confirmed with the issue #342
# reporter -- leaving Download and returning to it re-selects the
# saved program, not the last one-time one).
slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX))
if slot is None or slot not in state.get("programs", {}):
return None
return f"{cloudcourse.RAW_PREFIX}{slot}"
def cycle_write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Course', p),
if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX):
return _cloud_cycle_write(p, rep)
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("Course", p),
}
def _cloud_cycle_write(p, rep):
state = _cloud_state(rep)
download = state.get("download_course")
program = state.get("programs", {}).get(p[len(cloudcourse.RAW_PREFIX) :])
if not download or program is None:
return None
# Order matches what the appliance was confirmed to accept.
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_tokens(
("Course", download), (cloudcourse.ONESHOT_PREFIX, program["blob"])
),
}
def personal_course_labels(resources, href="/wm/personalcourse/vs/0"):
"""Return device-provided personal course names keyed by course code.
Populated entries use a small TLV payload. The leading field is
``01 <UTF-8-byte-length> <name>``; later fields contain a description and
settings and are intentionally left uninterpreted. Empty slots are
encoded as ``<code>_00``. Malformed or undecodable entries are ignored so
opaque device data can never become a misleading label.
"""
rep = resources.get(href) or {}
labels = {}
for entry in rep.get("x.com.samsung.da.courses") or []:
if not isinstance(entry, str) or "_" not in entry:
continue
code, encoded = entry.split("_", 1)
try:
payload = bytes.fromhex(encoded)
except ValueError:
continue
if len(payload) < 3 or payload[0] != 0x01:
continue
name_length = payload[1]
if name_length == 0 or len(payload) < 2 + name_length:
continue
try:
name = payload[2 : 2 + name_length].decode("utf-8")
except UnicodeDecodeError:
continue
if name.strip() and name.isprintable():
labels[code.upper()] = name
return labels
def washer_cycle_fallback(value, resources):
"""Label a personal washer course from its device-provided name.
No fallback for an unrecognized standard code -- an invented English
label would defeat translation (PR #251 review); the raw code displays
instead, same as before this function existed.
"""
if not isinstance(value, str):
return None
return personal_course_labels(resources).get(value.upper())
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get('x.com.samsung.da.st.courseTable')
return rep.get("x.com.samsung.da.st.courseTable")
def cycle_select(*, translation_key, icon, table_href=None):
def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The option list, current value, and write path are all shared across
washer/dryer/dishwasher; only the translation is family- (and, for
washer/dryer, board-) specific.
The option list, current value, and write path are shared across
washer/dryer/dishwasher; only the translation is family/board-specific.
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
suffixes translation_key with the device's own course-table id, read
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
'washer_cycle_table_02'). An absent or unrecognized table id gets the
name-only ``cycle`` translation key while the raw course code remains
visible and writable.
table_href (washer/dryer only) suffixes translation_key with the
device's own course-table id, read from /st/washercourse/vs/0 or
/st/dryercourse/vs/0's courseTable (e.g. 'washer_cycle' + 'Table_02' ->
'washer_cycle_table_02'). This matters because course codes are NOT
guaranteed consistent across board generations sharing the same
/course/vs/0 contract: washer_cycle_table_02 was confirmed against
Table_02 devices, but FlexWash's older board reports Table_00, where
the same hex code could mean a different course. An absent or
unrecognized table id falls back to the name-only ``cycle`` key
instead of borrowing a label from another board generation --
translating a new table is a translations-only change.
This matters because course codes are NOT guaranteed consistent across
board generations sharing the same /course/vs/0 contract: every code in
washer_cycle_table_02 was confirmed against Table_02-reporting devices
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
Table_00 instead, so the same hex code could mean a different course
there for all we've verified. So a table-specific key is used only when
the shipped catalog actually has one; any other table (Table_00 today,
whatever ships next) falls back to the name-only ``cycle`` key, which
shows the raw course code rather than a label borrowed from another
board generation. Translating a new table is therefore a
translations-only change -- add the ``<family>_cycle_<table>`` entry and
this resolver picks it up.
The raw course code remains writable regardless; its display uses
display_fn when supplied, otherwise it remains raw. display_fn is an
optional family-specific fallback for untranslated raw values --
select.py applies it after catalog lookup to both state and options.
Left at its default for dishwasher, which has no equivalent table-id
resource in any dump seen and no evidence its course codes vary by
table the way washer/dryer's do -- there's nothing to build a
table-specific key from.
resource and no evidence its codes vary by table the way washer/
dryer's do.
Any cloud "Download" programs the user has discovered and named join the
same option list, after the local courses -- see the cloud section above.
A device with none (or one whose owner hasn't named any yet) gets exactly
the list it got before they existed.
"""
key = translation_key
if table_href is not None:
def key(resources):
table = _table_id(resources, table_href)
if not isinstance(table, str) or not table:
return 'cycle'
candidate = f'{translation_key}_{table.lower()}'
return candidate if has_entity_translation('select', candidate) else 'cycle'
return "cycle"
candidate = f"{translation_key}_{table.lower()}"
return candidate if has_entity_translation("select", candidate) else "cycle"
def options(resources):
rep = resources.get(cloudcourse.COURSE_HREF) or {}
# Local courses first: a user-supplied cloud name that happens to
# match a translated course name resolves back to the real local
# course on write, which is the safer of the two. The options flow
# rejects such a name outright, so this is a backstop, not the fix.
return [*cycle_options(resources), *cloud_options(rep)]
def current(rep):
return cloud_current(rep) or option_value(rep.get("x.com.samsung.da.options"), "Course")
def label(value, resources):
cloud = cloud_label(value, resources)
if cloud is not None:
return cloud
return display_fn(value, resources) if display_fn is not None else None
return SelectDesc(
key='cycle', icon=icon, translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
key="cycle",
icon=icon,
translation_key=key,
options=options,
exists_fn=lambda rep, resources: bool(options(resources)),
rep_fn=current,
display_fn=label,
write_fn=cycle_write,
)
# ---------------------------------------------------------------------------
# Plain boolean toggles over /course/vs/0's options[] array: a
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
# both families ride this exact contract, just with different prefixes and
# different presence/validation needs on top.
# ---------------------------------------------------------------------------
# '<prefix>_On'/'<prefix>_Off' token, merged the same way as the 'Course'
# token above. Shared by washer (bubble soak, pre-wash, intensive -- issue
# #22) and dishwasher (storm wash, auto release dry), just with different
# prefixes and presence/validation needs on top.
def bool_option_write(prefix):
def write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, p),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, p),
}
return write
def bool_option_value(prefix):
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
return lambda rep: option_value(rep.get("x.com.samsung.da.options"), prefix) == "On"
def bool_option_exists(prefix):
return lambda rep, resources: option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
return lambda rep, resources: (
option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
)
def bool_option_switch(key, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
def bool_option_switch(
key, icon, prefix, *, entity_category=None, gate_on_presence=False, validate_fn=None
):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report
the token at all (washer's bubble soak/pre-wash/intensive); leave False
for a toggle every device in the family reports (dishwasher's storm
wash). validate_fn is passed straight through to SwitchDesc for callers
that need to reject a write against live device state (e.g. washer's
per-course availability check) -- this factory has no opinion on it and
building one, if needed, is the caller's job.
the token (washer's bubble soak/pre-wash/intensive); leave False for a
toggle every device in the family reports (dishwasher's storm wash).
validate_fn passes straight through to SwitchDesc for callers that need
to reject a write against live state -- this factory has no opinion on
it.
"""
return SwitchDesc(
key=key, icon=icon, entity_category=entity_category,
key=key,
icon=icon,
entity_category=entity_category,
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
rep_fn=bool_option_value(prefix),
write_fn=bool_option_write(prefix),
@@ -384,21 +590,20 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
)
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
# ever carried that field, so the dryer sensor was always blank -- fixed by
# sharing this one reader.
# ---------------------------------------------------------------------------
# (e.g. door open, no water), x.com.samsung.da.currentStatus on every dump
# that populates it. An earlier dryer descriptor read
# x.com.samsung.da.jobBeginingStatus instead, which no dump ever carried,
# so the dryer sensor was always blank -- fixed by sharing this one reader.
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
href="/wm/jobbeginingstatus/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
entity_category='diagnostic'),
SensorDesc(
key="job_beginning_status",
field="x.com.samsung.da.currentStatus",
entity_category="diagnostic",
),
),
)
@@ -8,34 +8,27 @@ oven.py in by_type/microwave.py rather than duplicated. What's genuinely
different from an oven, and defined fresh here:
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
KeepWarm never appear on an oven's /mode/vs/0, and this family spells
some shared-sounding modes differently than oven.py's own constants
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
mode list, not oven.OVEN_MODE.
KeepWarm never appear on an oven's /mode/vs/0, and some shared-sounding
modes are spelled differently (e.g. 'AirFryer', not oven.py's
'AirFry') -- a distinct SelectDesc and mode list, not oven.OVEN_MODE.
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
30-270 C range (verified against a different, bake-oven-class board).
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
on the MicroWave mode's powerListData) that plain ovens don't report --
exposed as its own sensor.
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
the combi dump (issue #121), so it's gated with exists_fn rather than
assumed universal like oven.py's lamp switch. Issue #137's dump only
ever showed 'Off', so 'On' was a guess at the paired value; issue #152's
ME7500D dump is the first to show a real non-Off value, and it's 'High'
(a brightness level, not literally 'On') -- the switch now treats any
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
(the two confirmed tokens) rather than the never-confirmed 'On'.
(issue #121) reports 40-200°C / step 5, not oven.py's 30-270°C range.
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W)
that plain ovens don't report -- exposed as its own sensor.
* Lamp: this family's option-array token is bare 'Lamp' (issue #137), not
oven.py's 'UpperLamp', and genuinely absent on the combi dump (issue
#121), so it's exists_fn-gated rather than assumed universal. 'On' has
never been observed as a value; the only confirmed non-Off token is
'High' (issue #152) -- the switch treats any non-Off/non-None value as
"on" for reads and writes back 'High'/'Off'.
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
option-array tokens (issue #181), both with On and Off observed live
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
option-array tokens (issue #181), gated with exists_fn like Lamp since
the MW7300B combi dump has neither.
Note: cooking-mode writes are unproven here, same caveat as oven.py's
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
is also the test.
Cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE
-- exposed as a SelectDesc for fidelity, first real-world write is the test.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, normalize_temp_unit
@@ -45,52 +38,44 @@ from .laundry import option_value, option_write
# Constants
# ---------------------------------------------------------------------------
# Union of every mode seen across the two known dumps: issue #121's combi
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
# every mode below on one device -- the select surfaces whatever a given
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
# never sends just never gets picked.
# Union of every mode seen across the two known dumps (issues #121, #137).
# No dump has shown every mode below on one device -- the select surfaces
# whatever a given board's own supportedModes reports; an entry here a
# device never sends just never gets picked.
_MICROWAVE_MODES = (
'NoOperation',
'MicroWave',
'MicroWaveGrill',
'MicroWaveConvection',
'Convection',
'AirFryer',
'Grill',
'Autocook',
'AutocookCustom',
'Deodorization',
'KeepWarm',
"NoOperation",
"MicroWave",
"MicroWaveGrill",
"MicroWaveConvection",
"Convection",
"AirFryer",
"Grill",
"Autocook",
"AutocookCustom",
"Deodorization",
"KeepWarm",
)
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
# against issue #44's range dump), there's nothing to verify a microwave's
# Fahrenheit bounds against, so this module only exposes the setpoint
# control when the live unit is Celsius (see _microwave_temp_unit below).
# Convection/MicroWaveConvection modeSpec on issue #121's dump: 40-200°C,
# step 5. No Fahrenheit dump exists for this family, unlike oven.py's own
# independently-verified F bounds, so this module only exposes the
# setpoint control when the live unit is Celsius (see _microwave_temp_unit).
SETPOINT_MIN_C = 40
SETPOINT_MAX_C = 200
SETPOINT_STEP_C = 5
def _microwave_temp_unit(rep):
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
carries a per-item x.com.samsung.da.unit field. Both known dumps for
this family report 'Celsius'; kept live rather than hardcoded per the
fridge/oven convention (issue #7)."""
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
"""Same shape as oven.py's _oven_temp_unit. Both known dumps report
'Celsius'; kept live rather than hardcoded (issue #7)."""
items = rep.get("x.com.samsung.da.items") or []
unit = items[0].get("x.com.samsung.da.unit") if items else None
return normalize_temp_unit(unit, default="°C")
def _setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array -- unproven for this
family (no live write confirmed against a real unit), same "exposed for
fidelity" caveat as the mode select."""
family, same "exposed for fidelity" caveat as the mode select."""
try:
temp = float(p)
except (TypeError, ValueError):
@@ -98,12 +83,12 @@ def _setpoint_write(p, rep, href=None):
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
return None
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
items[0]["x.com.samsung.da.desired"] = str(temp_i)
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
def _power_level_watts(v):
@@ -111,83 +96,81 @@ def _power_level_watts(v):
if v is None:
return None
s = str(v).strip()
if s.upper().endswith('W'):
if s.upper().endswith("W"):
s = s[:-1]
return int_or_none(s)
def _cooking_mode_options(resources):
"""Live mode list from the device's own /mode/vs/0 supportedModes when
it reports one (both known dumps do); the union-of-all-dumps
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
pattern as oven._oven_mode_options -- a fixed list here would offer
users modes their own unit doesn't have (issue #152's ME7500D reports
only 4 of _MICROWAVE_MODES' 11)."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
"""Live mode list from the device's own supportedModes when reported
(both known dumps do); the union-of-all-dumps _MICROWAVE_MODES guess
otherwise. Same live-first, static-fallback pattern as
oven._oven_mode_options -- a fixed list would offer modes a unit
doesn't have (issue #152 reports only 4 of _MICROWAVE_MODES' 11)."""
rep = resources.get("/mode/vs/0") or {}
live = rep.get("x.com.samsung.da.supportedModes")
return list(live) if live else list(_MICROWAVE_MODES)
def _mode_write(p, rep, href=None):
valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES
valid = rep.get("x.com.samsung.da.supportedModes") or _MICROWAVE_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Sound', p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Sound", p),
}
def _lamp_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
return option_value(rep.get("x.com.samsung.da.options"), "Lamp") is not None
def _filter_remind_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'FilterRemind') is not None
return option_value(rep.get("x.com.samsung.da.options"), "FilterRemind") is not None
def _remind_beep_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'RemindBeep') is not None
return option_value(rep.get("x.com.samsung.da.options"), "RemindBeep") is not None
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
# (issues #137/#152) -- 'On' has never been observed and the device
# likely doesn't recognize it (see module docstring).
token = 'High' if p == 'On' else 'Off'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Lamp', token),
# 'High'/'Off' are the two confirmed tokens (see module docstring);
# 'On' has never been observed and likely isn't recognized.
token = "High" if p == "On" else "Off"
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Lamp", token),
}
def _filter_remind_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('FilterRemind', p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("FilterRemind", p),
}
def _remind_beep_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('RemindBeep', p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("RemindBeep", p),
}
@@ -196,71 +179,100 @@ def _remind_beep_write(p, rep, href=None):
# ---------------------------------------------------------------------------
MICROWAVE_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
href="/oven/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
unit='W', value_fn=_power_level_watts),
SensorDesc(key="cavity_state", field="x.com.samsung.da.state"),
SensorDesc(
key="power_level",
field="x.com.samsung.da.powerLevel",
unit="W",
value_fn=_power_level_watts,
),
),
)
MICROWAVE_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
href="/temperatures/vs/0",
poll_tier="hot",
entities=(
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_microwave_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_microwave_temp_unit,
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.current') if items else None))),
NumberDesc(
key="setpoint",
field="x.com.samsung.da.items",
device_class="temperature",
unit_fn=_microwave_temp_unit,
native_min=float(SETPOINT_MIN_C),
native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C),
icon="mdi:thermometer-chevron-up",
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == "°C",
value_fn=lambda items: int_or_none(
items[0].get("x.com.samsung.da.desired") if items else None
),
write_fn=_setpoint_write,
),
SensorDesc(
key="current_temp_c",
field="x.com.samsung.da.items",
device_class="temperature",
state_class="measurement",
unit_fn=_microwave_temp_unit,
value_fn=lambda items: int_or_none(
items[0].get("x.com.samsung.da.current") if items else None
),
),
),
)
MICROWAVE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_cooking_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
exists_fn=_lamp_exists,
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
write_fn=_lamp_write),
# issue #181: Filter Reminder / End Signal Reminder toggles,
# confirmed present (both On and Off observed across dumps -- see
# issue #152's ME7500D fixtures) but only on boards that carry the
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
# MW7300B combi dump has neither) rather than assumed universal.
SwitchDesc(key='filter_remind', field='x.com.samsung.da.options',
icon='mdi:air-filter',
entity_category='config',
exists_fn=_filter_remind_exists,
value_fn=lambda opts: option_value(opts, 'FilterRemind') == 'On',
write_fn=_filter_remind_write),
SwitchDesc(key='remind_beep', field='x.com.samsung.da.options',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=_remind_beep_exists,
value_fn=lambda opts: option_value(opts, 'RemindBeep') == 'On',
write_fn=_remind_beep_write),
SelectDesc(
key="cooking_mode",
field="x.com.samsung.da.modes",
icon="mdi:tune",
options=_cooking_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write,
),
SwitchDesc(
key="sound",
field="x.com.samsung.da.options",
icon="mdi:volume-high",
entity_category="config",
value_fn=lambda opts: option_value(opts, "Sound") == "On",
write_fn=_sound_write,
),
SwitchDesc(
key="lamp",
field="x.com.samsung.da.options",
icon="mdi:track-light",
exists_fn=_lamp_exists,
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
write_fn=_lamp_write,
),
# issue #181: Filter Reminder / End Signal Reminder toggles, only on
# boards carrying the FilterRemind_*/RemindBeep_* tokens; gated off
# elsewhere (the MW7300B combi dump has neither).
SwitchDesc(
key="filter_remind",
field="x.com.samsung.da.options",
icon="mdi:air-filter",
entity_category="config",
exists_fn=_filter_remind_exists,
value_fn=lambda opts: option_value(opts, "FilterRemind") == "On",
write_fn=_filter_remind_write,
),
SwitchDesc(
key="remind_beep",
field="x.com.samsung.da.options",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=_remind_beep_exists,
value_fn=lambda opts: option_value(opts, "RemindBeep") == "On",
write_fn=_remind_beep_write,
),
),
)
@@ -2,15 +2,22 @@
Shared by dryer/dishwasher/oven/washer families.
"""
import math
from datetime import datetime, timedelta, timezone
import math
from datetime import UTC, datetime, timedelta
from ...catalog import translated_states
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
}
@@ -19,7 +26,7 @@ def _to_ocf(v):
def _progress(v):
return 'Idle' if v in (None, 'None') else v
return "idle" if v in (None, "None") else str(v).lower()
def _int(v):
@@ -29,12 +36,48 @@ def _int(v):
return None
def _state_is_active(rep):
return _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)
return _state_is_active(rep) and rep.get("x.com.samsung.da.progress") != "Finish"
def _just_finished(rep):
"""progress/progress_percentage's sticky_fn (issue #345, see sensor.py's
_apply_sticky): arms their grace window the moment `progress` reads
'Finish'.
Deliberately not also requiring `state == 'active'` in the same rep,
unlike _is_active above: #345 reports a washer whose `state` can
already read idle by the time `progress` is observed at 'Finish' --
the same-family dryer's firmware apparently keeps `state` at 'active'
longer, per the report -- so requiring both together risked the arm
condition never actually firing on the one device this fixes.
_apply_sticky's edge-triggering (only a fresh False->True transition
(re)arms) is what keeps a `progress` stuck at 'Finish' indefinitely
(the same class of quirk `_completion_minutes` below already works
around) from holding this open forever instead."""
return rep.get("x.com.samsung.da.progress") == "Finish"
def _new_cycle_running(rep):
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
early once a new cycle is genuinely running.
Gated on `state == 'active'`, unlike _just_finished's arm condition
above: issue #358's dryer resets `progress` to its course's first
stage ('Drying') in the same moment `state` goes idle, ~4s before
settling to 'None' -- confirmed by the reporter's machine_state
history, which flips to idle on the exact second progress reads
'Drying', in both captured cycles. A bypass keyed on the progress
code alone read that as a new cycle and republished it. Releasing
late costs nothing -- an unreleased hold still expires on its own --
so this side takes the stronger signal."""
v = rep.get("x.com.samsung.da.progress")
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
def _remaining_seconds(raw):
@@ -42,7 +85,7 @@ def _remaining_seconds(raw):
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(':')]
parts = [int(p) for p in raw.split(":")]
except (ValueError, TypeError):
return None
@@ -69,7 +112,7 @@ def _delay_hours(v):
def _format_delay(hours):
total_minutes = round(max(float(hours), 0) * 60)
h, m = divmod(total_minutes, 60)
return f'{h}:{m:02d}:00'
return f"{h:02d}:{m:02d}:00"
def _delay_field(rep):
@@ -79,118 +122,163 @@ def _delay_field(rep):
device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior)."""
return (
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
"x.com.samsung.da.delayEndTime"
if "x.com.samsung.da.delayEndTime" in rep
else "x.com.samsung.da.delayStartTime"
)
def _finish_time(rep):
if not _is_active(rep):
return None
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
total_s = _remaining_seconds(rep.get("x.com.samsung.da.remainingTime"))
if not total_s:
return None
# Round to whole minutes -- remainingTime itself only has minute
# resolution, but datetime.now() always carries fresh seconds/
# microseconds, so an unrounded result changes on nearly every poll
# even when the device-reported remaining time hasn't. That floods
# the recorder history/logbook with values that look identical once
# the UI rounds them down for display.
finish = datetime.now(timezone.utc) + timedelta(seconds=total_s)
# resolution, but datetime.now()'s fresh seconds/microseconds would
# otherwise change the result on nearly every poll, flooding the
# recorder with values that look identical once the UI rounds them.
finish = datetime.now(UTC) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0)
def _completion_minutes(rep):
"""Parse remaining time into minutes directly from device payload."""
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
raw = rep.get("x.com.samsung.da.remainingTime") or rep.get("remainingTime")
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get('x.com.samsung.da.progress') == 'Finish':
if rep.get("x.com.samsung.da.progress") == "Finish":
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW.
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p}))
STOP_BUTTON = ButtonDesc(
key="stop",
field="",
payload="Ready",
icon="mdi:stop",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
)
OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
href="/operational/state/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
device_class='enum',
options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
# so we also gate on progress to avoid a stuck 'Running' indication.
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
# start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc(key='cycle_active', device_class='running',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)),
SensorDesc(key='progress', icon='mdi:progress-wrench',
rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
unit='%', state_class='measurement',
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
hysteresis=True, rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
device_class='duration', state_class='measurement',
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h',
native_min=0, native_max=24, step=1,
rep_fn=lambda rep: _delay_hours(
rep.get('x.com.samsung.da.delayStartTime')
or rep.get('x.com.samsung.da.delayEndTime')),
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
# cycle_active is a bool derived from machine_state, gated on
# progress too since firmware keeps state='Run' after progress
# reaches 'Finish' (a stuck 'Running' indication otherwise). Named
# 'Running' in the catalog, not 'Cycle active' -- this href is
# shared with oven, and 'cycle' is laundry-specific vocabulary.
BinarySensorDesc(
key="cycle_active",
device_class="running",
rep_fn=_is_active,
),
# sticky_* (issue #345): once progress reads 'Finish', keep
# showing Finish/100 for a grace window even after machine_state
# reverts, rather than falling to Idle/0 the instant it does --
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
# stays the only definition of a live value -- the hold decides
# only *whether* to freeze. A second, ungated one here is what
# let issue #358's post-Finish tail reach the entity.
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
device_class="enum",
options=tuple(sorted(translated_states("sensor", "progress"))),
rep_fn=lambda rep: (
"idle"
if not _state_is_active(rep)
else _progress(rep.get("x.com.samsung.da.progress"))
),
sticky_fn=_just_finished,
# The catalog key, not the device's 'Finish': rep_fn is normalized
# now, and a held value outside `options` is what HA rejects.
sticky_value_fn=lambda rep: "finish",
sticky_bypass_fn=_new_cycle_running,
),
SensorDesc(
key="progress_percentage",
unit="%",
state_class="measurement",
rep_fn=lambda rep: (
0
if not _state_is_active(rep)
else _int(rep.get("x.com.samsung.da.progressPercentage"))
),
sticky_fn=_just_finished,
sticky_value_fn=lambda rep: 100,
sticky_bypass_fn=_new_cycle_running,
),
# Only show finish time while actively running -- firmware leaves a
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
SensorDesc(
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
),
SensorDesc(
key="completion_minutes",
icon="mdi:clock-outline",
unit="min",
device_class="duration",
state_class="measurement",
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes,
),
NumberDesc(
key="delay_start_hours",
icon="mdi:timer-plus-outline",
device_class="duration",
unit="h",
native_min=0,
native_max=24,
step=1,
rep_fn=lambda rep: _delay_hours(
rep.get("x.com.samsung.da.delayStartTime")
or rep.get("x.com.samsung.da.delayEndTime")
),
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{_delay_field(rep): _format_delay(p)},
),
),
ButtonDesc(
key="start",
field="",
payload="Run",
icon="mdi:play",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
ButtonDesc(
key="pause",
field="",
payload="Pause",
icon="mdi:pause",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
STOP_BUTTON,
),
)
@@ -1,33 +1,31 @@
"""Capabilities for the oven family (Samsung NV7000BS-class).
Resources verified against the live device via DTLS-CoAP.
See `local-tools/comparisons/oven-tree.md` for the full field reference.
Resources verified against the live device via DTLS-CoAP. See
`local-tools/comparisons/oven-tree.md` for the full field reference.
Write surfaces this module exposes:
Proven write: lamp, via /mode/vs/0 options RMW, works even with Remote
Control off. Unproven (first HA use is also the test): sound/fastPreheat/
naturalSteam (same RMW pattern), setpoint via /temperatures/vs/0 items RMW,
cook time via /operational/state/vs/0's operationTime/remainingTime, mode
select via /mode/vs/0.modes (mid-cook acceptance unknown), stop via
state='Ready'.
proven:
* Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
— works even with Remote Control off.
unproven (first HA use is also the test):
* Sound, FastPreheat, NaturalSteam — same RMW pattern as lamp.
* Setpoint via /temperatures/vs/0 items RMW.
* Cook time via /operational/state/vs/0 operationTime/remainingTime.
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown.
* Stop via /operational/state/vs/0 state='Ready'.
Note: Cycle start is not implemented. Reverse-engineering shows local-OCF
cycle start is not reproducible on this firmware (see project_oven_remote
_start_open.md). Mode writes are also unreliable — the oven rolls them back
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
but is effectively read-only in practice.
Cycle start is not implemented: local-OCF cycle start isn't reproducible on
this firmware. Mode writes are also unreliable -- the oven rolls them back
once a cycle is active, so OVEN_MODE's SelectDesc is effectively read-only
in practice.
"""
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import normalize_temp_unit
from .operational import STOP_BUTTON
@@ -40,47 +38,39 @@ SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
# constant set rather than converted from the Celsius bounds above, which
# are themselves unverified (no live dump; see module docstring).
# Verified against issue #44's range dump: Bake mode's modeSpec reports
# tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept separate rather than
# converted from the Celsius bounds above, which are themselves unverified.
SETPOINT_MIN_F = 175
SETPOINT_MAX_F = 550
SETPOINT_STEP_F = 5
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
# from Samsung documentation and firmware observations. The firmware will
# reject unknown modes; missing entries here are a coverage gap, not a bug.
#
# This is a fallback only, used when a device's own /mode/vs/0 doesn't report
# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/
# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports
# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
# in its own supportedModes; those are read live rather than added here, per
# the adding-device-support skill's preference for device-reported option
# lists over hardcoded ones.
# Mode options seen on NV7000BS-class. No dump exists so this list is
# inferred from Samsung documentation and firmware observations; the
# firmware rejects unknown modes, so a missing entry is a coverage gap, not
# a bug. Fallback only, used when a device's own /mode/vs/0 doesn't report
# supportedModes at all -- see _oven_mode_options/_oven_mode_write below.
_OVEN_MODES = (
'NoOperation',
'Bake',
'Broil',
'Convection',
'ConvectionBake',
'ConvectionBroil',
'FrozenPizzaPlus',
'SlowCook',
'PlateWarm',
'AirFry',
"NoOperation",
"Bake",
"Broil",
"Convection",
"ConvectionBake",
"ConvectionBroil",
"FrozenPizzaPlus",
"SlowCook",
"PlateWarm",
"AirFry",
)
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
'Stop': 'idle',
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
}
@@ -99,11 +89,11 @@ def _finish_time(remaining_str):
if not remaining_str:
return None
try:
h, m, s = remaining_str.split(':')
h, m, s = remaining_str.split(":")
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
return datetime.now(UTC) + timedelta(seconds=total_s)
except Exception:
return None
@@ -113,7 +103,7 @@ def _op_minutes(op_time):
if not op_time:
return None
try:
h, m, s = op_time.split(':')
h, m, s = op_time.split(":")
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
@@ -123,53 +113,49 @@ def _op_minutes(op_time):
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
# ---------------------------------------------------------------------------
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in an options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
def _has_option(prefix):
"""exists_fn for an options-array switch: bind only when the device's own
options[] actually carries a `<prefix>_<value>` token.
"""exists_fn for an options-array switch: bind only when the device's
own options[] actually carries a `<prefix>_<value>` token.
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
an unverified guess (see module docstring) -- issue #183's dump (model
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
options[] at all, so both switches were phantom controls: always read as
off, and toggling them wrote a token the firmware never recognized in
the first place, hence "does not appear to do anything."
fast_preheat/natural_steam were shipped unconditionally (no exists_fn)
as an unverified guess -- issue #183's dump reports neither token in
its options[] at all, so both switches were phantom controls that
"don't appear to do anything."
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
identical per-token exists_fn on its own options[]-array href: a stub
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
without this an entity whose token is genuinely present would never get
a first chance to bind, since exists_fn runs before that first real
fetch lands.
identical exists_fn: a stub /device/0 seed rep has no options[] at all,
and without this a genuinely-present token would never get a first
chance to bind.
"""
return lambda rep, resources: is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
return lambda rep, resources: (
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
)
def _option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write, mirroring
laundry.option_write. NOT independently confirmed on an oven -- issue
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
This extrapolates that same vendor field/contract to the oven's
/mode/vs/0, on the assumption the firmware handles the array the same
way there. If that assumption is wrong for some oven, a device that
replaces the field outright instead of merging would drop every other
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
real device report surfaces that."""
return [f'{prefix}_{new_value}']
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0;
this extrapolates the same contract here. If some oven replaces the
field outright instead of merging, this would drop every other option
on the next write -- revisit if a real device report surfaces that."""
return [f"{prefix}_{new_value}"]
# ---------------------------------------------------------------------------
# Write functions
# ---------------------------------------------------------------------------
def _oven_setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array."""
try:
@@ -180,27 +166,27 @@ def _oven_setpoint_write(p, rep, href=None):
temp_i = int(round(temp / step_v) * step_v)
if not (min_v <= temp_i <= max_v):
return None
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
items[0]["x.com.samsung.da.desired"] = str(temp_i)
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
def _cook_time_write(p, rep, href=None):
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
try:
minutes = int(round(float(p)))
minutes = round(float(p))
except (TypeError, ValueError):
return None
if not (0 <= minutes <= 1439):
return None
h, m = divmod(minutes, 60)
hms = f"{h:02d}:{m:02d}:00"
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.operationTime': hms,
'x.com.samsung.da.remainingTime': hms,
return ["operational", "state", "vs", "0"], {
"x.com.samsung.da.operationTime": hms,
"x.com.samsung.da.remainingTime": hms,
}
@@ -210,30 +196,32 @@ def _oven_mode_options(resources):
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
needs the callable form rather than options_field because a static
fallback has to kick in when the device's own field is absent."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
rep = resources.get("/mode/vs/0") or {}
live = rep.get("x.com.samsung.da.supportedModes")
return list(live) if live else list(_OVEN_MODES)
def _oven_mode_write(p, rep, href=None):
valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES
valid = rep.get("x.com.samsung.da.supportedModes") or _OVEN_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
def _option_switch_write(prefix):
"""Factory for a single-token on/off options-array write -- lamp, sound,
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
a byte-for-byte copy of this same shape, one per prefix."""
def write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write(prefix, p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": _option_write(prefix, p),
}
return write
@@ -242,55 +230,80 @@ def _option_switch_write(prefix):
# ---------------------------------------------------------------------------
OVEN_OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
href="/operational/state/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
icon='mdi:stove',
device_class='enum', options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
device_class='timestamp',
value_fn=_finish_time),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
icon="mdi:stove",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
BinarySensorDesc(
key="cycle_active",
field="x.com.samsung.da.state",
device_class="running",
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == "active",
),
SensorDesc(
key="progress_percentage",
field="x.com.samsung.da.progressPercentage",
unit="%",
state_class="measurement",
value_fn=_int,
),
SensorDesc(
key="operation_time_minutes",
field="x.com.samsung.da.operationTime",
unit="min",
state_class="measurement",
value_fn=_op_minutes,
),
SensorDesc(
key="finish_time",
field="x.com.samsung.da.remainingTime",
device_class="timestamp",
value_fn=_finish_time,
),
NumberDesc(
key="cook_time",
field="x.com.samsung.da.operationTime",
unit="min",
native_min=0,
native_max=1439,
step=1.0,
icon="mdi:timer",
value_fn=_op_minutes,
write_fn=_cook_time_write,
),
STOP_BUTTON,
),
)
OVEN_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
href="/oven/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
),
SensorDesc(
key="oven_state",
field="x.com.samsung.da.state",
),
),
)
def _oven_temp_unit(rep):
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
same aggregate `/temperatures/vs/0` items[] resource type, which on
fridge hardware carries a per-item `x.com.samsung.da.unit` field
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
Keeps the verified '°C' default when the field is absent (the original
NV7000BS-class dump this module was written against), but reads it live
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this aggregate
`/temperatures/vs/0` items[] resource carries a per-item `unit` field
that was previously hardcoded away (issue #7). Keeps the verified '°C'
default when the field is absent, but reads it live -- issue #44's
range dump is the first to report 'Fahrenheit' here."""
items = rep.get("x.com.samsung.da.items") or []
unit = items[0].get("x.com.samsung.da.unit") if items else None
return normalize_temp_unit(unit, default="°C")
def _setpoint_bounds(rep):
@@ -298,115 +311,157 @@ def _setpoint_bounds(rep):
constants above for provenance. Bounds must track the unit shown by
unit_fn (both read the same live rep), or the HA slider's range would
silently mismatch its own displayed unit."""
if _oven_temp_unit(rep) == '°F':
if _oven_temp_unit(rep) == "°F":
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
OVEN_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
href="/temperatures/vs/0",
poll_tier="hot",
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
NumberDesc(
key="oven_setpoint",
field="x.com.samsung.da.items",
device_class="temperature",
unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C),
native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C),
icon="mdi:thermometer-chevron-up",
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.desired") if items else None
),
write_fn=_oven_setpoint_write,
),
SensorDesc(
key="current_temp_c",
field="x.com.samsung.da.items",
device_class="temperature",
state_class="measurement",
unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.current") if items else None
),
),
),
)
OVEN_DOOR = Capability(
href='/doors/vs/0',
poll_tier='hot',
href="/doors/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
BinarySensorDesc(
key="door_open",
field="x.com.samsung.da.items",
device_class="door",
value_fn=lambda items: (
items[0].get("x.com.samsung.da.openState") == "Open" if items else None
),
),
),
)
OVEN_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
href="/connected/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
BinarySensorDesc(
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
),
)
# Static cavity capability metadata (count/type/supported features) -- no
# per-cavity data varies at runtime on any dump seen so far (issue #44's
# range: single cavity, no supportedFeatureList entries). Bound with no
# entities purely for coverage; revisit if a multi-cavity dump surfaces
# fields worth exposing.
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
# Static cavity capability metadata -- no per-cavity data varies at runtime
# on any dump seen so far. Bound with no entities purely for coverage.
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
# string (language/menu/servingSize/option) with every field blank on the
# only dump seen, and no documented write contract. No entity to bind per
# the 'don't guess' rule; a bare Capability still marks the href covered.
OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0')
# Quick-recipe display blob (combi microwave, issue #121) -- every field
# blank on the only dump seen, no documented write contract.
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
OVEN_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_oven_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_option_switch_write('UpperLamp')),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_option_switch_write('Sound')),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
icon='mdi:fire',
exists_fn=_has_option('fastpreheat'),
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_option_switch_write('fastpreheat')),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
icon='mdi:kettle-steam',
exists_fn=_has_option('NaturalSteam'),
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_option_switch_write('NaturalSteam')),
SelectDesc(
key="oven_mode",
field="x.com.samsung.da.modes",
icon="mdi:tune",
options=_oven_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write,
),
# No exists_fn on the NV7000BS-class board this was proven against
# (UpperLamp_ is always in its options[]) -- but issue #300's
# steam-oven-class WALLOVEN board's options[] has no UpperLamp_
# token at all, so this was a phantom, always-off, write-does-
# nothing switch there. Same fastpreheat/NaturalSteam-class gap
# issue #183 already fixed on the other switches below.
SwitchDesc(
key="lamp",
field="x.com.samsung.da.options",
icon="mdi:track-light",
exists_fn=_has_option("UpperLamp"),
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
write_fn=_option_switch_write("UpperLamp"),
),
SwitchDesc(
key="sound",
field="x.com.samsung.da.options",
icon="mdi:volume-high",
entity_category="config",
value_fn=lambda opts: _option_value(opts, "Sound") == "On",
write_fn=_option_switch_write("Sound"),
),
SwitchDesc(
key="fast_preheat",
field="x.com.samsung.da.options",
icon="mdi:fire",
exists_fn=_has_option("fastpreheat"),
value_fn=lambda opts: _option_value(opts, "fastpreheat") == "On",
write_fn=_option_switch_write("fastpreheat"),
),
SwitchDesc(
key="natural_steam",
field="x.com.samsung.da.options",
icon="mdi:kettle-steam",
exists_fn=_has_option("NaturalSteam"),
value_fn=lambda opts: _option_value(opts, "NaturalSteam") == "On",
write_fn=_option_switch_write("NaturalSteam"),
),
# 120-hour energy-saving standby (issue #183): confirmed present in
# this unit's options[] (EnergySaving_On) and directly requested --
# unlike fast_preheat/natural_steam above, this token is real on this
# hardware, just previously unbound entirely.
SwitchDesc(key='energy_saving', field='x.com.samsung.da.options',
icon='mdi:leaf', entity_category='config',
exists_fn=_has_option('EnergySaving'),
value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On',
write_fn=_option_switch_write('EnergySaving')),
# this unit's options[] -- unlike fast_preheat/natural_steam above,
# this token is real on this hardware, just previously unbound.
SwitchDesc(
key="energy_saving",
field="x.com.samsung.da.options",
icon="mdi:leaf",
entity_category="config",
exists_fn=_has_option("EnergySaving"),
value_fn=lambda opts: _option_value(opts, "EnergySaving") == "On",
write_fn=_option_switch_write("EnergySaving"),
),
# Cooktop-on alert (issue #183): also confirmed present
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
# the SmartThings app's own alerting, not local automation.
SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options',
icon='mdi:alert-circle-outline', entity_category='config',
exists_fn=_has_option('BurnerOnAlert'),
value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On',
write_fn=_option_switch_write('BurnerOnAlert')),
SwitchDesc(
key="cooktop_on_alert",
field="x.com.samsung.da.options",
icon="mdi:alert-circle-outline",
entity_category="config",
exists_fn=_has_option("BurnerOnAlert"),
value_fn=lambda opts: _option_value(opts, "BurnerOnAlert") == "On",
write_fn=_option_switch_write("BurnerOnAlert"),
),
),
)
@@ -1,47 +1,44 @@
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
model TP1X_DA-KS-RANGE-0102X).
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
burner state as strings inside /mode/vs/0's options array instead of the
structured /cooktop/status/vs/0 resource this module reads -- two different
OCF surfaces that happen to share the English word "cooktop".
Not to be confused with registry/capabilities/cooktop.py, which covers an
unrelated standalone-cooktop product (NA9300K-class) that encodes burner
state as strings inside /mode/vs/0's options array instead of the
structured /cooktop/status/vs/0 resource this module reads -- two
different OCF surfaces that happen to share the English word "cooktop".
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
names (no `x.com.samsung.da.` prefix).
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
array (indexed by `burnerNumber`, not by a separate href per burner like
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
and gated by exists_fn against whichever indices the device actually
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
an index absent from burnerList just never binds.
`/cooktop/status/vs/0` carries every burner's live state in one
`burnerList` array (indexed by `burnerNumber`), so per-burner entities are
hardcoded up to MAX_BURNERS and gated by exists_fn against whichever
indices the device actually reports -- an index absent from burnerList
just never binds.
Write surfaces here are unproven (no live device to verify against, same
caveat as oven.py's RMW writes) -- power level uses the same read-modify-
write pattern already proven safe elsewhere in this codebase (oven setpoint,
icemaker toggles).
caveat as oven.py's RMW writes) -- power level uses the same
read-modify-write pattern already proven safe elsewhere in this codebase.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
# burners exists. Kept a little above both since exists_fn gates unused
# slots out -- see module docstring.
# Observed as high as 4; user-reported hardware with 5 burners exists.
# Kept a little above both since exists_fn gates unused slots out.
MAX_BURNERS = 6
def _burner(burner_list, i):
for b in (burner_list or []):
if b.get('burnerNumber') == i:
for b in burner_list or []:
if b.get("burnerNumber") == i:
return b
return None
def _burner_exists(i):
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
return lambda rep, resources: _burner(rep.get("burnerList"), i) is not None
def _burner_field_fn(i, field):
@@ -49,35 +46,36 @@ def _burner_field_fn(i, field):
def _burner_hot_surface_fn(i):
get_state = _burner_field_fn(i, 'hotSurfaceState')
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
get_state = _burner_field_fn(i, "hotSurfaceState")
return lambda burner_list: get_state(burner_list) not in (None, "normal")
def _burner_pan_detection_fn(i):
return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list))
return lambda burner_list: bool(_burner_field_fn(i, "panDetection")(burner_list))
def _power_level_options(resources):
spec = resources.get('/cooktop/spec/vs/0') or {}
return list(spec.get('supportedPowerLevelList') or [])
spec = resources.get("/cooktop/spec/vs/0") or {}
return list(spec.get("supportedPowerLevelList") or [])
def _burner_power_level_write(i):
def write(p, rep, href=None):
burner_list = rep.get('burnerList')
burner_list = rep.get("burnerList")
if not burner_list:
return None
new_list = []
found = False
for b in burner_list:
if b.get('burnerNumber') == i:
if b.get("burnerNumber") == i:
b = dict(b)
b['powerLevel'] = p
b["powerLevel"] = p
found = True
new_list.append(b)
if not found:
return None
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
return ["cooktop", "status", "vs", "0"], {"burnerList": new_list}
return write
@@ -85,128 +83,168 @@ def _burner_entities(i):
exists = _burner_exists(i)
n = i + 1
return (
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
icon='mdi:knob',
translation_key='range_burner_power_level',
translation_placeholders={'number': str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, 'powerLevel'),
write_fn=_burner_power_level_write(i)),
SensorDesc(key=f'burner_{i}_state', field='burnerList',
icon='mdi:stove',
translation_key='burner_state',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
device_class='heat',
translation_key='burner_hot_surface',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList',
icon='mdi:pot',
translation_key='burner_pan_detected',
translation_placeholders={'number': str(n)},
entity_category='diagnostic',
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i)),
SelectDesc(
key=f"burner_{i}_power_level",
field="burnerList",
icon="mdi:knob",
translation_key="range_burner_power_level",
translation_placeholders={"number": str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, "powerLevel"),
write_fn=_burner_power_level_write(i),
),
SensorDesc(
key=f"burner_{i}_state",
field="burnerList",
icon="mdi:stove",
translation_key="burner_state",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, "operationState"),
),
BinarySensorDesc(
key=f"burner_{i}_hot_surface",
field="burnerList",
device_class="heat",
translation_key="burner_hot_surface",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i),
),
BinarySensorDesc(
key=f"burner_{i}_pan_detected",
field="burnerList",
icon="mdi:pot",
translation_key="burner_pan_detected",
translation_placeholders={"number": str(n)},
entity_category="diagnostic",
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i),
),
)
def _child_lock_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
return ["cooktop", "status", "vs", "0"], {"childLock": p.lower()}
COOKTOP_STATUS = Capability(
href='/cooktop/status/vs/0',
poll_tier='hot',
href="/cooktop/status/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='cooktop_state', field='operationState',
icon='mdi:pot-steam'),
# The cooktop section's own on/off (issue #86) -- distinct from
# common.POWER's /power/0 or /power/vs/0, which some boards in this
# family (the range combo) additionally carry for the whole
# appliance. Read-only: no live device to confirm remotely turning
# a cooktop on wouldn't leave a burner active unattended.
BinarySensorDesc(key='cooktop_power', field='power',
device_class='power', icon='mdi:pot-steam',
value_fn=lambda v: str(v).lower() == 'on'),
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
# The cooktop section's own on/off (issue #86), distinct from
# common.POWER's whole-appliance switch. Read-only: no live device
# to confirm remotely turning it on wouldn't leave a burner active
# unattended.
BinarySensorDesc(
key="cooktop_power",
field="power",
device_class="power",
icon="mdi:pot-steam",
value_fn=lambda v: str(v).lower() == "on",
),
# Safe to write -- a lock toggle, not a heat control -- via a
# direct single-field PUT (no RMW needed; unlike burnerList this
# is a lone scalar, not an array of siblings to preserve).
SwitchDesc(key='cooktop_child_lock', field='childLock',
device_class='lock', entity_category='config',
icon='mdi:lock',
value_fn=lambda v: str(v).lower() == 'on',
write_fn=_child_lock_write),
# direct single-field PUT, no RMW needed. No device_class:
# SwitchDeviceClass only has 'outlet'/'switch', not 'lock' --
# passing it crashed switch platform setup for the whole device
# (issue #349, same bug as water_purifier.py's lock switches).
SwitchDesc(
key="cooktop_child_lock",
field="childLock",
entity_category="config",
icon="mdi:lock",
value_fn=lambda v: str(v).lower() == "on",
write_fn=_child_lock_write,
),
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
),
)
# Static burner-count/power-level-list metadata, read directly by
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
# than exposed through its own entity -- same "informs another capability,
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
# COOKTOP_STATUS's power-level select (options=_power_level_options)
# rather than exposed through its own entity.
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
# feature itself is enabled -- not a live "surface is hot right now" alert
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
# verified, so read-only for now.
# duration; state on/off is whether the feature itself is enabled -- not a
# live "surface is hot right now" alert (that's COOKTOP_STATUS's per-burner
# hot_surface). No write contract verified, so read-only for now.
COOKTOP_SAFETY = Capability(
href='/cooktop/settings/status/vs/0',
poll_tier='warm',
href="/cooktop/settings/status/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'),
BinarySensorDesc(
key="cooktop_safety_shutoff_enabled",
field="safetyAlert",
entity_category="diagnostic",
value_fn=lambda v: (v or {}).get("state") == "on",
),
),
)
# Bluetooth meat probe (issue #86). All-idle sentinel values when
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of
# showing a disconnected accessory's fields plainly rather than hiding the
# whole capability.
# gating, matching cooktop.PAIRED_HOOD_STATUS's precedent of showing a
# disconnected accessory's fields plainly rather than hiding the capability.
PROBE_STATUS = Capability(
href='/bluetooth/probe/status/vs/0',
poll_tier='warm',
href="/bluetooth/probe/status/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='probe_connected', field='connectionState',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'connected'),
SensorDesc(key='probe_battery', field='batteryPercentage',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic'),
SensorDesc(key='probe_temperature', field='currentTemperature',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
SensorDesc(key='probe_target_temperature', field='targetTemperature',
device_class='temperature', entity_category='diagnostic',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
BinarySensorDesc(
key="probe_connected",
field="connectionState",
device_class="connectivity",
value_fn=lambda v: str(v).lower() == "connected",
),
SensorDesc(
key="probe_battery",
field="batteryPercentage",
device_class="battery",
state_class="measurement",
unit="%",
entity_category="diagnostic",
),
SensorDesc(
key="probe_temperature",
field="currentTemperature",
device_class="temperature",
state_class="measurement",
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
),
SensorDesc(
key="probe_target_temperature",
field="targetTemperature",
device_class="temperature",
entity_category="diagnostic",
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
),
),
)
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
# burner array at all -- their local API only exposes this coarse
# monitoring resource for the cooktop half, with no per-burner detail.
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
# `warmingCenterState`'s full value set aren't confirmed, so both are
# exposed as plain sensors rather than guessed at as a switch/select --
# `supportedHoodLampStateList` has no corresponding live-state field on
# this resource, so nothing to bind it to yet.
# Some range boards (issue #74) report no /cooktop/status/vs/0 burner
# array at all -- their local API only exposes this coarse monitoring
# resource, with no per-burner detail. Meaning of `cooktopMonitoring`
# (bare "0" on the only dump seen) and `warmingCenterState`'s full value
# set aren't confirmed, so both are plain sensors rather than a guessed
# switch/select.
COOKTOP_MONITORING = Capability(
href='/cooktopmonitoring/vs/0',
poll_tier='warm',
href="/cooktopmonitoring/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
icon='mdi:pot-steam-outline'),
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
icon='mdi:heat-wave', entity_category='diagnostic'),
SensorDesc(
key="cooktop_running_state",
field="x.com.samsung.da.cooktopRunningState",
icon="mdi:pot-steam-outline",
),
SensorDesc(
key="warming_center_state",
field="x.com.samsung.da.warmingCenterState",
icon="mdi:heat-wave",
entity_category="diagnostic",
),
),
)
@@ -7,8 +7,6 @@ brightness remain separate controls because the device advertises them as two
independent fields.
"""
from datetime import datetime, timezone
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
@@ -19,14 +17,7 @@ from ..entities import (
SensorDesc,
SwitchDesc,
)
from .common import int_or_none, sensor_item_value
def _timestamp(value):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except (TypeError, ValueError, OSError):
return None
from .common import epoch_to_utc, int_or_none, sensor_item_value
def _active_alarm_codes(items):
@@ -39,23 +30,23 @@ def _active_alarm_codes(items):
for item in items or ():
if not isinstance(item, dict):
continue
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
if str(item.get("x.com.samsung.da.state", "")).lower() == "deleted":
continue
code = item.get('x.com.samsung.da.code')
if code and str(code).lower() != 'errorcode_off':
code = item.get("x.com.samsung.da.code")
if code and str(code).lower() != "errorcode_off":
codes.append(code)
return ', '.join(codes) if codes else 'none'
return ", ".join(codes) if codes else "none"
HOOD_ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
href="/alarms/vs/0",
poll_tier="hot",
entities=(
SensorDesc(
key='alarm_code',
field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic',
key="alarm_code",
field="x.com.samsung.da.items",
icon="mdi:alert",
entity_category="diagnostic",
value_fn=_active_alarm_codes,
),
),
@@ -64,24 +55,21 @@ HOOD_ALARMS = Capability(
def _hood_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
power_href = args[0] if args else '/power/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
if power_href == '/power/vs/0':
return ['power', 'vs', '0'], {
'x.com.samsung.da.power': 'On' if value else 'Off',
if kind == "power":
power_href = args[0] if args else "/power/0"
if power_href == "/power/0":
return ["power", "0"], {"value": bool(value)}
if power_href == "/power/vs/0":
return ["power", "vs", "0"], {
"x.com.samsung.da.power": "On" if value else "Off",
}
return None
if kind == 'speed':
if kind == "speed":
value = str(value)
supported = [
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
supported = [str(code) for code in rep.get("x.com.samsung.da.hood.supportedFanSpeed", ())]
if not supported:
min_s = rep.get('x.com.samsung.da.hood.settableMinFanSpeed')
max_s = rep.get('x.com.samsung.da.hood.settableMaxFanSpeed')
min_s = rep.get("x.com.samsung.da.hood.settableMinFanSpeed")
max_s = rep.get("x.com.samsung.da.hood.settableMaxFanSpeed")
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
@@ -90,32 +78,33 @@ def _hood_fan_write(payload, rep, href=None):
pass
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
'x.com.samsung.da.hood.fanSpeed': value,
return ["hood", "fanspeed", "vs", "0"], {
"x.com.samsung.da.hood.fanSpeed": value,
}
return None
HOOD_FAN = Capability(
href='/hood/fanspeed/vs/0',
poll_tier='hot',
href="/hood/fanspeed/vs/0",
poll_tier="hot",
entities=(
FanDesc(
key='fan',
field='x.com.samsung.da.hood.fanSpeed',
key="fan",
field="x.com.samsung.da.hood.fanSpeed",
write_fn=_hood_fan_write,
),
BinarySensorDesc(
key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation',
icon='mdi:fan-auto',
entity_category='diagnostic',
key="automatic_operation",
field="x.com.samsung.da.hood.autoOperation",
icon="mdi:fan-auto",
entity_category="diagnostic",
# Absent on the microwave family's built-in vent fan (issue
# #137) -- this board has no auto-ventilation mode, unlike the
# standalone range hood this capability was written for.
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
value_fn=lambda value: str(value).lower() == 'on',
is_stub_rep(rep) or "x.com.samsung.da.hood.autoOperation" in rep
),
value_fn=lambda value: str(value).lower() == "on",
),
),
)
@@ -123,34 +112,34 @@ HOOD_FAN = Capability(
def _lamp_level_write(value, rep, href=None):
code = str(value)
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
supported = [str(level) for level in rep.get("x.com.samsung.lamp.range", ())]
if code not in supported:
return None
return ['hood', 'lamp', 'vs', '0'], {
'x.com.samsung.lamp.current': code,
return ["hood", "lamp", "vs", "0"], {
"x.com.samsung.lamp.current": code,
}
HOOD_LAMP = Capability(
href='/hood/lamp/vs/0',
poll_tier='hot',
href="/hood/lamp/vs/0",
poll_tier="hot",
entities=(
SwitchDesc(
key='lamp',
field='x.com.samsung.lamp.power',
icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on',
key="lamp",
field="x.com.samsung.lamp.power",
icon="mdi:range-hood",
value_fn=lambda value: str(value).lower() == "on",
write_fn=lambda payload, rep, href=None: (
['hood', 'lamp', 'vs', '0'],
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
["hood", "lamp", "vs", "0"],
{"x.com.samsung.lamp.power": "On" if payload == "On" else "Off"},
),
),
SelectDesc(
key='lamp_brightness',
field='x.com.samsung.lamp.current',
icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range',
key="lamp_brightness",
field="x.com.samsung.lamp.current",
icon="mdi:brightness-6",
translation_key="range_hood_lamp_brightness",
options_field="x.com.samsung.lamp.range",
write_fn=_lamp_level_write,
),
),
@@ -158,36 +147,34 @@ HOOD_LAMP = Capability(
HOOD_FILTER = Capability(
href='/filter/hoodfilter/vs/0',
poll_tier='cold',
href="/filter/hoodfilter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key='hood_filter_usage',
field='x.com.samsung.da.filterUsage',
unit='%',
state_class='measurement',
icon='mdi:air-filter',
entity_category='diagnostic',
key="hood_filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key='hood_filter_status',
field='x.com.samsung.da.filterStatus',
icon='mdi:air-filter',
entity_category='diagnostic',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
),
key="hood_filter_status",
field="x.com.samsung.da.filterStatus",
icon="mdi:air-filter",
entity_category="diagnostic",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
),
SensorDesc(
key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity',
unit='h',
icon='mdi:timer-outline',
entity_category='diagnostic',
key="hood_filter_capacity",
field="x.com.samsung.da.filterCapacity",
unit="h",
icon="mdi:timer-outline",
entity_category="diagnostic",
enabled_default=False,
value_fn=int_or_none,
),
@@ -195,44 +182,39 @@ HOOD_FILTER = Capability(
)
# After Run (issue #147): the hood keeps the fan running at low speed for a
# while after it's switched off, to clear residual cooking smoke -- a
# feature a user actively watches and cancels, not passive diagnostics, so
# none of the three entities below carry entity_category. No
# supported-values list is advertised for activationState, so it's modeled
# read-only (monitoring, not an invented "enable" write) per the 'don't
# guess' rule; runningCancel's only observed value is the command name
# itself ('Cancel'), the same self-describing command-field shape as
# operational.STOP_BUTTON. runningProgress's own name states its domain
# (a percentage of the cycle completed), so it's modeled as one rather than
# left an opaque passthrough -- unlike activationState/runningCancel, there's
# no ambiguous field name or missing-write-contract question here to hedge on.
# After Run (issue #147): the hood keeps the fan running at low speed after
# it's switched off, to clear residual cooking smoke -- a feature a user
# actively watches and cancels, so none of the three entities below carry
# entity_category. No supported-values list is advertised for
# activationState, so it's read-only monitoring rather than an invented
# "enable" write; runningCancel's only observed value is the command name
# itself ('Cancel'), the same shape as operational.STOP_BUTTON.
AFTER_RUN = Capability(
href='/afterrun/vs/0',
poll_tier='warm',
href="/afterrun/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='after_run_active',
field='x.com.samsung.da.activationState',
icon='mdi:fan-clock',
value_fn=lambda value: str(value).lower() == 'on',
key="after_run_active",
field="x.com.samsung.da.activationState",
icon="mdi:fan-clock",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='after_run_progress',
field='x.com.samsung.da.runningProgress',
unit='%',
state_class='measurement',
icon='mdi:fan-clock',
key="after_run_progress",
field="x.com.samsung.da.runningProgress",
unit="%",
state_class="measurement",
icon="mdi:fan-clock",
value_fn=int_or_none,
),
ButtonDesc(
key='after_run_cancel',
field='',
payload='Cancel',
icon='mdi:fan-off',
key="after_run_cancel",
field="",
payload="Cancel",
icon="mdi:fan-off",
write_fn=lambda p, rep, href=None: (
['afterrun', 'vs', '0'],
{'x.com.samsung.da.runningCancel': p},
["afterrun", "vs", "0"],
{"x.com.samsung.da.runningCancel": p},
),
),
),
@@ -240,82 +222,82 @@ AFTER_RUN = Capability(
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
href="/sensors/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key='clean_level',
field='x.com.samsung.da.items',
icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
key="clean_level",
field="x.com.samsung.da.items",
icon="mdi:air-filter",
value_fn=lambda items: sensor_item_value(items, "CleanLevel"),
),
SensorDesc(
key='dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'Dust'),
key="dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "Dust"),
),
SensorDesc(
key='fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
key="fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "FineDust"),
),
SensorDesc(
key='super_fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
key="super_fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "SuperFineDust"),
),
),
)
AIR_LEVEL_CHECK = Capability(
href='/airlevelcheck/vs/0',
poll_tier='warm',
href="/airlevelcheck/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState',
icon='mdi:radar',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
key="periodic_air_sensing",
field="x.com.samsung.da.periodicSensingActivationState",
icon="mdi:radar",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='air_sensing_state',
field='x.com.samsung.da.sensingState',
icon='mdi:radar',
entity_category='diagnostic',
key="air_sensing_state",
field="x.com.samsung.da.sensingState",
icon="mdi:radar",
entity_category="diagnostic",
),
SensorDesc(
key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime',
device_class='timestamp',
entity_category='diagnostic',
value_fn=_timestamp,
key="last_air_sensing_time",
field="x.com.samsung.da.lastSensingTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=epoch_to_utc,
),
SensorDesc(
key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel',
icon='mdi:air-filter',
entity_category='diagnostic',
key="last_air_sensing_level",
field="x.com.samsung.da.lastSensingLevel",
icon="mdi:air-filter",
entity_category="diagnostic",
),
SensorDesc(
key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState',
icon='mdi:fan-auto',
entity_category='diagnostic',
key="automatic_ventilation_state",
field="x.com.samsung.da.autoExeState",
icon="mdi:fan-auto",
entity_category="diagnostic",
),
),
)
AUTO_VENTILATION = Capability(
href='/autoventilation/vs/0',
poll_tier='warm',
href="/autoventilation/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key='auto_ventilation_action',
field='action',
icon='mdi:fan-auto',
key="auto_ventilation_action",
field="action",
icon="mdi:fan-auto",
),
),
)
@@ -326,11 +308,11 @@ AUTO_VENTILATION = Capability(
COVERAGE = [
Capability(href=href)
for href in (
'/power/0',
'/power/vs/0',
'/mode/vs/0',
'/personality/presence/vs/0',
'/availablecontrolsets/vs/0',
'/da/softreset/vs/0',
"/power/0",
"/power/vs/0",
"/mode/vs/0",
"/personality/presence/vs/0",
"/availablecontrolsets/vs/0",
"/da/softreset/vs/0",
)
]
@@ -1,33 +1,32 @@
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131).
(models A-VSKR-TP1-22-VS9500AL / A-VSWW-TP1-23-VS9700, issues #131 / #219).
The reporter's diagnostics dump shows no vacuum-body state at all (no
suction level, no battery, no cleaning-mode/room-mapping control, no
docked/undocked status even) -- only the clean station's own dustbag,
dustbin auto-empty settings, and UV-C sanitizing-cycle status. This
strongly suggests the WiFi/DTLS module lives in the station, not the
handheld stick: the station is the only "device" this integration's local
API can see, and the wand's own controls are unrelated hardware not
reachable this way. Modeled as its own device type -- these hrefs don't
overlap with any existing family, so there's no shared-href ambiguity to
resolve against another type (see registry/by_type/__init__.py's
docstring for that rule).
The WiFi/DTLS module lives in the clean station. Older VS9500 dumps
(#131) exposed only dustbag/dustbin/UV-C station state. VS9700 dumps
(#219) additionally expose `/status/stick/vs/0` with the wand's battery
%, cleaning/charging status, and BLE link -- still no suction/room-map
control. Modeled as its own device type -- these hrefs don't overlap with
any existing family (see registry/by_type/__init__.py's docstring).
Resources verified against the issue #131 diagnostics dump.
Resources verified against issue #131 and #219 diagnostics dumps.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
from .common import int_or_none
from .common import parse_iso_utc as _parse_iso_utc
DUSTBAG = Capability(
href='/component/station/dustbag/vs/0',
poll_tier='warm',
href="/component/station/dustbag/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status',
device_class='problem',
icon='mdi:bag-personal',
value_fn=lambda v: v == 'full'),
BinarySensorDesc(
key="dustbag_full",
field="x.com.samsung.da.status",
device_class="problem",
icon="mdi:bag-personal",
value_fn=lambda v: v == "full",
),
),
)
@@ -36,40 +35,57 @@ DUSTBAG = Capability(
# families' filterUsage, which always ships filterCapacity) -- exposed as a
# plain diagnostic count rather than guessing a unit.
DUSTBAG_USAGE = Capability(
href='/component/station/dustbagusage/vs/0',
poll_tier='cold',
href="/component/station/dustbagusage/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage',
icon='mdi:counter', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
SensorDesc(
key="dustbag_usage",
field="x.com.samsung.da.dustbagUsage",
icon="mdi:counter",
entity_category="diagnostic",
state_class="total_increasing",
value_fn=int_or_none,
),
),
)
DUSTBIN_SETTING = Capability(
href='/setting/dustbin/vs/0',
poll_tier='warm',
href="/setting/dustbin/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty',
icon='mdi:delete-empty',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})),
SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose',
icon='mdi:door-sliding',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='discharging_time', field='x.com.samsung.da.desiredDischargingTime',
icon='mdi:timer-outline',
entity_category='config',
options_field='x.com.samsung.da.supportedDischargingTime',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.desiredDischargingTime': p})),
SwitchDesc(
key="auto_empty",
field="x.com.samsung.da.autoEmpty",
icon="mdi:delete-empty",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.autoEmpty": "On" if p == "On" else "Off"},
),
),
SwitchDesc(
key="dustbin_auto_close",
field="x.com.samsung.da.autoClose",
icon="mdi:door-sliding",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.autoClose": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="discharging_time",
field="x.com.samsung.da.desiredDischargingTime",
icon="mdi:timer-outline",
entity_category="config",
options_field="x.com.samsung.da.supportedDischargingTime",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.desiredDischargingTime": p},
),
),
),
)
@@ -78,32 +94,100 @@ DUSTBIN_SETTING = Capability(
# rather than a binary_sensor, so no polarity/semantic is asserted that
# might be wrong.
CLEANSTATION_STATUS = Capability(
href='/status/cleanstation/vs/0',
poll_tier='warm',
href="/status/cleanstation/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status',
icon='mdi:home-lightning-bolt', entity_category='diagnostic'),
SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus',
icon='mdi:broom', entity_category='diagnostic'),
SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive',
icon='mdi:lightbulb-on-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['status', 'cleanstation', 'vs', '0'],
{'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='uvc_operation_time', field='x.com.samsung.da.uvcOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='uvc_total_operation_time',
field='x.com.samsung.da.uvcTotalOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
SensorDesc(key='uvc_finished_time', field='x.com.samsung.da.uvcFinishedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
SensorDesc(key='uvc_emitted_time', field='x.com.samsung.da.emittedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
SensorDesc(
key="cleanstation_status",
field="x.com.samsung.da.status",
icon="mdi:home-lightning-bolt",
entity_category="diagnostic",
),
SensorDesc(
key="stick_status",
field="x.com.samsung.da.stickStatus",
icon="mdi:broom",
entity_category="diagnostic",
),
SwitchDesc(
key="uvc_intensive_mode",
field="x.com.samsung.da.uvcIntensive",
icon="mdi:lightbulb-on-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["status", "cleanstation", "vs", "0"],
{"x.com.samsung.da.uvcIntensive": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="uvc_operation_time",
field="x.com.samsung.da.uvcOperationTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key="uvc_total_operation_time",
field="x.com.samsung.da.uvcTotalOperationTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
state_class="total_increasing",
value_fn=int_or_none,
),
SensorDesc(
key="uvc_finished_time",
field="x.com.samsung.da.uvcFinishedTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_parse_iso_utc,
),
SensorDesc(
key="uvc_emitted_time",
field="x.com.samsung.da.emittedTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_parse_iso_utc,
),
),
)
# Wand body state reported through the station (VS9700 / issue #219). Absent
# on the older VS9500 dump (#131) -- discovery drops entities when the href
# is missing.
STICK_BODY = Capability(
href="/status/stick/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="battery",
field="x.com.samsung.da.stickbattery",
device_class="battery",
state_class="measurement",
unit="%",
value_fn=int_or_none,
),
BinarySensorDesc(
key="battery_charging",
field="x.com.samsung.da.stickcleaningstatus",
device_class="battery_charging",
value_fn=lambda v: v == "Charging",
),
SensorDesc(
key="stick_cleaning_status",
field="x.com.samsung.da.stickcleaningstatus",
icon="mdi:vacuum",
),
SensorDesc(
key="stick_operation_mode",
field="x.com.samsung.da.stickoperationmode",
icon="mdi:broom",
),
BinarySensorDesc(
key="stick_ble_connected",
field="x.com.samsung.da.stickbleconnection",
device_class="connectivity",
value_fn=lambda v: v == "On",
),
),
)
@@ -14,177 +14,151 @@ specific controls (wash settings, drum-clean tracking, dispenser dosing) are
here; they read washer-only fields off the same shared /course/vs/0 options
array.
"""
from datetime import datetime, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import (
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
bool_option_exists,
bool_option_switch,
cycle_options,
cycle_select,
drum_clean_cycles_remaining,
drum_clean_last_cleaned,
hex_pairs,
option_value,
option_write,
washer_cycle_fallback,
)
# ---------------------------------------------------------------------------
# Course_XX hex codes. 23 of the codes named in translations/en.json
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
# positionally against a Slovak-UI user's screenshots of their app's course
# list (same order, same count -- see issue #2) and cross-checked against
# the printed user manual's course table (confirming e.g. '8F' as 'Intense
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a
# cycle the manual marks "applicable models only" and that does not appear
# in this device's editCourseList -- nor does 'AI Wash', also "applicable
# models only"). FixedCourseList_1C29 (the two courses always pinned in the
# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what
# you'd expect to be pinned (default cycle + maintenance cycle),
# corroborating the positional match.
# Course_XX hex code labels (translations/en.json,
# washer_cycle_table_02.state.<id>) come from several devices, cross-checked
# rather than guessed: 23 codes from a live WW90DG6U25LEU4's editCourseList,
# matched positionally against a user's app screenshots and the printed
# manual (issue #2); 5 more (Wash+Dry, Air Wash, Cotton Dry, Synthetics Dry,
# a second distinct '1F' Intense Cold) from a WD90T654DBN/S1 combo's own
# editCourseList and screenshots (issue #22, a combo's own course set, not
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
# code after selecting each cycle on the appliance (issue #80). 2 more
# ('0A' Towels, 'B0' Mixed Load) reported for a WW90DG5G34ABLE on the same
# Table_02 family (issue #363). Several codes legitimately share a label
# across different course tables -- '21'/'65' Colors, '27'/'5E'/'78'
# Rinse+Spin, '0A'/'33'/'54'/'70' Towels -- not typos. (This list said
# "'24' Towels" until issue #343 found 24/33 transposed; 24 is Bedding.)
#
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
# combo's editCourseList and were named from that user's app screenshot
# (issue #22). Combo units carry their own course set, so these codes
# don't imply anything about '1F' on a plain washer.
# No static fallback list is kept here: other models have different actual
# course sets, so hardcoding one device's list would show/hide the wrong
# options elsewhere. laundry.cycle_options() reads only the live
# x.com.samsung.da.editCourseList; a device that doesn't populate it gets no
# cycle select at all (see cycle_select's exists_fn). x.com.samsung.da.
# options' MostUsed_* entry was considered as a fallback source (its first
# byte matches the selected Course_XX on both dumps), but the remaining
# bytes don't decode to any confirmed course code, so it isn't used.
#
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a
# WF50A8600AV/US, verified directly rather than by inference: the reporter
# selected each cycle on the physical appliance and read back the resulting
# raw code from the cycle_select entity's state (issue #80). '54' shares a
# display name with the existing '24' Towels -- a different code on a
# different course table legitimately landing on the same label, not a typo
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
# The owner of a Korean Table_02 washer confirmed the names for its newer
# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names
# live only in the table-scoped translation catalog; a code not confirmed by
# the owner or device metadata falls back to washer_cycle_fallback, which
# surfaces a personal-course name only -- no invented English label for an
# unrecognized standard code (PR #251 review).
#
# No static fallback list of those codes is kept here, deliberately: other
# washer models have a different actual course set (a second dump's active
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
# Load' -- both "applicable models only" per the manual -- would have yet
# another set), so hardcoding one device's list would show/hide the wrong
# options on a different model. laundry.cycle_options() reads only the live
# x.com.samsung.da.editCourseList; if a device doesn't populate that
# resource, the cycle select isn't created at all (see cycle_select's
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
# fallback source (its first byte reliably equals the currently-selected
# Course_XX on both dumps we have), but the bytes after that don't
# correspond to any confirmed course code on either device -- e.g. dump 1's
# MostUsed_1C8410923FA67F00000000000000 decodes to
# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code --
# so it isn't trustworthy as a list of selectable courses and isn't used.
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
# cycle on the appliance and reading back the raw code, the same method used
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
# instance) still renders that code raw rather than borrowing a Table_02
# label -- the two tables are unrelated code spaces despite a handful of
# overlapping hex values.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
#
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
# path for different device families. Only one of the two ever binds for a
# given device, since dryer and washer are separate by_type registries.
# ---------------------------------------------------------------------------
WASHER_SETTINGS = Capability(
href='/washer/vs/0',
href="/washer/vs/0",
entities=(
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
icon='mdi:thermometer-water',
entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
icon='mdi:sync',
entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
icon='mdi:water-sync',
entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
SelectDesc(
key="wash_temperature",
field="x.com.samsung.da.waterTemperature",
icon="mdi:thermometer-water",
entity_category="config",
options_field="x.com.samsung.da.supportedWaterTemperature",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.waterTemperature": p},
),
),
SelectDesc(
key="spin_speed",
field="x.com.samsung.da.spinLevel",
icon="mdi:sync",
entity_category="config",
options_field="x.com.samsung.da.supportedSpinLevel",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.spinLevel": p},
),
),
SelectDesc(
key="rinse_cycles",
field="x.com.samsung.da.rinseCycles",
icon="mdi:water-sync",
entity_category="config",
options_field="x.com.samsung.da.supportedRinseCycles",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.rinseCycles": p},
),
),
# Washer/dryer combo units carry a dryLevel field on the wash
# resource itself (no separate dryer device/course) -- see issue
# #22. Self-gates off on plain washers, which never report
# supportedDryLevel.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:tumble-dryer',
entity_category='config',
translation_key='washer_dry_level',
options_field='x.com.samsung.da.supportedDryLevel',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedDryLevel')),
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
# resource itself (issue #22). Self-gates off on plain washers,
# which never report supportedDryLevel.
SelectDesc(
key="dry_level",
field="x.com.samsung.da.dryLevel",
icon="mdi:tumble-dryer",
entity_category="config",
translation_key="washer_dry_level",
options_field="x.com.samsung.da.supportedDryLevel",
exists_fn=lambda rep, resources: bool(rep.get("x.com.samsung.da.supportedDryLevel")),
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.dryLevel": p},
),
),
),
)
# ---------------------------------------------------------------------------
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
# drum-clean and dispenser-dosing entities below are washer-specific reads off
# the same options array.
# ---------------------------------------------------------------------------
# drum-clean and dispenser-dosing entities below are washer-specific reads
# off the same options array.
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
# recommended cleans; WashingTimes_<N> is the count since the last one --
# their difference is exactly the "N cycles until due" figure the Samsung
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
# is the last-clean timestamp (verified against the same screenshot's "10
# days ago"); no explicit timezone field accompanies it on this resource,
# so it's treated as UTC, matching this integration's convention for other
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get('x.com.samsung.da.options') or []
proposal = option_value(opts, 'DrumCleanProposal')
washed = option_value(opts, 'WashingTimes')
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
def _drum_clean_last_cleaned(rep):
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
except ValueError:
return None
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
# #258) since both families report identical DrumCleanProposal_/
# WashingTimes_/DrumCleanLog_ tokens on the same options[] array.
# Detergent/softener auto-dispense dosing, from the same options[] array
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
# detergent, concentration for softener -- matching the SmartThings app's
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
# valid raw codes for its field, same hex-pair shape as EditCourseList.
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
# '<Prefix>Level2Ctrl_<code>' is a second dial (water hardness for
# detergent, concentration for softener), matching the app's two-field
# dispenser screens. 'Supported<Prefix>Ctrl_<hexpairs>' lists the valid raw
# codes, same hex-pair shape as EditCourseList. '<Prefix>Alarm_<On/Off>' is
# a low-reservoir warning flag.
#
# Label mapping (entity.select.{detergent,softener}_quantity /
# detergent_water_hardness / softener_concentration in translations/en.json) is an
# assumed, not cross-device-verified, reading of the single issue #9 dump +
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
# on-screen equivalent -- the app's Quantité picker only offers
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
# 1x/2x/3x for softener concentration does *not* cleanly match
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
# setting the user changed in the app between the dump (issue body) and the
# screenshots (a later comment), not a different code scheme, since it's
# otherwise identical in shape to the detergent side. Revisit if a second
# device's dump contradicts this.
# Label mapping (translations/en.json's {detergent,softener}_quantity /
# detergent_water_hardness / softener_concentration) is an assumed reading
# of the single issue #9 dump + screenshots, cross-checked against the
# selected value on both dispensers, not independently verified per code --
# revisit if a second device's dump contradicts it.
def _supported_level_options(resources, prefix):
rep = resources.get('/course/vs/0') or {}
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
rep = resources.get("/course/vs/0") or {}
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
return hex_pairs(raw) if raw else []
@@ -193,21 +167,20 @@ def _level_options(prefix):
def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code format.
"""Current dose code, normalized to the `Supported<prefix>` code
format. The device reports the selected level as `<prefix>_<code>`
un-padded (e.g. '3'), but the select's own options come from
`Supported<prefix>_<hexpairs>` as zero-padded hex pairs (e.g. '03').
Left as '3', the value sits outside the select's own option list and
HA renders it 'unknown' (issue #9) -- resolve it to the matching
zero-padded code instead."""
The device reports the selected level as `<prefix>_<code>` with the code
un-padded (e.g. '3'), but the valid codes -- which are also this select's
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
outside the select's own option list, so HA renders it 'unknown' (issue #9).
Resolve it to the supported code with the same integer value so
current_option matches an option (and its translation)."""
def fn(rep):
opts = rep.get('x.com.samsung.da.options')
opts = rep.get("x.com.samsung.da.options")
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f'Supported{prefix}')
supported_raw = option_value(opts, f"Supported{prefix}")
try:
target = int(raw, 16)
except (TypeError, ValueError):
@@ -219,67 +192,60 @@ def _dosing_level(prefix):
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
# reports it, so write it back in that native shape.
# `p` is the zero-padded supported code (e.g. '03'); the device
# stores it un-padded (e.g. '3'), matching how it's reported.
try:
native = format(int(p, 16), 'X')
native = format(int(p, 16), "X")
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, native),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, native),
}
return write
def _dosing_low(prefix):
return lambda rep: option_value(
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
return lambda rep: (
option_value(rep.get("x.com.samsung.da.options"), prefix) not in (None, "Off")
)
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
# already used by AiOption and KidsLockBypass in this same array, so
# PreWashSetting/IntensiveSetting are assumed to follow suit.
# array (issue #22 follow-up). Each rides as a plain '<Prefix>_On'/'_Off'
# token, confirmed against a dump taken with Bubble Soak switched on in the
# app -- the same shape as AiOption/KidsLockBypass in this array.
#
# Each also has a differently-named hex-pair availability field that lines up
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
# 24), all three read 'F0' at that position and the toggle was writable --
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
# Off') decodes to '00' for that course, matching the app graying the
# control out there. 'F0'/'00' is treated as available/unavailable on that
# evidence. exists_fn (device-level presence) still only runs once, against
# the setup-time snapshot, so it isn't a fit for this per-course check --
# validate_fn runs on every write attempt instead (dispatched from
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
# for a course whose byte isn't 'F0' with a user-facing error rather than
# silently no-opping against the device. The read/write/presence machinery
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
# auto-release-dry toggles -- only this per-course gating is washer-only, so
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
# Each also has a hex-pair availability field positional with
# editCourseList (BubbleSoakSet, PreWashAvailableSet,
# IntensiveAvailableSet): on the reporter's dump 'F0' at a course's
# position matched the app enabling the control there, '00' matched it
# grayed out. exists_fn only runs once at setup, so it can't do this
# per-course check -- validate_fn runs on every write attempt instead,
# rejecting an on-write for a course whose byte isn't 'F0' with a
# user-facing error rather than silently no-opping. The read/write/
# presence machinery is laundry.bool_option_switch, shared with
# dishwasher's storm-wash/auto-release-dry toggles; only this per-course
# gating is washer-only.
def _bool_option_switch(key, icon, prefix, availability_field):
def validate(p, rep, resources):
"""Reject turning on when the selected course's byte in
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
back to allowing the write whenever the availability data can't be
resolved (unrecognized course, missing/mismatched-length bitmap)
rather than guessing -- a false rejection is worse than an
occasional no-op write."""
if p != 'On':
`availability_field` isn't 'F0'. Turning off is never blocked.
Falls back to allowing the write whenever the availability data
can't be resolved (unrecognized course, missing/mismatched-length
bitmap) -- a false rejection is worse than an occasional no-op."""
if p != "On":
return None
opts = rep.get('x.com.samsung.da.options') or []
current = option_value(opts, 'Course')
opts = rep.get("x.com.samsung.da.options") or []
current = option_value(opts, "Course")
courses = cycle_options(resources)
if not current or current not in courses:
return None
@@ -289,75 +255,227 @@ def _bool_option_switch(key, icon, prefix, availability_field):
pairs = hex_pairs(raw)
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != 'F0':
if pairs[courses.index(current)] != "F0":
return f"{key}_unavailable_for_cycle"
return None
return bool_option_switch(
key, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
key, icon, prefix, entity_category="config", gate_on_presence=True, validate_fn=validate
)
# AddWash -- the little door for adding a forgotten sock mid-cycle -- rides
# three independent tokens on the same options[] array:
#
# AddWashSet_<0-7> the alarm setting, and the only writable one:
# a 3-bit mask over the moments it fires, bit 0
# rinse, bit 1 final rinse, bit 2 spin.
# AddWashAvailable_<0-7> the same three bits, but what the running
# course still permits.
# AddWashIndicator_On/Off the panel lamp: laundry may go in right now.
#
# Bit order confirmed by watching a WW6500 run a cycle: AddWashAvailable
# shed one bit as each moment passed (7 through Rinse, then 6, 4, and 0 as
# Spin began) and reset to 7 at the end, while the lamp tracked the phase
# with the alarm switched off throughout.
def _add_wash_mask(rep, prefix):
"""One of the 3-bit AddWash masks, or None when its token is absent,
malformed, or outside 0-7. Never 0 for a missing token: 0 is a real
value, and a mask this model can't represent is a wrong model rather
than something to write back."""
raw = option_value(rep.get("x.com.samsung.da.options"), prefix)
try:
mask = int(raw)
except (TypeError, ValueError):
return None
return mask if 0 <= mask <= 0b111 else None
def _add_wash_any(prefix):
"""Whether any of the three moments is set in `prefix`'s mask."""
def read(rep):
mask = _add_wash_mask(rep, prefix)
return None if mask is None else mask != 0
return read
def _add_wash_set_write(mask):
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("AddWashSet", str(mask)),
}
def _add_wash_alarm_write(p, rep, href=None):
# Gated on the mask being readable, like the per-moment writes: a device
# reporting a wider mask than these three bits would otherwise have it
# truncated to 7 here, silently dropping a moment it supports.
mask = _add_wash_mask(rep, "AddWashSet")
if p not in ("On", "Off") or mask is None:
return None
if p == "On" and mask:
# Already on, so "on" is a no-op rather than a rewrite to 7. Home
# Assistant calls turn_on regardless of current state, so an
# automation asserting the alarm on over a rinse-only mask would
# otherwise widen it to all three moments with no state change on
# this switch to point at. Distinct from the off-then-on case in
# _add_wash_bit_switch, where there is no subset left to keep.
return None
return _add_wash_set_write(0b111 if p == "On" else 0)
def _add_wash_bit_switch(key, icon, bit):
"""One moment the alarm fires at, as its own bit of the mask.
The mask is the only state, so switching the last moment off lands on 0
and takes the alarm with it, and switching one on from 0 turns the alarm
back on. The corollary is that switching the master off and on again
writes 7, resetting a rinse-only selection to all three moments -- the
appliance remembers no previous subset either, so there is nothing to
restore.
"""
def read(rep):
mask = _add_wash_mask(rep, "AddWashSet")
return None if mask is None else bool(mask >> bit & 1)
def write(p, rep, href=None):
mask = _add_wash_mask(rep, "AddWashSet")
if p not in ("On", "Off") or mask is None:
return None
return _add_wash_set_write(mask | 1 << bit if p == "On" else mask & ~(1 << bit))
return SwitchDesc(
key=key,
icon=icon,
entity_category="config",
exists_fn=bool_option_exists("AddWashSet"),
rep_fn=read,
write_fn=write,
)
def _add_wash_indicator(rep):
raw = option_value(rep.get("x.com.samsung.da.options"), "AddWashIndicator")
return raw.lower() == "on" if isinstance(raw, str) else None
WASHER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'),
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
icon='mdi:washing-machine-alert',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
icon='mdi:calendar-clock',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
translation_key='detergent_quantity',
entity_category='config',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
translation_key='detergent_water_hardness',
entity_category='config',
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
translation_key='softener_quantity',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
translation_key='softener_concentration',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
cycle_select(
translation_key="washer_cycle",
icon="mdi:washing-machine",
table_href="/st/washercourse/vs/0",
display_fn=washer_cycle_fallback,
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:washing-machine-alert",
state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
rep_fn=drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
rep_fn=drum_clean_last_cleaned,
),
SelectDesc(
key="detergent_quantity",
icon="mdi:cup-water",
translation_key="detergent_quantity",
entity_category="config",
options=_level_options("DetergentLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevelCtrl")(resources)),
rep_fn=_dosing_level("DetergentLevelCtrl"),
write_fn=_level_write("DetergentLevelCtrl"),
),
SelectDesc(
key="detergent_water_hardness",
icon="mdi:water-opacity",
translation_key="detergent_water_hardness",
entity_category="config",
options=_level_options("DetergentLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevel2Ctrl")(resources)),
rep_fn=_dosing_level("DetergentLevel2Ctrl"),
write_fn=_level_write("DetergentLevel2Ctrl"),
),
SelectDesc(
key="softener_quantity",
icon="mdi:flask-outline",
translation_key="softener_quantity",
entity_category="config",
options=_level_options("SoftenerLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevelCtrl")(resources)),
rep_fn=_dosing_level("SoftenerLevelCtrl"),
write_fn=_level_write("SoftenerLevelCtrl"),
),
SelectDesc(
key="softener_concentration",
icon="mdi:flask-plus-outline",
translation_key="softener_concentration",
entity_category="config",
options=_level_options("SoftenerLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevel2Ctrl")(resources)),
rep_fn=_dosing_level("SoftenerLevel2Ctrl"),
write_fn=_level_write("SoftenerLevel2Ctrl"),
),
BinarySensorDesc(
key="detergent_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("DetergentAlarm"),
rep_fn=_dosing_low("DetergentAlarm"),
),
BinarySensorDesc(
key="softener_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("SoftenerAlarm"),
rep_fn=_dosing_low("SoftenerAlarm"),
),
_bool_option_switch("bubble_soak", "mdi:chart-bubble", "BubbleSoak", "BubbleSoakSet"),
_bool_option_switch(
"pre_wash", "mdi:washing-machine", "PreWashSetting", "PreWashAvailableSet"
),
_bool_option_switch(
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
),
SwitchDesc(
key="add_wash_alarm",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=bool_option_exists("AddWashSet"),
rep_fn=_add_wash_any("AddWashSet"),
write_fn=_add_wash_alarm_write,
),
_add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0),
_add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1),
_add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2),
# On at rest: an idle washer reports AddWashAvailable_7 and the mask
# only empties as the cycle consumes each moment. This says the cycle
# permits AddWash, not that laundry can go in now -- that is
# add_wash_indicator.
BinarySensorDesc(
key="add_wash_available",
icon="mdi:tshirt-crew-outline",
entity_category="diagnostic",
exists_fn=bool_option_exists("AddWashAvailable"),
rep_fn=_add_wash_any("AddWashAvailable"),
),
BinarySensorDesc(
key="add_wash_indicator",
icon="mdi:door-open",
exists_fn=bool_option_exists("AddWashIndicator"),
rep_fn=_add_wash_indicator,
),
),
)
@@ -4,216 +4,247 @@ issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
Resources verified against the issue #90 and #196 diagnostics dumps.
"""
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
from .common import int_or_none
from .common import parse_iso_utc as _parse_iso_utc
DISPENSE = Capability(
href='/setting/waterpurifier/vs/0',
poll_tier='warm',
href="/setting/waterpurifier/vs/0",
poll_tier="warm",
entities=(
SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType',
icon='mdi:cup-water',
options_field='x.com.samsung.da.supportedTypes',
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredType': p})),
# Only a handful of discrete temperatures are selectable (not a
# continuous range) -- a select over the live-reported set, not a
# number with invented bounds.
#
# Newer boards (issue #196, RWP70F15ANW) don't populate
# supportedHotTemperatures at all -- they report a hotwaterRange
# (min/max) and a hotwaterLevel (step count?) instead, with no
# confirmed write contract for values off the old preset list. With
# an empty options_field result, HA's current_option still returns
# the live tempDesiredHotWater, which isn't in the (empty) options
# list and renders as "unknown" -- the exact symptom reported. Gate
# the entity off entirely when the board doesn't report a supported
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.supportedHotTemperatures',
exists_fn=lambda rep, resources: (
is_stub_rep(rep)
or 'x.com.samsung.da.supportedHotTemperatures' in rep),
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.tempDesiredHotWater': p})),
SelectDesc(
key="dispense_type",
field="x.com.samsung.da.desiredType",
icon="mdi:cup-water",
options_field="x.com.samsung.da.supportedTypes",
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.desiredType": p},
),
),
# Only a handful of discrete temperatures are selectable -- a select
# over the live-reported set, not a number with invented bounds.
# Newer boards (issue #196) don't populate supportedHotTemperatures
# at all, reporting a hotwaterRange/hotwaterLevel pair instead with
# no confirmed write contract -- gate the entity off entirely there
# rather than guess at that pair's meaning (an empty options list
# otherwise left current_option rendering "unknown").
SelectDesc(
key="hot_water_temperature",
field="x.com.samsung.da.tempDesiredHotWater",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.supportedHotTemperatures",
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.supportedHotTemperatures" in rep
),
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.tempDesiredHotWater": p},
),
),
# Bounds and step come live from the device's own
# desiredCapacityRange/capacityResolution fields, not a hardcoded
# constant -- see the adding-device-support skill's "never hard-code
# the one dump's values" section. No unit is set: capacityUnit reads
# "C" on this dump, which can't be right for a volume field, so per
# the 'don't guess' rule the unit is left unset rather than assumed
# to be mL.
NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity',
icon='mdi:cup-water',
value_fn=int_or_none,
range_field='x.com.samsung.da.desiredCapacityRange',
step_fn=lambda rep: int_or_none(
rep.get('x.com.samsung.da.capacityResolution')) or 1,
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})),
BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus',
icon='mdi:cup-water',
value_fn=lambda v: v == 'On'),
# desiredCapacityRange/capacityResolution, not a hardcoded constant.
# No unit is set: capacityUnit reads "C" on this dump, which can't
# be right for a volume field, so it's left unset rather than
# assumed to be mL.
NumberDesc(
key="dispense_capacity",
field="x.com.samsung.da.desiredCapacity",
icon="mdi:cup-water",
value_fn=int_or_none,
range_field="x.com.samsung.da.desiredCapacityRange",
step_fn=lambda rep: int_or_none(rep.get("x.com.samsung.da.capacityResolution")) or 1,
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.desiredCapacity": str(round(float(p)))},
),
),
BinarySensorDesc(
key="pouring",
field="x.com.samsung.da.pourStatus",
icon="mdi:cup-water",
value_fn=lambda v: v == "On",
),
),
)
STATUS = Capability(
href='/status/waterpurifier/vs/0',
poll_tier='warm',
href="/status/waterpurifier/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status',
icon='mdi:water-pump',
entity_category='diagnostic'),
BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus',
device_class='door',
entity_category='diagnostic',
value_fn=lambda v: v == 'Open'),
SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod',
icon='mdi:calendar-sync',
entity_category='diagnostic'),
SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime',
icon='mdi:timer-outline',
entity_category='diagnostic'),
SensorDesc(key='sterilize_last_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizeLastTime'))),
SensorDesc(key='sterilize_plan_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizePlanTime'))),
SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime',
icon='mdi:timer-sand',
entity_category='diagnostic'),
SensorDesc(
key="waterpurifier_status",
field="x.com.samsung.da.status",
icon="mdi:water-pump",
entity_category="diagnostic",
),
BinarySensorDesc(
key="filter_door_status",
field="x.com.samsung.da.filterDoorStatus",
device_class="door",
entity_category="diagnostic",
value_fn=lambda v: v == "Open",
),
SensorDesc(
key="sterilize_period",
field="x.com.samsung.da.sterilizePeriod",
icon="mdi:calendar-sync",
entity_category="diagnostic",
),
SensorDesc(
key="sterilize_run_time",
field="x.com.samsung.da.sterilizeRunTime",
icon="mdi:timer-outline",
entity_category="diagnostic",
),
SensorDesc(
key="sterilize_last_time",
device_class="timestamp",
entity_category="diagnostic",
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizeLastTime")),
),
SensorDesc(
key="sterilize_plan_time",
device_class="timestamp",
entity_category="diagnostic",
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizePlanTime")),
),
SensorDesc(
key="filter_clean_remain_time",
field="x.com.samsung.da.filterCleanRemainTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
),
),
)
FAVORITE_CAPACITY = Capability(
href='/favorite/capacity/vs/0',
poll_tier='cold',
href="/favorite/capacity/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity',
icon='mdi:star-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity',
icon='mdi:cup-water',
entity_category='config',
options_field='x.com.samsung.da.capacityList',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.defaultCapacity': p})),
SwitchDesc(
key="favorite_capacity_enabled",
field="x.com.samsung.da.switchCapacity",
icon="mdi:star-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["favorite", "capacity", "vs", "0"],
{"x.com.samsung.da.switchCapacity": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="favorite_capacity",
field="x.com.samsung.da.defaultCapacity",
icon="mdi:cup-water",
entity_category="config",
options_field="x.com.samsung.da.capacityList",
write_fn=lambda p, rep, href=None: (
["favorite", "capacity", "vs", "0"],
{"x.com.samsung.da.defaultCapacity": p},
),
),
),
)
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
# for the hot water dispensed alongside brewing, same shape as
# FAVORITE_CAPACITY above.
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favoring
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
still ambiguous:
- href entirely absent from this device -> definitely no clash, the
switchHotwater fallback below may claim the entity.
- href present but an unfetched stub ({}) -> outcome pending, *not* a
confirmed absence. LOCK's own exists_fn optimistically includes itself
through a stub (matching entity.py's default), so returning True here
too would register both descriptors -- as SwitchDescs sharing one key,
with identical unique_ids -- until the next poll resolves it.
- href present and fetched -> the real answer."""
rep = resources.get('/status/lock/vs/0')
still ambiguous: href absent -> True (fallback may claim the entity);
href present but an unfetched stub ({}) -> False (pending, not
confirmed absence -- LOCK's own exists_fn optimistically includes
itself through a stub too, so returning True would register both
descriptors under one key until the next poll); href present and
fetched -> the real answer."""
rep = resources.get("/status/lock/vs/0")
if rep is None:
return True
if not rep:
return False
return 'x.com.samsung.da.hotwaterLock' not in rep
return "x.com.samsung.da.hotwaterLock" not in rep
FAVORITE_HOTWATER = Capability(
href='/favorite/hotwater/vs/0',
poll_tier='cold',
href="/favorite/hotwater/vs/0",
poll_tier="cold",
entities=(
# Despite the resource/field naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
# this href on boards that don't populate /status/lock/vs/0's
# hotwaterLock field. Shares that descriptor's key so only one "Hot
# water lock" entity ever appears.
#
# Both halves of this fallback pair need an exists_fn, not just this
# one: adapter.flatten() (the coordinator.data source every entity's
# is_on reads) only ever honours exists_fn, never entity.py's
# implicit "require own field present" default that gates plain
# registration. Two same-keyed descriptors with only one of them
# gated still both land in flatten()'s output dict -- whichever is
# processed last silently wins, decided by device-reported href
# order, not by which one is actually correct. So this exists_fn
# also re-asserts its own field's presence (switchHotwater), the
# gate a bare `field=` used to get for free before it had to share a
# key with LOCK's descriptor.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
'x.com.samsung.da.switchHotwater' in rep
and _status_lock_definitely_lacks_hotwater_field(resources)),
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
# Issue #196: `supportedList` is only the four *fixed* presets
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
# the user add one custom value to their own display list via its
# "temperatures to display" editor (a wheel picker bounded by
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
# and that custom value shows up in `showList`
# (['40', '50', '75', '85', '90'] here) but never in
# `supportedList`. Reading options from `supportedList` meant a
# unit whose current default *was* that custom value rendered as
# "unknown" -- not a coverage gap, just the wrong field. `showList`
# is a superset of `supportedList` that always includes whatever
# the current default actually is, custom or not.
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.favorite.showList',
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.favorite.defaultTemperature': p})),
# Despite the naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- the same
# hot-water lock as LOCK.hotwater_lock below, surfaced through this
# href on boards that don't populate /status/lock/vs/0's
# hotwaterLock. Shares that descriptor's key so only one "Hot water
# lock" entity appears; both halves need an exists_fn since
# adapter.flatten() only ever honors exists_fn, not entity.py's
# implicit field-presence default -- without it, whichever
# same-keyed descriptor is processed last would silently win.
# No device_class: SwitchDeviceClass only has 'outlet'/'switch',
# not 'lock' -- passing it crashed switch platform setup entirely
# for the whole device (issue #349), same bug KIDS_LOCK_GENERIC
# dodged by switching to BinarySensorDesc (issues #181/#183). This
# entity stays a SwitchDesc since it's genuinely writable.
SwitchDesc(
key="hotwater_lock",
field="x.com.samsung.da.switchHotwater",
icon="mdi:lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: (
"x.com.samsung.da.switchHotwater" in rep
and _status_lock_definitely_lacks_hotwater_field(resources)
),
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
),
),
# Issue #196: `supportedList` is only the four fixed presets -- the
# app also lets the user add one custom value to their own display
# list, which shows up in `showList` but never in `supportedList`.
# Reading from `supportedList` meant a unit whose current default
# was that custom value rendered as "unknown"; `showList` is a
# superset that always includes the actual current default.
SelectDesc(
key="favorite_hotwater_temperature",
field="x.com.samsung.da.favorite.defaultTemperature",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.favorite.showList",
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.favorite.defaultTemperature": p},
),
),
),
)
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
# on this resource, unlike the rest of the water-purifier surface.
COFFEE = Capability(
href='/favorite/coffee/vs/0',
poll_tier='warm',
href="/favorite/coffee/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate',
icon='mdi:coffee-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'coffee', 'vs', '0'],
{'favorite.activate': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='coffee_brew_status', field='brew.status',
icon='mdi:coffee-outline',
entity_category='diagnostic'),
SwitchDesc(
key="favorite_coffee_enabled",
field="favorite.activate",
icon="mdi:coffee-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["favorite", "coffee", "vs", "0"],
{"favorite.activate": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="coffee_brew_status",
field="brew.status",
icon="mdi:coffee-outline",
entity_category="diagnostic",
),
),
)
@@ -221,143 +252,169 @@ COFFEE = Capability(
# the full state domain isn't confirmed, so this stays a plain diagnostic
# sensor rather than an enum with an invented state table.
CUP_STATE = Capability(
href='/cup/state/vs/0',
poll_tier='warm',
href="/cup/state/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cup_state', field='water.cup.state',
icon='mdi:cup-outline', entity_category='diagnostic'),
SensorDesc(
key="cup_state",
field="water.cup.state",
icon="mdi:cup-outline",
entity_category="diagnostic",
),
),
)
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
# air_purifier.py's same-named hrefs, but this board's own values differ
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
# reject a live-supported value, so these read the device's own supported
# list/range like air_purifier's versions do.
# air_purifier.py's same-named hrefs, but this board's own supportedModes
# (voice/fixedTone/mute) differs from both, so these read the device's own
# supported list/range rather than reusing either.
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
poll_tier='cold',
href="/settings/sound/mode/vs/0",
poll_tier="cold",
entities=(
SelectDesc(key='sound_mode', translation_key='water_purifier_sound_mode',
field='mode',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedModes',
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
SelectDesc(
key="sound_mode",
translation_key="water_purifier_sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedModes",
write_fn=lambda p, rep, href=None: (
["settings", "sound", "mode", "vs", "0"],
{"mode": p},
),
),
),
)
SOUND_OUTPUT = Capability(
href='/settings/sound/output/vs/0',
poll_tier='cold',
href="/settings/sound/output/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='sound_output', field='deviceType',
icon='mdi:volume-high', entity_category='diagnostic'),
SensorDesc(
key="sound_output",
field="deviceType",
icon="mdi:volume-high",
entity_category="diagnostic",
),
# No confirmed write contract (no sibling field advertising this as
# user-settable) -- surfaced read-only per the 'don't guess' rule.
BinarySensorDesc(key='alarm_in_mute', field='alarmInMute',
icon='mdi:volume-mute',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true'),
BinarySensorDesc(
key="alarm_in_mute",
field="alarmInMute",
icon="mdi:volume-mute",
entity_category="diagnostic",
value_fn=lambda v: str(v).lower() == "true",
),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
poll_tier='cold',
href="/settings/sound/volume/vs/0",
poll_tier="cold",
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
# which at least has an -- albeit suspect -- capacityUnit) so it's left
# unitless rather than assumed to be mL.
# (no sibling unit field on this resource) so it's left unitless rather
# than assumed to be mL.
STATISTIC_POUR = Capability(
href='/statistic/pour/vs/0',
poll_tier='cold',
href="/statistic/pour/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='last_pour_type', field='last.type',
icon='mdi:cup-water', entity_category='diagnostic'),
SensorDesc(key='last_pour_capacity', field='last.capacity',
icon='mdi:cup-water', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(
key="last_pour_type",
field="last.type",
icon="mdi:cup-water",
entity_category="diagnostic",
),
SensorDesc(
key="last_pour_capacity",
field="last.capacity",
icon="mdi:cup-water",
entity_category="diagnostic",
value_fn=int_or_none,
),
),
)
LOCK = Capability(
href='/status/lock/vs/0',
poll_tier='warm',
href="/status/lock/vs/0",
poll_tier="warm",
entities=(
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
# above (issue #144); see the comment there for why this half also
# needs an explicit exists_fn now that the two share a key in
# adapter.flatten()'s output. A stub rep ({}) still counts as
# "present" here (matches entity.py's own default for a field-less
# gate) since the alternative -- treating an unfetched resource as
# confirmed-absent -- is what let both descriptors register at once.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})),
# above (issue #144); see the comment there. A stub rep ({}) still
# counts as "present" here, matching entity.py's own default. No
# device_class on any of the three locks below -- see the
# device_class note on FAVORITE_HOTWATER's hotwater_lock (issue #349).
SwitchDesc(
key="hotwater_lock",
field="x.com.samsung.da.hotwaterLock",
icon="mdi:lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.hotwaterLock": "Locked" if p == "On" else "Unlocked"},
),
),
SwitchDesc(
key="coldwater_lock",
field="x.com.samsung.da.coldwaterLock",
icon="mdi:lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.coldwaterLock": "Locked" if p == "On" else "Unlocked"},
),
),
SwitchDesc(
key="buzz_lock",
field="x.com.samsung.da.buzzLock",
icon="mdi:lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.buzzLock": "Locked" if p == "On" else "Unlocked"},
),
),
),
)
# ---------------------------------------------------------------------------
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
# confirmed contract, following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_WP_IGNORED = [
# supportedModes carries a single opaque wizard-workflow token
# ('HOMECARE_WIZARD_V2') and modes reports a completely different,
# unrelated value ('WATERFILTER_DISABLE') not even present in
# supportedModes -- internal plumbing, not a real user-facing mode
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
# already covered by the global ignored.IGNORED (fridge's OCF-native
# vacation-mode flag shares that href).
'/mode/vs/0',
# Static support-flags blob (automation.supported.modes/options) -- no
# live "current automation setting" field to expose.
'/automation/waterpurifier/vs/0',
# Coffee-capable variant (issue #107). All four are static
# capability-advertisement blobs or empty -- no live "current recipe" /
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0
# (COFFEE above), which does carry live brew status.
'/brand/recipe/info/vs/0', # revision + max-brand-count metadata
'/coffee/custom/recipe/vs/0', # publisher.support: allowed custom-recipe slot IDs
'/recipe/coffee/vs/0', # same publisher.support shape, no per-recipe content
'/recipe/coffee/deletion/vs/0', # empty {} on this dump
# supportedModes carries a single opaque wizard-workflow token and
# modes reports an unrelated value not even in supportedModes --
# internal plumbing, not a real mode select.
"/mode/vs/0",
# Static support-flags blob -- no live "current setting" field.
"/automation/waterpurifier/vs/0",
# Coffee-capable variant (issue #107): static capability-advertisement
# blobs or empty, unlike /favorite/coffee/vs/0 (COFFEE above) which
# does carry live brew status.
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata
"/coffee/custom/recipe/vs/0", # allowed custom-recipe slot IDs
"/recipe/coffee/vs/0", # same shape, no per-recipe content
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
]
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
@@ -1,26 +1,27 @@
"""A Capability binds one OCF resource href to the entities it produces."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable, Optional
from .entities import SamsungEntityDescription
@dataclass(frozen=True, kw_only=True)
class Capability:
href: Optional[str] = None
href: str | None = None
entities: tuple[SamsungEntityDescription, ...] = ()
poll_tier: str = 'cold' # 'hot' | 'warm' | 'cold'
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
poll_tier: str = "cold" # 'hot' | 'warm' | 'cold'
rt_filter: str | None = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: str | None = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
# Rep field holding this instance's device-given name (e.g. an ice
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
# name prefix in place of the href-derived instance label. Does not
# affect key_override/unique_id -- only what's shown in the UI.
name_field: Optional[str] = None
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
name_field: str | None = None
match_fn: Callable[[dict, dict], bool] | None = None # match_fn(rep, resources) -> bool
# Rare optional hook — only operational-state-style resources use this.
on_observation: Optional[Callable[[dict, dict], None]] = None
project: Optional[Callable[[dict, dict], dict]] = None
on_observation: Callable[[dict, dict], None] | None = None
project: Callable[[dict, dict], dict] | None = None
@@ -11,10 +11,11 @@ doesn't have that filter) is *not* a gap — a maintainer already looked at
that href and decided how to handle it. Only hrefs absent from the registry
entirely are reported.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
@@ -26,24 +27,23 @@ class BoundEntity:
href: str
capability: Capability
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
instance: str = ""
key_override: str | None = None
instance_name: str | None = None
# Which logical indoor subdevice (issue #177) this entity belongs to.
# `href` above is always the *actual*, on-the-wire href for that
# subdevice -- MAIN's
# to_actual is the identity transform, so every device with no subdevices
# behaves exactly as before this field existed.
# `href` above is always the actual, on-the-wire href for that
# subdevice -- MAIN's to_actual is the identity transform, so a device
# with no subdevices is unaffected.
subdevice: Subdevice = MAIN
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to an href-derived key."""
return s.replace('_', ' ').title()
return s.replace("_", " ").title()
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
def _instance_name(cap: Capability, rep: dict) -> str | None:
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
for use as a display-name prefix, or None if the cap doesn't declare
one or the device didn't report it."""
@@ -57,15 +57,20 @@ def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
def instance_suffix(href: str) -> str:
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
tail = href.rstrip('/').rsplit('/', 1)[-1]
if tail.isdigit() and tail != '0':
return f'_{tail}'
return ''
tail = href.rstrip("/").rsplit("/", 1)[-1]
if tail.isdigit() and tail != "0":
return f"_{tail}"
return ""
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None,
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
def _bind(
cap: Capability,
href: str,
inst: str,
inst_name: str | None,
key_prefix: str | None = None,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller.
@@ -74,10 +79,15 @@ def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
actually reads/writes (identity for MAIN, so single-subdevice devices are
unaffected -- see subdevices.py)."""
return [
BoundEntity(href=subdevice.to_actual(href), capability=cap, desc=desc,
instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name, subdevice=subdevice)
BoundEntity(
href=subdevice.to_actual(href),
capability=cap,
desc=desc,
instance=inst,
key_override=f"{key_prefix}_{desc.key}" if key_prefix else None,
instance_name=inst_name,
subdevice=subdevice,
)
for desc in cap.entities
]
@@ -86,35 +96,30 @@ def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability actually
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows.
Callers that need a href's poll cadence (the coordinator's hot/warm
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
coverage-only capability's `poll_tier` would otherwise be silently
dropped since it never appears in `bound`.
"""`tier_log(href, poll_tier)` fires for every href a capability
actually matches, even a no-entity "coverage-only" capability that
`_bind()` turns into zero `BoundEntity` rows. Callers that need a
href's poll cadence must use this, not `bound` -- a coverage-only
capability's `poll_tier` would otherwise never appear in `bound`.
`resources` is always keyed by *canonical* hrefs -- for a subdevice
(issue #177) that means its own canonical view (see
subdevices.canonical_view), the same shape as a plain single-subdevice
device's resources dict, so registry lookups/rt_filter/match_fn/
instance_suffix all behave identically regardless of which subdevice is
being discovered.
`subdevice` only affects the *href* stamped onto each BoundEntity (via
`_bind`, see above) and the href `log`/`tier_log` report -- both the
real, subscribable/pollable path, not the canonical one the registry is
keyed on.
`resources` is always keyed by canonical hrefs -- for a subdevice
(issue #177), its own canonical view (see subdevices.canonical_view),
the same shape as a single-subdevice device's resources dict, so
registry lookups behave identically regardless of which subdevice is
being discovered. `subdevice` only affects the href stamped onto each
BoundEntity and the href `log`/`tier_log` report -- the real,
subscribable/pollable path, not the canonical one.
"""
out: list[BoundEntity] = []
for href, rep in resources.items():
if not isinstance(rep, dict):
continue
rts = rep.get('rt') or ()
rts = rep.get("rt") or ()
caps = registry.get(href) or []
matched = False
@@ -142,10 +147,17 @@ def discover(
continue
inst = instance_suffix(href)
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
subdevice=subdevice))
src = (
href[len(cap.href_prefix) :]
if (cap.strip_prefix_in_key and cap.href_prefix)
else href
)
segs = [s for s in src.strip("/").split("/") if s and not s.isdigit() and s != "vs"]
out.extend(
_bind(
cap, href, inst, _instance_name(cap, rep), "_".join(segs), subdevice=subdevice
)
)
matched = True
if tier_log is not None:
tier_log(subdevice.to_actual(href), cap.poll_tier)
@@ -6,17 +6,20 @@ presence gating in exists_fn; write logic in write_fn on command platforms;
pre-write rejection (surfaced to the user, not just logged) in validate_fn
where a description declares one.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional
from typing import Any
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
# (payload, rep, resources) -> a translation key, or None to allow the
# write. resources is the coordinator's full href->rep snapshot, for the same
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
# option list).
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
DisplayFn = Callable[[Any, dict], Any] | None
def _identity(v: Any) -> Any:
@@ -26,88 +29,102 @@ def _identity(v: Any) -> Any:
@dataclass(frozen=True, kw_only=True)
class SamsungEntityDescription:
key: str
field: str = ''
field: str = ""
# Defaults to `key`: entity names and states live in translations/, never
# here, so a descriptor only sets this to share one catalog entry across
# several descriptors, or to point at a differently-named one.
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot and returns the key to use -- for a descriptor shared across
# board generations whose state-code meaning isn't guaranteed consistent
# between them; see laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Optional[Mapping[str, str]] = None
# callable form receives the full href->rep snapshot and returns the key
# to use -- for a descriptor shared across board generations whose
# state-code meaning isn't consistent between them; see
# laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Mapping[str, str] | None = None
# Dynamic resources such as fridge compartments and ice makers use a
# device-provided or href-derived instance label inside a translated name.
use_instance_name: bool = False
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
icon: str | None = None
entity_category: str | None = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
value_fn: Callable[[Any], Any] = _identity
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
rep_fn: Callable[[dict], Any] | None = None # replaces field+value_fn; receives full rep
# (rep, resources): rep is this entity's own href's representation;
# resources is the coordinator's full href->rep snapshot, for gating
# presence on a sibling resource (e.g. laundry.cycle_options's source).
exists_fn: Optional[Callable[[dict, dict], bool]] = None
exists_fn: Callable[[dict, dict], bool] | None = None
@dataclass(frozen=True, kw_only=True)
class SensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None
state_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
# device-side revisions -- not a general-purpose flag every sensor should set.
device_class: str | None = None
state_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
options: tuple | None = None # required by HA when device_class == 'enum'
# Opt-in: gate this value behind CONF_FINISH_TIME_HYSTERESIS_MINUTES
# (see sensor.py). Only for values expected to jitter between
# device-side revisions -- not a general-purpose flag.
hysteresis: bool = False
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
# for the full contract (arm/value/bypass semantics, one window per
# bypass, why this never touches the coordinator cache).
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
# moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
# hold and lets rep_fn's own live result through; sticky_seconds
# bounds how long it can hold. There is deliberately no hook for
# computing a live value differently from rep_fn -- see issue #358.
sticky_fn: Callable[[dict], bool] | None = None
sticky_value_fn: Callable[[dict], Any] | None = None
sticky_bypass_fn: Callable[[dict], bool] | None = None
sticky_seconds: float = 300.0
@dataclass(frozen=True, kw_only=True)
class BinarySensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None # value_fn must return bool
device_class: str | None = None # value_fn must return bool
@dataclass(frozen=True, kw_only=True)
class SelectDesc(SamsungEntityDescription):
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot (not just this entity's own href) and returns raw device
# option values; see select.py's LocalThingsSelect._raw_options().
options_field: Optional[str] = None # resource field that contains the live options list
options_field: str | None = None # resource field that contains the live options list
# Optional device-specific fallback for values absent from the translation
# catalog. Receives (raw_value, canonical_resources); select.py applies it
# identically to the current state and every option.
display_fn: DisplayFn = None
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class SwitchDesc(SamsungEntityDescription):
device_class: Optional[str] = None
device_class: str | None = None
write_fn: WriteFn = None
validate_fn: ValidateFn = None
@dataclass(frozen=True, kw_only=True)
class ButtonDesc(SamsungEntityDescription):
payload: str = ''
payload: str = ""
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class NumberDesc(SamsungEntityDescription):
device_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
native_min: Optional[float] = None
native_max: Optional[float] = None
step: Optional[float] = None
# Override native_min/native_max/step from the live rep, when set --
# same "static default, live override" shape as unit_fn, for resources
# whose sane bounds depend on a per-device value (e.g. a temperature
# setpoint reported in Celsius on one device, Fahrenheit on another).
native_min_fn: Optional[Callable[[dict], float]] = None
native_max_fn: Optional[Callable[[dict], float]] = None
step_fn: Optional[Callable[[dict], float]] = None
range_field: Optional[str] = None # resource field containing [min, max] list
device_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
native_min: float | None = None
native_max: float | None = None
step: float | None = None
# Override native_min/max/step from the live rep, when set -- same
# "static default, live override" shape as unit_fn, for resources whose
# bounds depend on a per-device value (e.g. Celsius vs. Fahrenheit).
native_min_fn: Callable[[dict], float] | None = None
native_max_fn: Callable[[dict], float] | None = None
step_fn: Callable[[dict], float] | None = None
range_field: str | None = None # resource field containing [min, max] list
write_fn: WriteFn = None
@@ -118,29 +135,36 @@ class TimeDesc(SamsungEntityDescription):
@dataclass(frozen=True, kw_only=True)
class ClimateDesc(SamsungEntityDescription):
# A composite entity: it binds one *primary* resource (its href) but the
# climate platform reads sibling resources (power, temperature, wind) from
# the coordinator snapshot and writes to several of them. write_fn takes a
# (kind, value) payload from the platform and returns the (path_segs, body)
# for that one sub-write, so a single desc drives multi-resource writes.
# Composite entity: binds one primary resource (its href) but the
# climate platform reads sibling resources from the coordinator
# snapshot and writes to several of them. write_fn takes a (kind,
# value) payload and returns the (path_segs, body) for that sub-write.
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class FanDesc(SamsungEntityDescription):
# Composite fan entity: reads power from /power/0 and speed/support data
# from its bound href. Payloads are (kind, value), like ClimateDesc.
# from its bound href. Payloads are (kind, value), like ClimateDesc.
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class WaterHeaterDesc(SamsungEntityDescription):
# Composite water_heater entity, same (kind, value) -> (path_segs,
# body) write_fn shape as ClimateDesc/FanDesc.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
SelectDesc: 'select',
SwitchDesc: 'switch',
ButtonDesc: 'button',
NumberDesc: 'number',
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
SensorDesc: "sensor",
BinarySensorDesc: "binary_sensor",
SelectDesc: "select",
SwitchDesc: "switch",
ButtonDesc: "button",
NumberDesc: "number",
TimeDesc: "time",
ClimateDesc: "climate",
FanDesc: "fan",
WaterHeaterDesc: "water_heater",
}
@@ -1,8 +1,8 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import cbor2
@@ -12,9 +12,137 @@ class DeviceIdentity:
manufacturer: str
model: str
name: str
serial: Optional[str]
serial: str | None
# /oic/d's `di` and /oic/p's `pi` -- OCF's own device and platform
# UUIDs. Promoted out of `raw` into named fields because
# resolve_device_key mints permanent registry keys from them; see its
# docstring for why `di` leads.
device_id: str | None = None
platform_id: str | None = None
device_types: tuple[str, ...] = ()
raw: dict[str, dict] = field(default_factory=dict)
raw: dict[str, dict | list] = field(default_factory=dict)
def is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
check doesn't catch it, and two such units on the same install silently
collide, dropping the second one's entities (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry family reports a
flash-unset sentinel instead -- every character the same repeated hex
digit -- which the 'nothing' check doesn't catch either, aborting the
second unit's config flow as already configured.
Lives here rather than duplicated in config_flow.py/coordinator.py: the
config flow resolves the serial once and persists it for the
coordinator to seed its registry keys from (issue #236), so two copies
of this rule could let the two sides disagree and orphan a registry
entry.
"""
s = serial.strip()
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
def resolve_serial(raw_serial: str | None, host: str) -> str:
"""The device identity to mint registry keys from.
`raw_serial` is /information/vs/0's x.com.samsung.da.serialNum as the
device reported it. Boards that report nothing usable fall back to the
host, which is stable per install and unique across devices on one
network -- see is_placeholder_serial for the two families that need it.
"""
s = (raw_serial or "").strip()
if not s or is_placeholder_serial(s):
return host
return s
def is_usable_device_id(value: str | None) -> bool:
"""True for an OCF `di`/`pi` that actually identifies one unit.
Rejects OCF's nil UUID, which firmware that never had one assigned
reports on every unit of the family -- the #189 failure mode on a new
field, and one `is_placeholder_serial`'s repeated-digit rule misses
because the dashes make more than one distinct character. Past that the
same known-junk rules apply: a board flashed with 'Nothing(SVC)' in one
identity field is not one to trust in another.
"""
s = (value or "").strip()
if not s:
return False
if not set(s) - {"0", "-"}:
return False
return not is_placeholder_serial(s)
def ocf_device_key(identity: DeviceIdentity | None) -> str | None:
"""The OCF-derived half of resolve_device_key's chain, or None when the
device reported no usable UUID.
Split out because "no UUID" and "this UUID" are different answers to a
caller holding an existing key: the coordinator must never demote an
entry off its UUID just because one poll couldn't read /oic/d.
Normalized so firmware that changes case between reads doesn't look
like a different appliance.
"""
if identity is None:
return None
for candidate in (identity.device_id, identity.platform_id):
if candidate is not None and is_usable_device_id(candidate):
return candidate.strip().lower()
return None
def resolve_device_key(identity: DeviceIdentity | None, raw_serial: str | None, host: str) -> str:
"""The identity to mint this device's permanent registry keys from.
Tried in order: /oic/d's `di`, /oic/p's `pi`, the serialNum, the host.
`di` leads because it is what the protocol already uses to address this
endpoint: if it were wrong or shared, OCF discovery and the DTLS
association would not work at all. serialNum is a vendor-populated
string nothing depends on, which is why three firmware families have
shipped it unusable -- 'Nothing(SVC)' (#83), a flash-unset sentinel
(#189), and a well-formed serial duplicated across every unit (#381).
`pi` is only the fallback despite the spec calling it immutable: it is
*platform*-scoped, so a board hosting several logical OCF devices
shares one across all of them. `di` is device-scoped, the granularity
of a config entry. The serial and host stay below both so a board
answering neither resource lands where it always did.
"""
return ocf_device_key(identity) or resolve_serial(raw_serial, host)
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
"""The model string to name and register a device under.
`model_num` is /information/vs/0's modelNum, which many boards report
as `<model>|<board>` -- only the part before the pipe is recognizable.
A board reporting no modelNum falls back to /oic/p's mnmo. Shared with
resolve_serial's motivation: two copies of this split rule could let
the config flow and the coordinator's post-poll recompute disagree, and
a device renaming itself after the first poll is the visible symptom.
"""
if model_num:
return model_num.split("|", 1)[0]
return identity.model if identity else ""
def device_display_name(device_type_name: str | None, model: str) -> str:
"""The HA device name for a resolved device type + model. Shared by the
config flow and the coordinator's post-discovery rebuild, so the name a
device is first registered under matches what discovery produces later
-- otherwise every setup would rename the device once the first poll
landed."""
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
def _get(sess, path) -> dict:
@@ -45,17 +173,15 @@ def _get_links(sess, path) -> list:
def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
In OCF this is the one standardized "what am I" field: alongside the
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
now consults this first, ahead of board-part-number parsing, via
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a
minority of dumps populate it, so the modelNum/description path stays
load-bearing for everything else. It's also kept whole in diagnostics
(see `raw` below) so incoming issue reports keep surfacing types that
table doesn't know about yet.
The one standardized "what am I" field in OCF: alongside the generic
'oic.wk.d' it carries a concrete type like 'oic.d.airconditioner' or a
SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
consults this first, via `for_device_by_oic_type`, but only a minority
of dumps populate it, so the modelNum/description path stays
load-bearing. Kept whole in diagnostics (see `raw` below) so issue
reports keep surfacing types the table doesn't know about yet.
"""
rt = d.get('rt')
rt = d.get("rt")
if isinstance(rt, str):
rt = [rt]
if not isinstance(rt, (list, tuple)):
@@ -63,30 +189,28 @@ def _device_types(d: dict) -> tuple[str, ...]:
return tuple(t for t in rt if isinstance(t, str))
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
def read_identity(sess, serial: str | None) -> DeviceIdentity:
p = _get(sess, ["oic", "p"])
d = _get(sess, ["oic", "d"])
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
# RETRIEVE on it returns every Resource/Collection href this endpoint
# hosts, not just the one /device/0 seed path the coordinator polls.
# Relevant for the OCF "Composite Device" model (issue #177: a single
# physical device -- one IP, one /oic/p -- exposing more than one logical
# subdevice, each as its own Collection resource, same rt shape as our own
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
# /device/2 speculative fallback it used to run right here on every
# _connect_session (including every reconnect), moved to that module so
# it only runs once, at first discovery, instead of on every reconnect.
res = _get_links(sess, ['oic', 'res'])
# RETRIEVE returns every Resource/Collection href this endpoint hosts,
# not just /device/0. Relevant for the "Composite Device" model (issue
# #177: one physical device exposing more than one logical subdevice,
# each its own Collection). registry.subdevices.enumerate_subdevices
# reads this to find a board's `/device/<n>` siblings -- that probing
# used to run right here on every _connect_session/reconnect and moved
# to that module so it only runs once, at first discovery.
res = _get_links(sess, ["oic", "res"])
return DeviceIdentity(
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
name=d.get('n') or '',
manufacturer=p.get("mnmn") or "Samsung",
model=p.get("mnmo") or "",
name=d.get("n") or "",
serial=serial,
device_id=d.get("di") if isinstance(d.get("di"), str) else None,
platform_id=p.get("pi") if isinstance(p.get("pi"), str) else None,
device_types=_device_types(d),
# Kept whole rather than field-by-field: these resources are outside
# the /device/0 dump diagnostics already captures, and we don't yet
# know which of their fields will turn out to identify a device type.
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res},
# Kept whole rather than field-by-field: outside the /device/0 dump
# diagnostics already captures, and we don't yet know which fields
# will turn out to identify a device type.
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
)
@@ -10,29 +10,38 @@ under — new device types will have unknown-shaped data we can't fully
enumerate in advance, so this errs on catching the field by name rather
than only redacting inside hrefs we already recognize.
"""
from __future__ import annotations
REDACTED = "**REDACTED**"
_SENSITIVE_SUBSTRINGS = (
'mac', 'serial', 'token', 'login', 'account', 'email',
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
"mac",
"serial",
"token",
"login",
"account",
"email",
"userid",
"deviceid",
"uuid",
"duid",
"password",
"secret",
)
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
# with bare one- and two-letter keys that the rules above cannot see, being
# far too short to match on -- 'di' alone is a substring of 'condition',
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
# Matched whole, not as substrings: these are bare one/two-letter keys too
# short for the substring rules above ('n' is a substring of very nearly
# everything). 'n' is /oic/d's free-text device name, which the owner sets
# from the SmartThings app and can carry a person's name -- the device-type
# signal we actually want from that resource is `rt`, which is not redacted.
#
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
# number above.
# 'n' -- /oic/d's device name. Free text the owner can set from the
# SmartThings app, so it may well 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, and the
# device-type signal we actually want from that resource is `rt`,
# which is not redacted.
_SENSITIVE_EXACT = frozenset({'di', 'pi', 'n'})
# /oic/d's `di` and /oic/p's `pi` are deliberately not redacted: they're
# randomly-assigned per-unit UUIDs rather than account data, and they are
# what registry keys are minted from (issue #381), so blanking them hides
# the identity every entity in a report is named after -- which is exactly
# what made #381's first diagnostics download unable to answer it.
_SENSITIVE_EXACT = frozenset({"n"})
def _is_sensitive_key(key: str) -> bool:
@@ -7,6 +7,7 @@ Raises ValueError at import if any href group contains an unfiltered cap
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
in a group with multiple caps).
"""
from .capabilities import ALL
from .capability import Capability
@@ -32,8 +33,7 @@ def _build() -> dict[str, list[Capability]]:
# Validate: every group with >1 cap must have all caps filtered
for href, caps in out.items():
if len(caps) > 1:
unfiltered = [c for c in caps
if c.rt_filter is None and c.match_fn is None]
unfiltered = [c for c in caps if c.rt_filter is None and c.match_fn is None]
if unfiltered:
raise ValueError(
f"duplicate capability href {href!r} has unfiltered cap(s); "
@@ -1,101 +1,63 @@
"""Subdevice ("composite device") support for one physical connection exposing
more than one logical indoor subdevice -- issue #177.
"""Subdevice ("composite device") support for one physical connection
exposing more than one logical indoor subdevice -- issue #177.
Two reporters, two different board families, two genuinely different
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
session (see DESIGN-177.md section 1 for the full evidence trail; the two
diagnostics dumps this was built against come from the Pattern A and
Pattern B reporters, respectively -- they each filed one of the two
reports this module unifies):
Three discovery patterns, unified by the same shape: a logical subdevice is
a seed collection path to poll, plus an href transform between the
canonical href the registry knows (e.g. `/mode/vs/0`) and the actual
on-the-wire href.
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's
board). `/oic/res` lists three complete parallel resource sets whose
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`,
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s
batch carries only the index-0 hrefs -- the sibling subdevices are
reachable only via their own `/device/<n>` collection.
- **Pattern A -- indexed siblings** (`ARTIK051_DONGLE_FAC_18K`). `/oic/res`
lists parallel resource sets by trailing index (`/mode/vs/0`,
`/mode/vs/1`, ...); each sibling has its own `/device/<n>` Collection.
- **Pattern B -- UUID-prefixed tree** (`TP2X_FAC_BORA_21K`). `/oic/res`
hides the tree; `/subdevices/vs/0`'s `subdeviceIdList` gives the UUID.
`GET /<uuid>/device/0` is tried first; when it comes back empty (issue
#205 -- not even the reference board always exposes it), this falls back
to probing every href the master answered this cycle individually under
the UUID prefix (see `Subdevice.flat_hrefs`).
- **Pattern C -- UUID prefix via `/oic/res` only** (`AWM-WW-AID-26-ONEBODY`
washer+dryer combo, issue #241). No `subdeviceIdList`, `/device/<n>`
404s; the sibling's UUID only appears as a link prefix in `/oic/res`
(e.g. the `x.com.samsung.da.multidevice` link) -- treated as Pattern B's
transform with the UUID sourced from there instead.
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter's board).
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
same UUID appears as a literal href prefix in `/oic/res`
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
that reporter's unit is narrower than early issue #177 writeups suggested: a
single individual `GET /<uuid>/information/vs/0` was read by hand through
the debug panel and came back carrying a different model/serial than the
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
second subdevice exists at that prefix, but not evidence that `GET
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
around) itself returns anything. Issue #205, the same unit on a later
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
So `enumerate_subdevices` tries it first (a future board might genuinely
expose it) and falls back, when it's empty, to probing every href the
master itself answered this cycle individually under the UUID prefix --
the only thing ever actually confirmed to work for this pattern -- on the
assumption that a composite device's siblings share the master's resource
surface. See `Subdevice.flat_hrefs`.
Both are "the same thing wearing different clothes": a logical subdevice is a
seed collection path to poll, plus an href transform between the canonical
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
detection signals don't overlap on either captured board (the Pattern A
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
no `/device/1`), so no disambiguation logic is needed --
`enumerate_subdevices` checks both and materializes any candidate whose
seed answers with a non-empty batch.
A non-empty seed batch is necessary but not sufficient for the *candidate*
to actually be a live second subdevice, though: the Pattern A reporter's
own board also has a `/device/2` -- a third, unused SmartThings slot --
that answers with the exact same 14-href shape as the real `/device/1`
sibling, populated with three constant/echoed/shape-only reps (a region
code identical to every other subdevice's, an /information rep echoing the
*same* model string as subdevice 1, and a /temperatures items[] entry with
an id/description but no current/desired/minimum/maximum reading) and
nothing resembling live climate state. Gating on *resource* shape/hrefs
turned out to be the wrong layer -- it would need per-family domain
knowledge (which hrefs mean "in use" for a washer's second drum, a
fridge's second compartment, ...) baked into a registry field before any
of those families could use this module at all. `discover_partitioned`
instead gates at the *entity* layer, after discovery+flattening: a
candidate is only kept if it produced at least one *primary* (no
`entity_category`), non-meter bound entity whose flattened value isn't
`None` -- e.g. the Pattern A reporter's /device/2 does flatten to an
`alarm_code` value, but that entity is diagnostic-category and derived from
an empty /alarms/vs/2, so it doesn't count. This reuses the same
primary/config/diagnostic taxonomy every registry already declares (see the
adding-device-support skill's entity-taxonomy section) instead of adding a
second, parallel domain-knowledge mechanism.
The meter carve-out is issue #214, and it's the same "an unused slot still
answers *something*" problem one layer further in: that reporter's
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
all empty {} -- the /device/2 shape above -- but which also reports a
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
that materialized the slot as a phantom second air conditioner. See
A non-empty seed batch is necessary but not sufficient for a candidate to
be a real second subdevice: an unused SmartThings slot (e.g. the Pattern A
reporter's own `/device/2`) answers the same shape with constant/echoed
reps and no live state. Gating on resource shape would need per-family
domain knowledge, so `discover_partitioned` instead gates at the *entity*
layer: a candidate is only materialized if it produces at least one live,
non-`None`, primary (no `entity_category`), non-meter bound entity. The
meter exclusion (issue #214) covers a second failure mode: an unused slot
reporting a populated whole-appliance energy counter, which is the
appliance's own bookkeeping, not evidence of a second indoor unit -- see
`_has_live_primary_entity`.
"""
from __future__ import annotations
import re
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Callable, Optional, Sequence
import cbor2
from .batch import parse_device0_batch
from .by_type._base import DeviceRegistry
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
_INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$")
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
# discovery signal (issue #241).
_UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/")
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical subdevice's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
# including every reconnect, for information enumeration only needs once).
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
# guess the write-contract 'don't guess' rule is about. Widen only if a real
# board ever turns out to need more than two siblings.
# second subdevice's Collection (moved here from identity.py, issue #177,
# since the old read_identity fired these on every _connect_session
# including reconnects, when enumeration only needs to run once). A plain
# tolerated-404 RETRIEVE, not the kind of guess the write-contract
# 'don't guess' rule is about. Widen only if a board needs more siblings.
_SPECULATIVE_DEVICE_INDICES = (1, 2)
@@ -104,116 +66,110 @@ class Subdevice:
"""One logical indoor subdevice reachable over a single physical
connection.
`kind='main'` is the subdevice this config entry actually connects to and
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
identity transform, so every existing single-subdevice device keeps
behaving exactly as it did before this module existed. `'indexed'`/
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
Collection href (as path segments) whose batch response
enumerates/refreshes that subdevice.
`kind='main'` is the subdevice this config entry actually connects to
and always exists (see MAIN below) -- its `to_actual`/`to_canonical`
are the identity transform, so a single-subdevice device behaves
exactly as before this module existed. `'indexed'`/`'prefixed'` are
Pattern A/B above; `key` is the trailing index string ('1', '2', ...)
or the full subdevice UUID, and `seed_path` is the Collection href (as
path segments) whose batch enumerates/refreshes that subdevice.
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
expose its own Collection at `seed_path` (issue #205 -- not even
TP2X_FAC_BORA_21K, the board this pattern was built against, always
does). When set, `seed_path` is meaningless (left as `()`) and this
subdevice's state comes from GETting each of these canonical hrefs
individually under its prefix instead of one Collection batch -- see
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
`flat_hrefs` is non-empty only for a 'prefixed' subdevice with no
Collection at `seed_path` (issue #205). When set, `seed_path` is
meaningless (left as `()`) and this subdevice's state comes from
GETting each of these canonical hrefs individually under its prefix
instead -- see enumerate_subdevices' fallback and
coordinator._poll_subdevice_seed.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
seed_path: tuple[str, ...]
flat_hrefs: tuple[str, ...] = ()
def to_actual(self, canonical: str) -> str:
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
on-the-wire href for this subdevice."""
if self.kind == 'indexed':
head, sep, tail = canonical.rpartition('/')
# Only the index-0 trailing segment is ours to rewrite --
# deliberately not a "replace any trailing digit" rule, which
# would misread a genuine multi-instance resource (the fridge's
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
# registry declares a non-zero trailing index today and no
# fixture in the corpus contains one (verified across the whole
# corpus), so the strict rule costs nothing.
if tail == '0':
return f'{head}{sep}{self.key}'
if self.kind == "indexed":
head, sep, tail = canonical.rpartition("/")
# Only the index-0 trailing segment is ours to rewrite -- not a
# "replace any trailing digit" rule, which would misread a
# genuine multi-instance resource (e.g. the fridge's
# '/door/vs/1') as a subdevice's. No registry declares a
# non-zero trailing index today.
if tail == "0":
return f"{head}{sep}{self.key}"
return canonical
if self.kind == 'prefixed':
return f'/{self.key}{canonical}'
if self.kind == "prefixed":
return f"/{self.key}{canonical}"
return canonical
def to_canonical(self, actual: str) -> Optional[str]:
def to_canonical(self, actual: str) -> str | None:
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
if self.kind == 'indexed':
head, sep, tail = actual.rpartition('/')
if self.kind == "indexed":
head, sep, tail = actual.rpartition("/")
if tail == self.key:
return f'{head}{sep}0'
return f"{head}{sep}0"
return None
if self.kind == 'prefixed':
prefix = f'/{self.key}'
if actual.startswith(prefix + '/'):
return actual[len(prefix):]
if self.kind == "prefixed":
prefix = f"/{self.key}"
if actual.startswith(prefix + "/"):
return actual[len(prefix) :]
return None
return actual
def owns(self, actual: str) -> bool:
"""True if `actual` belongs to this subdevice's namespace. MAIN never
"owns" anything by this definition -- it gets whatever's left after
every other subdevice's hrefs are excluded (see canonical_view)."""
if self.kind == 'main':
"""True if `actual` belongs to this subdevice's namespace. MAIN
never "owns" anything by this definition -- it gets whatever's
left after every other subdevice's hrefs are excluded (see
canonical_view)."""
if self.kind == "main":
return False
return self.to_canonical(actual) is not None
@property
def key_prefix(self) -> str:
"""Prefix that guarantees a unique entity key/unique_id (see
adapter._key). '' for MAIN -- the master's flattened state
keys must stay byte-identical to every device this integration
shipped before issue #177, so no golden file changes. The full
subdevice UUID is used verbatim (non-alphanumerics stripped, not
truncated or replaced with an ordinal) because it's device-reported
and stable across reconnects/restarts, unlike an ordinal assigned by
enumeration order -- and it never appears in a user-visible string
(see DESIGN-177.md section 6): HA derives the visible entity_id from
the device name + entity name, not from unique_id.
"""Prefix guaranteeing a unique entity key/unique_id (see
adapter._key). '' for MAIN, so the master's flattened state keys
stay byte-identical to every device shipped before issue #177. The
full subdevice UUID is used verbatim (non-alphanumerics stripped)
rather than an enumeration-order ordinal, since it's device-reported
and stable across reconnects; it never appears in a user-visible
string, since HA derives entity_id from device+entity name, not
unique_id.
"""
if self.kind == 'indexed':
return f'subdevice{self.key}_'
if self.kind == 'prefixed':
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
return f'subdevice_{slug}_'
return ''
if self.kind == "indexed":
return f"subdevice{self.key}_"
if self.kind == "prefixed":
slug = re.sub(r"[^a-zA-Z0-9]", "", self.key)
return f"subdevice_{slug}_"
return ""
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
MAIN = Subdevice(kind="main", key="", seed_path=("device", "0"))
def canonical_view(
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
subdevice: Subdevice,
resources: dict[str, dict],
subdevices: list[Subdevice],
) -> dict[str, dict]:
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
and friends are written against.
For MAIN this is the snapshot *minus* every href owned by one of the
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1`
would leak into the master's view under the same canonical key
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
silently mixing two subdevices' state together. For an indexed/prefixed
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
back through `to_canonical`.
For MAIN this is the snapshot minus every href owned by one of the
other subdevices in `subdevices` -- otherwise a sibling's own
`/mode/vs/1` would leak into the master's view under the canonical key
('/mode/vs/0') the master's own resource also maps to. For an
indexed/prefixed subdevice it's the reverse: only the hrefs that
subdevice owns, rewritten back through `to_canonical`.
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
False, so including it is harmless.
`subdevices` may or may not include MAIN itself -- MAIN.owns() is
always False, so including it is harmless.
"""
if subdevice.kind == 'main':
owned_elsewhere = {
href for href in resources
if any(su.owns(href) for su in subdevices)
}
if subdevice.kind == "main":
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
return {
canon: resources[actual]
@@ -227,17 +183,15 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
normalized so every href actually carries this subdevice's prefix/index.
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
href in its own `/device/<n>` batch (confirmed against the Pattern A
reporter's dump). A prefixed subdevice's batch entries may or may not
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
reporter's board was never probed live before the subdevice id was
known), so it's added when missing.
href in its own `/device/<n>` batch. A prefixed subdevice's batch
entries may or may not already carry the `/<id>` prefix (unconfirmed),
so it's added when missing.
"""
if subdevice.kind != 'prefixed':
if subdevice.kind != "prefixed":
return batch
prefix = f'/{subdevice.key}'
prefix = f"/{subdevice.key}"
return {
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
(href if href.startswith(prefix + "/") else f"{prefix}{href}"): rep
for href, rep in batch.items()
}
@@ -247,18 +201,17 @@ def _iter_oic_res_hrefs(oic_res):
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
-- see identity.py's read_identity/_get_links), so that's the shape
handled here. Tolerant of a flat link-list too (nothing in the OCF spec
rules it out, and _get_links' own posture already treats any list-shaped
body as possible) and of anything else by yielding nothing.
handled here. Tolerant of a flat link-list too, and of anything else by
yielding nothing.
"""
for entry in (oic_res or []):
for entry in oic_res or []:
if not isinstance(entry, dict):
continue
if 'links' in entry:
for link in entry.get('links') or []:
if "links" in entry:
for link in entry.get("links") or []:
if isinstance(link, dict):
yield link
elif 'href' in entry:
elif "href" in entry:
yield entry
@@ -266,16 +219,15 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
"""('device', '1') -> '/device/1' -- the leading-slash href form
`probe_log` and diagnostics report, built from the path-segment form
`sess.get` takes."""
return '/' + '/'.join(path_segs)
return "/" + "/".join(path_segs)
def _get_raw(sess, path_segs: tuple[str, ...]):
def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0):
"""GET `path_segs` and CBOR-decode the payload, or None on any
missing/malformed response (a 4.04, a timeout, an empty payload) --
shared tolerated-absence posture for both callers below, which differ
only in which body shape they accept."""
shared tolerated-absence posture for both callers below."""
try:
code, pl = sess.get(list(path_segs), timeout=10.0)
code, pl = sess.get(list(path_segs), timeout=timeout)
if code == 0x45 and pl:
return cbor2.loads(pl)
except Exception:
@@ -283,21 +235,29 @@ def _get_raw(sess, path_segs: tuple[str, ...]):
return None
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
def _get_batch(
sess,
path_segs: tuple[str, ...],
timeout: float = 10.0,
) -> dict[str, dict]:
"""GET a Samsung Collection resource and parse it the same way
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
{href, rep}, ...] CBOR list, not a bare Property map."""
body = _get_raw(sess, path_segs)
body = _get_raw(sess, path_segs, timeout)
return parse_device0_batch(body) if isinstance(body, list) else {}
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
def _get_property(
sess,
path_segs: tuple[str, ...],
timeout: float = 10.0,
) -> dict:
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
`/oic/res` on the Pattern A reporter's board but absent from
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
single Property map, not a [devcol-rep, ...] list."""
body = _get_raw(sess, path_segs)
body = _get_raw(sess, path_segs, timeout)
return body if isinstance(body, dict) else {}
@@ -305,120 +265,203 @@ def enumerate_subdevices(
sess,
resources: dict[str, dict],
oic_res_links,
probe_log: Optional[Callable[[str, bool], None]] = None,
) -> tuple[list['Subdevice'], dict[str, dict]]:
probe_log: Callable[[str, bool], None] | None = None,
*,
preferred_hrefs: Sequence[str] = (),
time_budget: float | None = None,
collection_timeout: float = 10.0,
property_timeout: float = 10.0,
) -> tuple[list[Subdevice], dict[str, dict]]:
"""Discover every sibling indoor subdevice reachable over `sess`'s
connection.
Runs once, at first discovery, in an executor, under the coordinator's
session lock -- every GET here is a plain RETRIEVE (the write-contract
'don't guess' rule doesn't apply to reading an extra resource to find
out whether it's there). Returns the *candidate* subdevices and the resources
already fetched while probing them (already normalized to real hrefs),
so the coordinator's first discovery poll doesn't need to re-poll them.
session lock -- every GET here is a plain RETRIEVE. Returns the
*candidate* subdevices and the resources already fetched while probing
them (normalized to real hrefs), so the coordinator's first discovery
poll doesn't need to re-poll them.
`probe_log(seed_href, found)` fires for every seed attempted, whether or
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
can tell "checked, nothing there" apart from "never checked", the same
posture the speculative-probe code this replaces used to document in
identity.py.
`probe_log(seed_href, found)` fires for every seed attempted, whether
or not it answered, so diagnostics can tell "checked, nothing there"
apart from "never checked".
`preferred_hrefs` only changes the order of the flat Property fallback;
it never filters the device's resource surface. When `time_budget` is
supplied, probes are bounded by one shared monotonic deadline and this
returns every candidate/resource confirmed before it. This makes first
setup finite even when firmware silently drops unknown paths instead of
returning 4.04.
Every candidate whose seed answers with a non-empty batch is returned
here -- this function has no way to tell a real sibling from an unused
SmartThings slot that merely answers the same shape (the Pattern A
reporter's `/device/2`); that requires discovering+flattening the
candidate's own entities first, which is `discover_partitioned`'s job,
not this one's.
See this module's docstring.
here -- this function can't tell a real sibling from an unused
SmartThings slot that answers the same shape; that requires
discovering+flattening the candidate's own entities first, which is
`discover_partitioned`'s job. See this module's docstring.
"""
subdevices: list[Subdevice] = []
fetched: dict[str, dict] = {}
# Case-insensitive -- the same UUID can reach here once from
# subdeviceIdList and once from an /oic/res link prefix with different
# casing, and probing it twice would materialize the same physical
# subdevice as two Subdevice candidates.
probed_ids: set[str] = set()
deadline = time.monotonic() + max(0.0, time_budget) if time_budget is not None else None
budget_exhausted = False
def _next_timeout(maximum: float) -> float | None:
"""Clamp one probe to the remaining enumeration wall-clock budget."""
nonlocal budget_exhausted
if deadline is None:
return maximum
remaining = deadline - time.monotonic()
if remaining <= 0:
budget_exhausted = True
return None
return min(maximum, remaining)
def _flat_probe_hrefs():
"""Preferred live-state hrefs first, then every remaining master href."""
seen = set()
for href in preferred_hrefs:
if href in resources and href not in seen:
seen.add(href)
yield href
for href in sorted(resources):
if href not in seen:
yield href
def _probed(seed_href: str, batch: dict) -> None:
if probe_log is not None:
probe_log(seed_href, bool(batch))
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
# crash -- issue #177 is additive, it must never break an already-working
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
seed = (sub_id, 'device', '0')
batch = _get_batch(sess, seed)
def _probe_prefixed(sub_id: str) -> None:
"""Materialize one UUID-prefixed subdevice candidate -- shared by
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
/oic/res link prefixes) below, which differ only in where the UUID
came from."""
if sub_id.lower() in probed_ids:
return
probed_ids.add(sub_id.lower())
seed = (sub_id, "device", "0")
timeout = _next_timeout(collection_timeout)
if timeout is None:
return
batch = _get_batch(sess, seed, timeout)
_probed(_seed_href(seed), batch)
if batch:
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
continue
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
# pattern was built against -- turns out not to always expose its own
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
# has one" doesn't hold even on the reference hardware. With no
# Collection to seed from and no per-UUID entry in `/oic/res` to
return
# Fallback (issue #205): even the reference TP2X_FAC_BORA_21K board
# doesn't always expose its own `/<uuid>/device/0` Collection. With
# no Collection to seed from and no per-UUID entry in /oic/res to
# enumerate hrefs from, the only signal left is that a composite
# device's siblings are the same physical board family as the
# subdevice this config entry already talks to -- so probe every
# href the master itself answered this cycle, individually, under
# this UUID's prefix, and keep whichever ones answer. Each is a
# plain tolerated-404 RETRIEVE, same posture as every other probe in
# this function.
# this UUID's prefix, and keep whichever ones answer before the
# optional enumeration deadline. Each is a plain tolerated-404
# RETRIEVE, same posture as every other probe in this function.
#
# Known gap, not yet guarded against: a firmware that answers *any*
# request under an unrecognized prefix (echoing the master's own
# state back rather than 4.04ing) would pass every one of these
# probes and, if the echoed state also clears discover_partitioned's
# liveness gate, materialize a phantom duplicate of the master
# rather than a real sibling. Every board seen so far genuinely
# 4.04s on paths it doesn't own (issue #205's own unit answered only
# 1 of 31 probes), so this hasn't been built -- the one place it
# could hook in later is comparing a candidate's confirmed reps
# against the master's own values for those same canonical hrefs.
# Known gap: a firmware that echoes the master's own state back
# under an unrecognized prefix, rather than 4.04ing, would pass
# every probe here and could materialize a phantom duplicate. Every
# board seen so far genuinely 4.04s on paths it doesn't own (issue
# #205's unit answered only 1 of 31 probes), so this hasn't been
# guarded against -- the fix would compare a candidate's confirmed
# reps against the master's own values for the same hrefs.
flat_hrefs = []
first = True
for href in sorted(resources):
for href in _flat_probe_hrefs():
if not first:
sess.pace()
first = False
actual = f'/{sub_id}{href}'
rep = _get_property(sess, tuple(actual.strip('/').split('/')))
_probed(actual, bool(rep))
timeout = _next_timeout(property_timeout)
if timeout is None:
break
actual = f"/{sub_id}{href}"
rep = _get_property(sess, tuple(actual.strip("/").split("/")), timeout)
_probed(actual, rep)
if rep:
flat_hrefs.append(href)
fetched[actual] = rep
if not flat_hrefs:
continue
subdevices.append(Subdevice(
kind='prefixed', key=sub_id, seed_path=(),
flat_hrefs=tuple(flat_hrefs),
))
return
subdevices.append(
Subdevice(
kind="prefixed",
key=sub_id,
seed_path=(),
flat_hrefs=tuple(flat_hrefs),
)
)
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
# Tolerate anything but a list of strings -- this field is
# redaction-prone (matches redact.py's 'deviceid' rule) and a shipped
# fixture carries the literal string 'REDACTED' there. That must yield
# zero subdevices, not a crash -- issue #177 is additive and must never
# break an already-working single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
listed = sorted(i for i in ids if isinstance(i, str) and i)
for sub_id in listed:
_probe_prefixed(sub_id)
if budget_exhausted:
break
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
# /subdevices/vs/0 and /device/<n> 404s; the only trace of the sibling
# is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice
# link). Its own tree answers a full Collection at /<uuid>/device/0,
# exactly Pattern B's transform, so a UUID prefix attached to that
# resource type is treated as a candidate. Other UUID-prefixed links are
# not evidence of a sibling: some single-unit AC boards advertise only
# per-prefix file-transfer resources, and probing those prefixes against
# every master href needlessly burns the setup timeout budget.
# _probe_prefixed's probed_ids
# guard (not a set difference against `listed`) is what keeps an id
# already named by subdeviceIdList from being probed twice, since the
# two sources can disagree on case.
linked = sorted(
{
m.group(1)
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_UUID_PREFIX_RE.match(link.get("href", ""))]
if m and "x.com.samsung.da.multidevice" in (link.get("rt") or ())
}
)
for sub_id in linked:
_probe_prefixed(sub_id)
if budget_exhausted:
break
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get('href', ''))]
if m and int(m.group(1)) >= 1
})
indices = sorted(
{
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get("href", ""))]
if m and int(m.group(1)) >= 1
}
)
if not indices:
# A board that hides its whole tree from /oic/res (Pattern B's
# reporter board does this too, but it has no /device/<n> to find
# regardless) gives us nothing to enumerate from -- fall back to the
# bounded speculative probe this replaces from identity.py.
# A board that hides its whole tree from /oic/res gives us nothing
# to enumerate from -- fall back to the bounded speculative probe
# this replaces from identity.py.
indices = list(_SPECULATIVE_DEVICE_INDICES)
for n in indices:
seed = ('device', str(n))
batch = _get_batch(sess, seed)
timeout = _next_timeout(collection_timeout)
if timeout is None:
break
seed = ("device", str(n))
batch = _get_batch(sess, seed, timeout)
_probed(_seed_href(seed), batch)
if not batch:
continue
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
subdevice = Subdevice(kind="indexed", key=str(n), seed_path=seed)
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
subdevices.append(subdevice)
@@ -435,35 +478,36 @@ def enumerate_subdevices(
# with the number of subdevices actually materialized is the
# coordinator's call to log (it owns the logger; this module doesn't),
# not this function's.
multidevice_seed = ('multidevice', 'vs', '0')
multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched['/multidevice/vs/0'] = multidevice
multidevice_seed = ("multidevice", "vs", "0")
timeout = _next_timeout(property_timeout)
if timeout is not None:
multidevice = _get_property(sess, multidevice_seed, timeout)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched["/multidevice/vs/0"] = multidevice
return subdevices, fetched
@dataclass(frozen=True)
class SkippedSubdevice:
"""A candidate `enumerate_subdevices` found whose seed answered, but that
`discover_partitioned`'s entity-level liveness gate rejected -- an
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
real second subdevice. Kept around (rather than silently dropped) so a
"""A candidate `enumerate_subdevices` found whose seed answered, but
that `discover_partitioned`'s entity-level liveness gate rejected -- an
unused SmartThings slot, not a real second subdevice. Kept around so a
caller can log/report what was skipped and why."""
subdevice: Subdevice
hrefs: tuple[str, ...]
# Sensor kinds whose value is a running total the *appliance* keeps rather
# Sensor kinds whose value is a running total the appliance keeps rather
# than a reading of the subdevice's own hardware -- excluded from the
# liveness gate below (issue #214). HA's own running-total state classes
# cover most of them; the consumption device classes catch the rest, since a
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
# monthly totals reset at each billing boundary, so they aren't
# `total_increasing`).
_METER_STATE_CLASSES = frozenset({'total', 'total_increasing'})
_METER_DEVICE_CLASSES = frozenset({'energy', 'water', 'gas'})
# liveness gate below (issue #214). HA's running-total state classes cover
# most of them; the consumption device classes catch the rest (a descriptor
# may deliberately declare no state_class, e.g. common.ENERGY_METER's
# monthly totals that reset at each billing boundary).
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
def _is_meter(desc) -> bool:
@@ -471,123 +515,104 @@ def _is_meter(desc) -> bool:
constants above. Only SensorDesc carries either attribute; everything
else answers False through the getattr defaults."""
return (
getattr(desc, 'state_class', None) in _METER_STATE_CLASSES
or getattr(desc, 'device_class', None) in _METER_DEVICE_CLASSES
getattr(desc, "state_class", None) in _METER_STATE_CLASSES
or getattr(desc, "device_class", None) in _METER_DEVICE_CLASSES
)
def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate subdevice's BoundEntity
list) produced at least one non-`None` value for a *primary* entity --
`entity_category` unset, HA's own "the user acts on or watches this"
tier (see the adding-device-support skill's entity-taxonomy section) --
that isn't a cumulative meter (`_is_meter`).
list) produced at least one non-`None` value for a primary entity
(`entity_category` unset) that isn't a cumulative meter (`_is_meter`).
This is the materialization gate itself (see this module's docstring).
Two exclusions, both for the same reason -- the question this answers is
"is a physical subdevice installed at this slot?", and neither kind of
value can speak to it:
Two exclusions, both because the question this answers is "is a
physical subdevice installed at this slot?", and neither kind of value
can speak to it:
- **Non-primary entities.** The Pattern A reporter's `/device/2` does
flatten to one non-`None` value (`alarm_code`), but that entity is
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a
config/diagnostic entity reading "something" proves nothing about
whether hardware is there.
- **Cumulative meters** (issue #214). An unused slot on the issue #214
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
populated `cumulativePower` while every operational rep on it
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
counter. That counter got the slot materialized as a phantom second
air conditioner. A single-split AC has one compressor and one energy
meter, so a whole-appliance total showing up under a second index is
the appliance's own bookkeeping, not evidence of a second indoor unit.
A genuinely installed subdevice reports its own operational state too
(the Pattern A reporter's real `/device/1` reports power, mode, both
temperatures and airflow), and that state is what still passes this
gate.
- **Non-primary entities.** An unused slot can still flatten to a
diagnostic-category value derived from an empty resource (e.g. a
formatted `alarm_code` off an empty `/alarms/vs/2`) -- that proves
nothing about whether hardware is there.
- **Cumulative meters** (issue #214). An unused slot has been seen
reporting a populated whole-appliance `cumulativePower` while every
operational rep on it is empty `{}`. A single-split AC has one
compressor and one energy meter, so a whole-appliance total showing
up under a second index is the appliance's own bookkeeping, not
evidence of a second indoor unit. A genuinely installed subdevice
reports its own operational state too, and that is what still passes
this gate.
"""
from .adapter import _key # see discover_partitioned's deferred-import note
return any(
not b.desc.entity_category and not _is_meter(b.desc)
and state.get(_key(b)) is not None
not b.desc.entity_category and not _is_meter(b.desc) and state.get(_key(b)) is not None
for b in bound
)
def discover_partitioned(
resources: dict[str, dict],
subdevices: list['Subdevice'],
resolve_registry: Callable[..., object],
subdevices: list[Subdevice],
resolve_registry: Callable[..., DeviceRegistry | None],
fallback_capabilities: dict,
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
oic_device_types: Sequence[str] = (),
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
plus every enumerated subdevice's seed) to entities, partitioned by which
subdevice owns it.
"""Bind every href in `resources` (the merged, real-href snapshot --
main plus every enumerated subdevice's seed) to entities, partitioned
by which subdevice owns it.
Main pass runs over hrefs owned by no subdevice -- otherwise every
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
device's registry claims that literal href) and raise a spurious
coverage-gap repair. Then one pass per *candidate* subdevice over its own
`/mode/vs/1` would land in `unbound_hrefs` too and raise a spurious
coverage-gap repair. Then one pass per candidate subdevice over its own
canonical view, resolving that subdevice's own device type from its own
`/information/vs/0` when it reports one (e.g. the Pattern B reporter's
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
airconditioner), falling back to the master's registry otherwise --
every AC family shares the same resource surface, and a sibling that
fails to answer its own identity resource is still the same appliance
type as the subdevice this config entry was set up against.
`/information/vs/0` when it reports one, falling back to the master's
registry otherwise -- a sibling that fails to answer its own identity
resource is still treated as the same appliance type as the master.
Each candidate is discovered and flattened *twice*: once silently to
evaluate `_has_live_primary_entity` (this module's materialization
gate -- see its docstring and this module's own), and, only if that
passes, a second time with `log`/`tier_log` wired so its coverage gaps
and poll tiers actually count. A candidate that fails the gate
contributes nothing at all -- no bound entities, no unbound-href
report, no hot/warm href -- as if it had never answered its seed.
Discovering an unused slot's small, fixed resource set twice at
first-discovery time only is a non-issue; getting a phantom subdevice
silently counted into unbound_hrefs or hot/warm tiers is not.
Each candidate is discovered and flattened twice: once silently to
evaluate `_has_live_primary_entity`, and, only if that passes, a second
time with `log`/`tier_log` wired so its coverage gaps and poll tiers
actually count. A candidate that fails the gate contributes nothing at
all, as if it had never answered its seed.
`oic_device_types` (from the master's own `/oic/d`, see
registry/identity.py) is passed only to the *master's* resolution --
subdevices have no `/oic/d` of their own read today (they resolve from
their own `/information/vs/0` or fall back to the master's whole
registry, as documented above), and blindly applying the master's OCF
device type to every subdevice's own model-based resolution would be
wrong the moment a composite appliance ever pairs two genuinely
different device types under one connection.
`oic_device_types` (from the master's own `/oic/d`) is passed only to
the master's resolution -- subdevices resolve from their own
`/information/vs/0` or fall back to the master's whole registry, and
blindly applying the master's OCF device type to every subdevice would
be wrong the moment a composite appliance pairs two different device
types under one connection.
Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized
subdevice).
- `device_type_name`: the *master's* resolved device type (used for
logging/device naming; each subdevice's own resolved type only affects
which capabilities bind its hrefs, not this).
- `materialized`: the subset of `subdevices` that passed the gate, in the
same order -- what the caller should keep as its live subdevice roster
going forward (poll seeds, canonical_resources, device_info_for, ...).
- `device_type_name`: the master's resolved device type (used for
logging/device naming; each subdevice's own resolved type only
affects which capabilities bind its hrefs).
- `materialized`: the subset of `subdevices` that passed the gate, in
the same order -- what the caller should keep as its live subdevice
roster going forward (poll seeds, canonical_resources,
device_info_for, ...).
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
"""
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
# module scope, so importing discover() back here at module scope would
# be a circular import. By the time this function actually runs both
# modules are fully loaded. adapter.py imports discovery.py, so the same
# applies to flatten()/_key().
# Deferred import: discovery.py imports Subdevice/MAIN from this module
# at module scope, so importing discover() back here at module scope
# would be circular. By the time this function runs both modules are
# fully loaded; adapter.py imports discovery.py, so the same applies to
# flatten()/_key().
from .adapter import flatten
from .discovery import discover
# Same computation canonical_view does for MAIN (snapshot minus every
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
# owned_elsewhere here too.
# Same computation canonical_view does for MAIN -- reuse it rather than
# re-deriving owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view, device_types=oic_device_types)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities) if reg is not None
(reg.capabilities, reg.pattern_capabilities)
if reg is not None
else (fallback_capabilities, [])
)
# MAIN is never gated -- the config entry's own physical connection
@@ -602,7 +627,8 @@ def discover_partitioned(
view = canonical_view(su, resources, subdevices)
su_reg = resolve_registry(view) or reg
su_caps, su_pats = (
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
(su_reg.capabilities, su_reg.pattern_capabilities)
if su_reg is not None
else (fallback_capabilities, [])
)
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
@@ -610,12 +636,19 @@ def discover_partitioned(
if _has_live_primary_entity(probe_bound, probe_state):
materialized.append(su)
bound = bound + discover(
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
view,
su_caps,
su_pats,
log=log,
tier_log=tier_log,
subdevice=su,
)
else:
skipped.append(SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
))
skipped.append(
SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
)
)
return bound, device_type_name, materialized, skipped
+90
View File
@@ -0,0 +1,90 @@
"""Move an entry's registry entries from one device key to another.
The key (registry.identity.resolve_device_key) is permanent in three
places, so changing it means rewriting the registries rather than storing
a new value -- anything left behind is orphaned. Rewriting rather than
recreating is what keeps an entity's entity_id, and with it its history,
area and automations.
Its own module because both callers need it: the v1 -> v2 migration in
__init__.py and the coordinator's first-poll adoption (issue #381).
"""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@callback
def rekey_entry(hass: HomeAssistant, entry: ConfigEntry, old_key: str, new_key: str) -> None:
"""Rewrite everything this entry registered under `old_key` to `new_key`.
All three permanent places move together: entity unique_ids
(f"{DOMAIN}_{key}_{state_key}"), device identifiers ((DOMAIN, key), plus
(DOMAIN, f"{key}_{subdevice}") per subdevice -- see device_info_for),
and the entry's own unique_id. Leaving that last one behind would let
the config flow's duplicate check wave through a re-add of this very
appliance.
Idempotent, so it is safe to attempt on every poll rather than tracking
whether it has run. Where both keys already exist the `old_key` copy is
the dead one, so it is removed rather than rewritten over the live entry.
Must run on the event loop; the registry helpers require it.
"""
if old_key == new_key:
return
new_entry_unique_id = f"{DOMAIN}_{new_key}"
if entry.unique_id != new_entry_unique_id:
hass.config_entries.async_update_entry(entry, unique_id=new_entry_unique_id)
ent_reg = er.async_get(hass)
stale_prefix = f"{DOMAIN}_{old_key}_"
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
if not entity.unique_id.startswith(stale_prefix):
continue
new_unique_id = f"{DOMAIN}_{new_key}_{entity.unique_id[len(stale_prefix) :]}"
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
ent_reg.async_remove(entity.entity_id)
else:
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
dev_reg = dr.async_get(hass)
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
stale = {
ident
for ident in device.identifiers
if ident[0] == DOMAIN and (ident[1] == old_key or ident[1].startswith(f"{old_key}_"))
}
if not stale:
continue
fresh = {(DOMAIN, f"{new_key}{ident[1][len(old_key) :]}") for ident in stale}
existing = dev_reg.async_get_device(identifiers=fresh)
if existing is not None and existing.id != device.id:
# Removing a device takes its entities with it. Anything still
# attached here was re-keyed rather than removed above -- the
# surviving copy, not a duplicate -- so move it onto the device
# it now belongs to before the removal destroys it too.
for entity in er.async_entries_for_device(
ent_reg, device.id, include_disabled_entities=True
):
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
_LOGGER.debug("removing orphaned device %s", device.id)
dev_reg.async_remove_device(device.id)
else:
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
dev_reg.async_update_device(
device.id, new_identifiers=(device.identifiers - stale) | fresh
)
+54 -49
View File
@@ -1,20 +1,20 @@
"""Select platform for Local Things."""
from __future__ import annotations
import re
from typing import Optional
from typing import cast
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SelectDesc
from .catalog import translated_states
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SelectDesc
async def async_setup_entry(
@@ -30,7 +30,7 @@ async def async_setup_entry(
)
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
def _translation_state(value: str, known: frozenset[str]) -> str | None:
@@ -42,70 +42,75 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
knows are normalized -- an unrecognized (or future) vendor value keeps
its own readable form rather than becoming an untranslatable slug.
"""
direct = value.lower().replace(' ', '_')
direct = value.lower().replace(" ", "_")
if direct in known:
return direct
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
snake = _CAMEL_BOUNDARY_RE.sub("_", value).lower().replace(" ", "_")
return snake if snake in known else None
def _display(value, translation_key: Optional[str]):
def _display(value, translation_key: str | None, fallback_fn=None):
"""Turn a raw device option/state value into what's shown in the UI.
`translation_key` is the entity's already-resolved key (SelectDesc.
translation_key can itself be a callable -- see entities.py -- so
callers pass the resolved value, e.g. self.translation_key, not
the raw descriptor field).
`translation_key` is the entity's already-resolved key (it can itself
be a callable -- see entities.py -- so callers pass the resolved
value, not the raw descriptor field).
An entity with a translation_key looks its state up in the shipped
translation catalog, whose state keys are lowercase -- so those values
must be lowercased exactly to match, and the device still expects
that same raw casing back on write (callers map the displayed value
back to raw via _raw_options()).
Everything else has no catalog lookup, so there's no reason to
destroy the device's own casing. Only two cosmetic fixups apply: a
fully lowercase device-native token (e.g. "voice") is title-cased,
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
the case boundary ("Extra High"). A value that's already
human-friendly (e.g. "AI Wash") matches neither pattern and passes
through unchanged.
translation catalog, whose state keys are lowercase, and the device
still expects that same raw casing back on write (mapped back via
_raw_options()). Everything else has no catalog lookup, so there's no
reason to destroy the device's own casing: only two cosmetic fixups
apply, title-casing a fully lowercase token ("voice") and spacing a
PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value
("AI Wash") matches neither and passes through unchanged.
"""
if not isinstance(value, str):
return value
if translation_key:
known = translated_states('select', translation_key)
if not known:
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
# aren't (an unrecognized course table, say). Either way the
# opaque device value is the best thing to show.
return value
if translated := _translation_state(value, known):
return translated
known = translated_states("select", translation_key) if translation_key else frozenset()
if translated := _translation_state(value, known):
return translated
if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
return fallback
if translation_key and not known:
# No state table for this key: either the entity isn't translated at
# all, or its name is translated but its options deliberately aren't
# (an unrecognized course table, say). Nothing named this value, so
# the raw device value is the best choice -- the cosmetic reshaping
# below would only mangle an opaque code, turning a course '0E' into
# '0 E'. Reached only when the fallback *declined* the value, not
# merely when none was supplied: cycle_select always supplies one now
# (it labels cloud programs) and returns None for everything else.
return value
if value.islower():
return value.replace('_', ' ').title()
return _CAMEL_BOUNDARY_RE.sub(' ', value)
return value.replace("_", " ").title()
return _CAMEL_BOUNDARY_RE.sub(" ", value)
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
desc = cast(SelectDesc, bound.desc)
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
self._attr_options = [self._display_option(o) for o in desc.options]
def _display_option(self, value):
"""Normalize both current state and options through one path."""
display_fn = cast(SelectDesc, self._bound.desc).display_fn
fallback_fn = (
(lambda raw: display_fn(raw, self._resources)) if display_fn is not None else None
)
return _display(value, self.translation_key, fallback_fn)
def _raw_options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
desc = cast(SelectDesc, self._bound.desc)
if callable(desc.options):
# Per-device option list computed from the full resource
# snapshot (not just this entity's own href) -- e.g. a course
# list decoded from a sibling resource. There is no static
# fallback: when that resource isn't populated the callable
# returns [] and the entity's exists_fn suppresses it entirely.
# This entity's own subdevice's canonical view (issue #177), not
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
# snapshot -- e.g. a course list decoded from a sibling
# resource. No static fallback: when unpopulated, the callable
# returns [] and exists_fn suppresses the entity entirely. Uses
# this subdevice's canonical view (issue #177), not the raw
# snapshot -- see LocalThingsEntity._resources.
return list(desc.options(self._resources) or [])
if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {}
@@ -114,19 +119,19 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
@property
def options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
desc = cast(SelectDesc, self._bound.desc)
if desc.options_field or callable(desc.options):
return [_display(o, self.translation_key) for o in self._raw_options()]
return [self._display_option(o) for o in self._raw_options()]
return self._attr_options
@property
def current_option(self):
raw = (self.coordinator.data or {}).get(self._state_key)
return _display(raw, self.translation_key)
return self._display_option(raw)
async def async_select_option(self, option: str) -> None:
raw = next(
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
(o for o in self._raw_options() if self._display_option(o) == option),
option,
)
await self.coordinator.async_send_command(self._bound, raw)
+101 -17
View File
@@ -1,9 +1,12 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
import time
from datetime import timedelta
from typing import cast
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
@@ -11,12 +14,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DOMAIN
from .const import (
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN,
)
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
async def async_setup_entry(
@@ -25,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
entities: list[SensorEntity] = [
LocalThingsSensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
@@ -35,31 +41,108 @@ async def async_setup_entry(
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SensorDesc = bound.desc
desc = cast(SensorDesc, bound.desc)
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
self._attr_device_class = (
SensorDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
# Always set, so the `options` property below can read it unguarded.
self._attr_options = list(desc.options) if desc.options else None
self._hysteresis_value = None
self._sticky_value = None
self._sticky_until: float | None = None
self._sticky_spent = False
@property
def native_unit_of_measurement(self):
desc: SensorDesc = self._bound.desc
desc = cast(SensorDesc, self._bound.desc)
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
@property
def options(self):
"""Declared options, plus whatever this device is actually reporting.
HA raises for an enum state outside `options`, which would turn any
device value we don't have a translation for into a broken entity --
the opposite of this registry's rule that an unrecognized value
renders raw. Admitting the live value keeps it displayable; it just
shows untranslated (PR #341 review).
"""
if self._attr_options is None:
return None
value = self.native_value
if not isinstance(value, str) or value in self._attr_options:
return self._attr_options
return [*self._attr_options, value]
@property
def native_value(self):
raw = (self.coordinator.data or {}).get(self._state_key)
if not self._bound.desc.hysteresis:
desc = cast(SensorDesc, self._bound.desc)
if desc.sticky_fn is not None:
raw = self._apply_sticky(raw, desc)
if not desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_sticky(self, raw, desc: SensorDesc):
"""Freeze this entity at a value for up to `desc.sticky_seconds`
after `desc.sticky_fn` next stops matching this href's live rep
(issue #345 -- see operational.py's `_just_finished` for the
motivating case). Entity-instance state only, exactly like
`_hysteresis_value` above -- never written back to the coordinator
cache, so write_fn, diagnostics, and the observe-mode sweep
comparison keep seeing real device data throughout.
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
than the already-computed `raw`, so they can key on fields rep_fn
has collapsed away -- but they never compute a value. `raw` and
the frozen `sticky_value` are the only things returned here, so a
held entity and a free-running one agree on what "live" means; a
hook that broke that rule caused issue #358.
At most one window per `sticky_bypass_fn` cycle: arming marks the
hold spent, and only the bypass clears that. So a `sticky_fn` that
keeps matching (firmware leaving the field stuck -- the quirk
`_completion_minutes` works around) can't extend the window, and
one flapping in and out can't restart it either, before or after
expiry. Expiry alone doesn't re-open the door: without something
the calibre of "a new cycle is actually running" in between, a
second Finish is the same Finish, and re-arming on it would strobe
the entity between held and live once per window -- exactly the
repeated announcements #345 and #358 are about.
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
sticky right now" is ambiguous between "went idle, honor the hold"
and "genuinely moved on to new data". It is both the early release
and the only re-arm, so it should demand positive evidence of that
move; when unsure, letting the window run out is the cheaper
mistake.
"""
assert desc.sticky_fn is not None # native_value only calls this when set
rep = self.coordinator.resource(self._bound.href)
now = time.monotonic()
if desc.sticky_fn(rep):
if not self._sticky_spent:
self._sticky_value = (
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
)
self._sticky_until = now + desc.sticky_seconds
self._sticky_spent = True
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
self._sticky_spent = False
return raw
holding = self._sticky_until is not None and now < self._sticky_until
return self._sticky_value if holding else raw
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
@@ -75,6 +158,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
assert self.coordinator.config_entry is not None
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
@@ -95,15 +179,15 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True
_attr_translation_key = 'connection_mode'
_attr_translation_key = "connection_mode"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = [MODE_OBSERVE, MODE_POLL]
_attr_options = [MODE_OBSERVE, MODE_POLL] # noqa: RUF012 -- HA `_attr_*` convention
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_connection_mode"
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_connection_mode"
@property
def device_info(self) -> DeviceInfo:
+229
View File
@@ -0,0 +1,229 @@
"""Home Assistant services for direct OCF resource read/write access
(issue #300): a raw-transport escape hatch for reverse-engineering a
device's write contract -- an ordered multi-write sequence with settle
delays and a delayed re-read, which the single-write options-flow debug
panel can't express. Both sit on the same coordinator primitives the panel
now calls too (config_flow.py), so there is exactly one code path that
performs a raw write.
Kept thin on purpose: session/lock ownership lives on the coordinator
(coordinator.py). This module only resolves the service call's device
target to a `(coordinator, subdevice)` pair, translates canonical hrefs
through that subdevice, and shapes the response.
"""
from __future__ import annotations
from typing import Any, cast
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
from .coordinator import LocalThingsCoordinator, normalize_href
from .registry.subdevices import MAIN, Subdevice
ATTR_HREF = "href"
ATTR_PAYLOAD = "payload"
ATTR_SETTLE = "settle"
ATTR_WRITES = "writes"
ATTR_VERIFY_AFTER = "verify_after"
ATTR_HOLD_SESSION_LOCK = "hold_session_lock"
ATTR_DEVICE_ID = "device_id"
_WRITE_ITEM_SCHEMA = vol.Schema(
{
vol.Required(ATTR_HREF): str,
# Not `dict` here: a non-dict payload must fail the same way an
# empty one does -- coordinator.async_raw_write_sequence's
# ServiceValidationError -- not a raw schema vol.Invalid, so every
# caller sees one consistent error shape regardless of which rule
# a bad payload tripped.
vol.Required(ATTR_PAYLOAD): object,
vol.Optional(ATTR_SETTLE): vol.Coerce(float),
}
)
# Structural validation only (types, and unwrapping a bare dict into a
# one-item list) -- the semantic checks (non-empty payload, non-root href,
# the 1..10/settle/verify_after ranges) live on
# LocalThingsCoordinator.async_raw_write_sequence, so every caller gets the
# same ServiceValidationError + translation key regardless of whether it
# reached the primitive through this service, the options-flow panel, or a
# future caller.
_WRITE_RESOURCE_SCHEMA = vol.Schema(
{
**cv.TARGET_SERVICE_FIELDS,
vol.Required(ATTR_WRITES): vol.All(cv.ensure_list, [_WRITE_ITEM_SCHEMA]),
vol.Optional(ATTR_VERIFY_AFTER): vol.Coerce(float),
vol.Optional(ATTR_HOLD_SESSION_LOCK): cv.boolean,
}
)
_READ_RESOURCE_SCHEMA = vol.Schema(
{
**cv.TARGET_SERVICE_FIELDS,
vol.Optional(ATTR_HREF): str,
}
)
def _resolve_target(
hass: HomeAssistant, call: ServiceCall
) -> tuple[LocalThingsCoordinator, Subdevice, str]:
"""The one `(coordinator, subdevice, device_id)` a service call's
device target names.
Deliberately strict about count, not just presence: the `target:
device:` selector in services.yaml still lets a user pick an area or
label in the picker, and the frontend expands that into a `device_id`
list before the call reaches here -- more than one entry means an
area/label fanned this out across several appliances, which a raw
debug write must never do silently (issue #300).
"""
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID) or [])
if len(device_ids) != 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_device_target_invalid",
)
device_id = device_ids[0]
dev_reg = dr.async_get(hass)
device = dev_reg.async_get(device_id)
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_device_not_found",
)
for coordinator in hass.data.get(DOMAIN, {}).values():
if device.identifiers & coordinator.device_info.get("identifiers", set()):
return coordinator, MAIN, device_id
for sub in coordinator.subdevices:
if device.identifiers & coordinator.device_info_for(sub).get("identifiers", set()):
return coordinator, sub, device_id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_device_not_loaded",
)
async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
coordinator, subdevice, device_id = _resolve_target(hass, call)
writes_in: list[dict[str, Any]] = call.data[ATTR_WRITES]
# Canonical -> actual translation happens here, not in the coordinator
# (issue #177), whose raw-write primitive has no notion of subdevices --
# identity transform for MAIN. Normalized first, or a trailing slash
# slips past to_actual onto the master (see coordinator.normalize_href).
canonicals = [normalize_href(w[ATTR_HREF]) for w in writes_in]
raw_writes = [
{
"href": subdevice.to_actual(canonical),
"payload": w.get(ATTR_PAYLOAD),
"settle": w.get(ATTR_SETTLE, 0.0),
}
for canonical, w in zip(canonicals, writes_in, strict=True)
]
sequence = await coordinator.async_raw_write_sequence(
raw_writes,
verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0),
hold_session_lock=call.data.get(ATTR_HOLD_SESSION_LOCK, True),
)
results = [
{
"href": canonical,
"actual_href": result["href"],
"code": result["code"],
"raw_code": result["raw_code"],
"accepted": result["accepted"],
"before": result["before"],
"after": result["after"],
"changed": result["changed"],
}
for canonical, result in zip(canonicals, sequence["results"], strict=True)
]
response: dict[str, Any] = {"device_id": device_id, "results": results}
if "verified" in sequence:
# Keyed off the same normalized canonicals the sequence was built
# from, so the lookup can't miss and return an actual href where the
# contract promises a canonical one.
canonical_by_actual = {subdevice.to_actual(c): c for c in canonicals}
response["verified"] = {
canonical_by_actual.get(actual_href, actual_href): verified
for actual_href, verified in sequence["verified"].items()
}
return response
async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
coordinator, subdevice, _device_id = _resolve_target(hass, call)
href = call.data.get(ATTR_HREF)
if not href:
# No href -> the cached snapshot, not a live sweep of every known
# href: lets a user enumerate what exists without hammering the
# device (see this module's docstring and the coordinator's
# canonical_resources).
# device_resources, not canonical_resources: this response is what
# the appliance reported, without the fields this integration merges
# on for its own use (see coordinator.entity_resources).
snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)}
return cast(ServiceResponse, snapshot)
# Same normalize-before-translate order as the write path above.
canonical = normalize_href(href)
actual_href = subdevice.to_actual(canonical)
code, rep, body = await coordinator.async_raw_read(actual_href)
read_result: dict[str, Any] = {
"href": canonical,
"actual_href": actual_href,
"code": f"{code >> 5}.{code & 0x1F:02d}",
"raw_code": code,
"rep": rep,
}
# `body` only when it isn't the Property map already in `rep` -- a
# Collection (`/device/0`, `/sec/devices`) answers a CBOR list, which
# `rep` can't carry and which used to vanish into an empty-looking
# 2.05 (issue #335). Omitted for the ordinary map case rather than
# duplicating every rep in every response.
if body is not None and not isinstance(body, dict):
read_result["body"] = body
return cast(ServiceResponse, read_result)
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the write_resource/read_resource services (issue #300).
Called once from `async_setup`, not per config entry: services are
process-global, and `hass.services.async_register` on an
already-registered name just replaces the handler, so re-registering
on every entry setup would silently rebind to whichever entry loaded
last. `async_unload_entry` must never call the inverse of this.
"""
async def _handle_write(call: ServiceCall) -> ServiceResponse:
return await _async_write_resource(hass, call)
async def _handle_read(call: ServiceCall) -> ServiceResponse:
return await _async_read_resource(hass, call)
hass.services.async_register(
DOMAIN,
SERVICE_WRITE_RESOURCE,
_handle_write,
schema=_WRITE_RESOURCE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
hass.services.async_register(
DOMAIN,
SERVICE_READ_RESOURCE,
_handle_read,
schema=_READ_RESOURCE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -0,0 +1,88 @@
write_resource:
name: Write resource
description: >-
Send one or more raw partial-rep writes straight to a device's OCF
resources, in order, with an optional settle delay between steps and a
delayed re-read at the end -- for reverse-engineering a device's write
contract (issue #300), not day-to-day control. 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 verbatim:
it can misconfigure your appliance. Prefer a real entity, or the Debug
write panel in the integration's Configure menu, for anything this
integration already models.
fields:
device_id:
name: Device
description: The appliance to write to, or one of its subdevices.
required: true
selector:
device:
integration: localthings
writes:
name: Writes
description: >-
1-10 writes to perform in order. Each item needs href (the
canonical resource, e.g. /mode/vs/0) and payload (a non-empty
object sent verbatim as a partial-rep POST); settle is how many
seconds to wait after that write before starting the next one
(0-30, default 0).
required: true
example: >-
[{"href": "/mode/vs/0", "payload": {"x.com.samsung.da.modes":
["Bake"]}, "settle": 3}]
selector:
object:
hold_session_lock:
name: Hold the session for the whole sequence
description: >-
Keep the device session for the entire sequence, settle delays
included, so nothing else -- a routine poll, another entity's write
-- can land between two steps and blur which write the appliance
was reacting to. On by default. Turning it off takes the session
per write and frees it across the waits, which lets entities keep
updating during a long sequence at the cost of that certainty.
required: false
default: true
selector:
boolean:
verify_after:
name: Verify after
description: >-
Seconds to wait after the whole sequence finishes before
re-reading every href touched, to see whether the values held or
were reverted by the board. 0 (default) skips verification.
required: false
default: 0
selector:
number:
min: 0
max: 60
step: 0.5
unit_of_measurement: seconds
mode: box
read_resource:
name: Read resource
description: >-
Read a device's OCF resources directly, bypassing this integration's
entity model. Give an href for a live GET straight from the device --
deliberately not the cache, which can be up to a poll interval stale --
or omit it to get the cached snapshot of every resource this
integration currently tracks on that device.
fields:
device_id:
name: Device
description: The appliance to read from, or one of its subdevices.
required: true
selector:
device:
integration: localthings
href:
name: Resource href
description: >-
Canonical resource href to read (e.g. /mode/vs/0). Omit to get the
cached snapshot of every tracked resource instead of a live GET.
required: false
example: /mode/vs/0
selector:
text:
+8 -7
View File
@@ -1,16 +1,16 @@
"""Switch platform for Local Things."""
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SwitchDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SwitchDesc
async def async_setup_entry(
@@ -27,18 +27,19 @@ async def async_setup_entry(
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SwitchDesc = bound.desc
self._attr_device_class = desc.device_class
self._attr_device_class = (
SwitchDeviceClass(desc.device_class) if desc.device_class else None
)
@property
def is_on(self):
return (self.coordinator.data or {}).get(self._state_key)
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'On')
await self.coordinator.async_send_command(self._bound, "On")
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'Off')
await self.coordinator.async_send_command(self._bound, "Off")
+2 -3
View File
@@ -1,4 +1,5 @@
"""Time platform for Local Things."""
from __future__ import annotations
import datetime
@@ -8,11 +9,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import TimeDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import TimeDesc
async def async_setup_entry(
@@ -29,7 +29,6 @@ async def async_setup_entry(
class LocalThingsTime(LocalThingsEntity, TimeEntity):
@property
def native_value(self) -> datetime.time | None:
return (self.coordinator.data or {}).get(self._state_key)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean running"
},
"after_run_active": {
"name": "After run active"
},
@@ -105,6 +108,15 @@
},
"softener_low": {
"name": "Softener low"
},
"add_wash_available": {
"name": "AddWash allowed"
},
"add_wash_indicator": {
"name": "AddWash ready"
},
"stick_ble_connected": {
"name": "Stick BLE connected"
}
},
"button": {
@@ -114,6 +126,12 @@
"diagnosis_start": {
"name": "Start diagnosis"
},
"auto_clean_stop": {
"name": "Stop auto clean"
},
"filter_time_reset": {
"name": "Reset filter time"
},
"pause": {
"name": "Pause"
},
@@ -143,11 +161,13 @@
"smart": "Smart",
"speed": "Speed",
"nano": "WindFree",
"sleep": "Sleep",
"nanosleep": "WindFree sleep",
"longwind": "Long wind",
"motionindirect": "Motion indirect",
"motiondirect": "Motion direct",
"drycomfort": "Dry comfort",
"dlightcool": "d'light Cool",
"2step": "2-Step"
}
}
@@ -188,6 +208,9 @@
"oven_setpoint": {
"name": "Setpoint"
},
"sensing_interval": {
"name": "AI Purify interval"
},
"setpoint": {
"name": "Setpoint"
},
@@ -199,6 +222,9 @@
},
"tropical_night_mode": {
"name": "Tropical night mode"
},
"zone_target_temperature": {
"name": "Zone target temperature"
}
},
"select": {
@@ -208,6 +234,9 @@
"air_filter_threshold": {
"name": "Filter alarm threshold"
},
"auto_door_timer": {
"name": "Auto door open timer"
},
"beverage_zone_mode": {
"name": "Beverage zone mode",
"state": {
@@ -238,7 +267,11 @@
"name": "Buzzer sound",
"state": {
"off": "Off",
"on": "On"
"on": "On",
"volume_off": "Off",
"volume_low": "Low",
"volume_med": "Medium",
"volume_high": "High"
}
},
"discharging_time": {
@@ -272,15 +305,24 @@
"name": "Cycle",
"state": {
"80": "Delicate",
"83": "Express 60",
"83": "Normal",
"84": "Heavy",
"86": "Normal",
"86": "Express 60",
"90": "Self clean",
"0e": "AI Wash",
"07": "Pre blast",
"8d": "Pots and pans",
"85": "Delicate",
"0c": "Express",
"0d": "Self clean",
"8e": "Plastic",
"8f": "Baby Care"
"8f": "Baby Care",
"82": "Auto",
"8a": "Normal",
"a7": "Heavy",
"a8": "Express",
"8c": "Extra Silence",
"88": "Self Clean"
}
},
"dispense_type": {
@@ -295,6 +337,35 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"9c": "Heavy Duty",
"a5": "Bedding",
"9e": "Perm Press",
"9b": "Steam Sanitize+",
"27": "Refresh",
"a0": "Air Fluff",
"a4": "Time Dry",
"a6": "Quick Dry",
"a3": "Active Wear",
"a2": "Delicates",
"9a": "Cotton",
"ca": "Air Wash",
"db": "Super Speed",
"99": "Mixed Load",
"93": "Iron Dry",
"b5": "Wool",
"d7": "Outdoor Care",
"96": "Cool Air",
"97": "Warm Air",
"7f": "Time Dry",
"98": "Quick Dry 35",
"eb": "Delicates",
"b6": "Synthetics"
}
},
"dryer_cycle_table_03": {
"name": "Cycle",
"state": {
@@ -310,8 +381,10 @@
"23": "Quick Dry 35'",
"24": "Cool air",
"25": "Warm air",
"26": "Air wash",
"27": "Time dry",
"29": "AI Dry",
"2a": "Hygiene Care+",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
@@ -319,7 +392,28 @@
"1e": "Outdoor",
"1f": "Mixed load",
"2b": "Self Tub Dry",
"4c": "Air Refresh"
"4c": "Air Refresh",
"51": "Eco Cotton",
"53": "AI Dry+",
"4e": "Self Dry",
"02": "AI Dry",
"03": "Super Speed",
"05": "Bedding",
"07": "Delicates",
"09": "Shirts",
"0b": "Padding Care",
"0c": "Outdoor Water-Repellent Care",
"0e": "Towels",
"0f": "Wool",
"11": "Cool air",
"28": "Interior Hot Air Sanitize",
"37": "Blouses",
"38": "Iron dry",
"39": "Room Dehumidify",
"3a": "Hygiene Care",
"3b": "Bedding/Dust Off",
"3c": "Activewear",
"3d": "Denim"
}
},
"favorite_capacity": {
@@ -328,6 +422,15 @@
"favorite_hotwater_temperature": {
"name": "Favorite hot water temperature"
},
"filter_alarm_time": {
"name": "Filter alarm interval",
"state": {
"180": "180 hours",
"300": "300 hours",
"500": "500 hours",
"700": "700 hours"
}
},
"finish_sound": {
"name": "Finish sound",
"state": {
@@ -434,9 +537,11 @@
"storage_fridge_normal": "Fridge",
"storage_fridge_cold": "Fridge, strong",
"storage_fridge_warm": "Fridge, weak",
"storage_fridge": "Fridge",
"storage_freezer_normal": "Freezer",
"storage_freezer_cold": "Freezer, strong",
"storage_freezer_warm": "Freezer, weak",
"storage_freezer": "Freezer",
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
@@ -447,7 +552,8 @@
"storage_fresh_cereal": "Grains",
"storage_fridge_drink": "Beverages",
"storage_fresh_wine": "Wine",
"storage_fresh_potato_banana": "Potato & banana"
"storage_fresh_potato_banana": "Potato & banana",
"newmode_kimchi_0000": "New mode"
}
},
"pantry_zone_mode": {
@@ -458,6 +564,16 @@
"fdr_drinks": "Drinks"
}
},
"winecellar_pantry_zone_mode": {
"name": "Pantry zone mode",
"state": {
"processed_meat": "Processed meat",
"cheese": "Cheese",
"nuts": "Nuts",
"fruit": "Fruit",
"wine": "Wine"
}
},
"range_burner_power_level": {
"name": "Burner {number} power level",
"state": {
@@ -476,6 +592,14 @@
"rinse_cycles": {
"name": "Rinse cycles"
},
"sensing_mode": {
"name": "AI Purify action",
"state": {
"off": "Sensing only",
"airpurify": "Auto clean",
"alarm": "Get notified"
}
},
"softener_concentration": {
"name": "Softener concentration",
"state": {
@@ -501,6 +625,9 @@
"mute": "Mute"
}
},
"energy_saving_mode": {
"name": "Energy saving mode"
},
"air_purifier_sound_mode": {
"name": "Sound mode",
"state": {
@@ -537,12 +664,51 @@
"extra_hot": "Extra hot"
}
},
"washer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"70": "Heavy Duty",
"55": "Whites",
"71": "Bedding",
"72": "Sanitize",
"77": "Perm Press",
"57": "Self Clean+",
"73": "Rinse + Spin",
"74": "Active Wear",
"75": "Delicates",
"78": "Quick Wash"
}
},
"washer_cycle_table_02": {
"name": "Cycle",
"state": {
"01": "Normal",
"02": "Extra Heavy Duty",
"03": "Super Eco Wash",
"04": "Quick Wash",
"05": "Wool/Lingerie",
"06": "Bedding",
"07": "Outdoor",
"08": "Rinse+Spin",
"09": "Drum Clean",
"0a": "Towels",
"0b": "Boil Wash",
"0c": "Baby Care",
"0d": "Spin Only",
"0e": "Cloudy Day",
"0f": "Pure Wash",
"10": "Spin Dry",
"11": "Summer Bedding",
"12": "Cottons",
"13": "Black Cottons",
"14": "Delicate Underwear",
"15": "Activewear",
"16": "Blouses",
"17": "Downloaded",
"18": "Soft Bubble",
"19": "AI Wash",
"1a": "Shirts",
"1b": "Cotton",
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -552,7 +718,7 @@
"21": "Colors",
"22": "Wool",
"23": "Outdoor",
"24": "Towels",
"24": "Bedding",
"25": "Synthetics",
"26": "Delicates",
"27": "Rinse+Spin",
@@ -565,8 +731,9 @@
"2f": "Activewear",
"30": "Cloudy Day",
"32": "Shirts",
"33": "Bedding",
"33": "Towels",
"34": "Mixed",
"35": "E Cotton",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
@@ -581,14 +748,34 @@
"60": "Self Clean+",
"65": "Colors",
"66": "Denim",
"69": "AI Wash",
"6a": "Wool",
"6b": "Denim",
"6c": "Blouses",
"6d": "Delicates",
"6e": "Active Wear",
"6f": "Bedding",
"70": "Towels",
"71": "Quick Wash",
"72": "Shirts",
"73": "Sanitize",
"74": "Drum Clean",
"75": "Outdoor",
"76": "Baby Care",
"77": "Cottons",
"78": "Rinse + Spin",
"79": "Spin Only",
"7c": "Whites",
"7d": "Bedding/Waterproof",
"7e": "Self-Clean",
"7f": "Wool/Delicate",
"86": "Deep Wash",
"87": "Download",
"88": "Pet Care",
"8f": "Intense Cold",
"96": "Less Microfiber"
"96": "Less Microfiber",
"a0": "15' Quick Wash",
"b0": "Mixed Load"
}
},
"washer_dry_level": {
@@ -603,9 +790,69 @@
"none": "Off",
"cupboard": "Cupboard"
}
},
"zone_mode": {
"name": "Zone mode"
},
"watertank_light_color": {
"name": "Water tank light color",
"state": {
"warmwhite": "Warm white",
"naturalwhite": "Natural white",
"coolwhite": "Cool white",
"green": "Green",
"purple": "Purple",
"blue": "Blue",
"orange": "Orange",
"pink": "Pink"
}
},
"watertank_light_brightness": {
"name": "Water tank light brightness"
},
"air_filter_pm1_threshold": {
"name": "PM1 filter alarm threshold"
},
"freezer_temperature_setpoint": {
"name": "Freezer temperature"
},
"edge_lighting_mode": {
"name": "Edge lighting mode",
"state": {
"smart": "Smart",
"high": "High",
"low": "Low"
}
},
"edge_lighting_color": {
"name": "Edge lighting color",
"state": {
"3000k": "3000 K",
"4000k": "4000 K",
"6500k": "6500 K"
}
},
"indicator_light_mode": {
"name": "Indicator light mode",
"state": {
"smart": "Smart",
"high": "High",
"low": "Low"
}
},
"ventilation_mode": {
"name": "Mode",
"state": {
"purification": "Purification",
"ventilation": "Ventilation",
"smartventilation": "Smart Ventilation"
}
}
},
"sensor": {
"auto_clean_progress": {
"name": "Auto clean progress"
},
"after_run_progress": {
"name": "After run progress"
},
@@ -615,6 +862,9 @@
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"deodor_filter_usage": {
"name": "Filter usage"
},
"air_quality_standard": {
"name": "Air quality standard"
},
@@ -688,6 +938,12 @@
"stick_status": {
"name": "Stick status"
},
"stick_operation_mode": {
"name": "Stick operation mode"
},
"stick_cleaning_status": {
"name": "Stick cleaning status"
},
"uvc_operation_time": {
"name": "UV-C operation time"
},
@@ -722,6 +978,12 @@
"indirect": "Indirect"
}
},
"energy_saving_state": {
"name": "Energy saving state"
},
"energy_saving_operating_status": {
"name": "Energy saving operating status"
},
"current_temp_c": {
"name": "Temperature"
},
@@ -729,10 +991,16 @@
"name": "Temperature"
},
"diagnosis": {
"name": "Diagnosis"
"name": "Diagnosis",
"state": {
"ready": "Ready"
}
},
"diagnosis_status": {
"name": "Diagnosis status"
"name": "Diagnosis status",
"state": {
"ready": "Ready"
}
},
"drum_clean_cycles_remaining": {
"name": "Drum clean due in"
@@ -747,7 +1015,10 @@
"name": "Dry time"
},
"dryer_type": {
"name": "Dryer type"
"name": "Dryer type",
"state": {
"electricity": "Electricity"
}
},
"dust": {
"name": "Dust"
@@ -876,7 +1147,23 @@
"name": "Probe temperature"
},
"progress": {
"name": "Progress"
"name": "Progress",
"state": {
"idle": "Idle",
"weightsensing": "Weight sensing",
"wash": "Washing",
"rinse": "Rinsing",
"spin": "Spinning",
"finish": "Finished",
"steaming": "Steaming",
"airwashing": "Air washing",
"drying": "Drying",
"dryingwithdooropen": "Venting",
"cooling": "Cooling",
"predrain": "Pre-drain",
"prewash": "Pre-wash",
"sanitizing": "Sanitizing"
}
},
"progress_percentage": {
"name": "Progress percent"
@@ -945,6 +1232,18 @@
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"zone_temperature": {
"name": "Zone temperature"
},
"watertank_full_alarm_status": {
"name": "Water tank full alarm status"
},
"air_filter_pm1_usage": {
"name": "PM1 filter usage"
},
"air_filter_pm1_usage_hours": {
"name": "PM1 filter usage hours"
}
},
"switch": {
@@ -963,6 +1262,9 @@
"absence_power_saving_active": {
"name": "Absence power saving active"
},
"absence_clean": {
"name": "Absence clean"
},
"motion_detect_wind_active": {
"name": "Motion-detect wind avoidance active"
},
@@ -972,6 +1274,12 @@
"display": {
"name": "Display"
},
"periodic_air_sensing": {
"name": "AI Purify"
},
"periodic_sensing_skip_status": {
"name": "AI Purify sensing skip"
},
"pet_filter_activation": {
"name": "Pet filter activation"
},
@@ -990,6 +1298,12 @@
"auto_door_opener": {
"name": "Auto door opener"
},
"auto_door_sound_control": {
"name": "Auto door sound"
},
"auto_door_voice_control": {
"name": "Auto door voice control"
},
"auto_release_dry": {
"name": "Auto release dry"
},
@@ -1056,6 +1370,18 @@
"intensive": {
"name": "Intensive"
},
"add_wash_alarm": {
"name": "AddWash alarm"
},
"add_wash_alarm_rinse": {
"name": "AddWash rinse"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash final rinse"
},
"add_wash_alarm_spin": {
"name": "AddWash spin"
},
"lamp": {
"name": "Lamp"
},
@@ -1106,6 +1432,33 @@
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
},
"zone_power": {
"name": "Zone power"
},
"away_mode": {
"name": "Away mode"
},
"watertank_light": {
"name": "Water tank light"
},
"uv_led": {
"name": "UV LED"
},
"ventilation_alarm": {
"name": "Ventilation alarm"
},
"edge_lighting": {
"name": "Edge lighting"
},
"indicator_light": {
"name": "Indicator light"
},
"windfree": {
"name": "Wind-Free mode"
},
"windsleep": {
"name": "Sleep mode"
}
},
"time": {
@@ -1126,6 +1479,17 @@
},
"night_start": {
"name": "Night light start"
},
"sensing_skip_end": {
"name": "AI Purify sensing skip end"
},
"sensing_skip_start": {
"name": "AI Purify sensing skip start"
}
},
"water_heater": {
"dhw": {
"name": "Hot water"
}
}
},
@@ -1157,10 +1521,18 @@
},
"confirm_unknown_type": {
"title": "Appliance type not recognized",
"description": "This appliance's type couldn't be recognized. 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."
"description": "This appliance reported model \"{model}\", which isn't a type LocalThings recognizes yet. 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": {
"no_response": "No response from that IP address — nothing came back on any port in the appliance's local API range (UDP 49152-49160). Check the IP address is correct, the appliance is powered on and on the same network as Home Assistant, and that no firewall is dropping UDP traffic to it.",
"ports_closed": "That IP address is reachable, but it actively refused every port in the local API range (UDP 49152-49160). Either it isn't a Samsung appliance, or it's on older firmware that only talks to Samsung's cloud over TCP 8888, which this integration can't use.",
"no_dtls_server": "Ports in the local API range are reachable at that address, but nothing there answered a DTLS handshake. Check that the IP address belongs to the appliance and not another device on your network.",
"handshake_timeout": "The appliance answered on its local API port but never finished the DTLS handshake. It is usually still holding the session from a previous attempt — wait about a minute and try again.",
"cert_rejected": "The appliance rejected the certificate. The CA certificate and key are most likely not the AC14K_M CA this appliance trusts, or they don't pair. The Home Assistant log records the exact alert the appliance sent.",
"handshake_failed": "The appliance refused the DTLS handshake for a reason unrelated to the certificate, most likely a protocol or cipher mismatch. The Home Assistant log records the exact alert it sent.",
"cloud_unreachable": "Couldn't reach Samsung's cloud gateway to fetch the UUID needed to mint this device's certificate. Check Home Assistant's internet access and try again.",
"unexpected_response": "Connected to the device successfully, but it didn't return a usable device description. It may not be an appliance this integration supports. The Home Assistant log records what it sent.",
"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."
@@ -1175,17 +1547,25 @@
"title": "LocalThings options",
"menu_options": {
"settings": "Device settings",
"cloud_courses": "Download cycles",
"forget_learned_modes": "Forget remembered modes",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control 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. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control 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. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.\n\nA laundry device with cloud-downloaded cycles gets a reminder here once it's reported one Home Assistant can't offer yet -- some devices report one even if you've never opened the SmartThings app's \"Download cycles\" screen yourself. Turn this off to stop that reminder and hide any already-named downloaded cycles from the cycle list; nothing already set up is lost, and turning it back on brings it right back.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
"learn_device_modes": "Remember modes the device reports but doesn't advertise",
"cloud_courses_enabled": "Offer downloaded cycles"
}
},
"forget_learned_modes": {
"title": "Forget remembered modes",
"description": "Currently remembered: {codes}\n\nThese are modes this device reported itself in without listing them as supported, kept so they stay selectable. Forgetting them is the fix if one turned out to be bogus -- anything the device genuinely reports again will simply be remembered again, unless you also turn off \"Remember modes the device reports but doesn't advertise\" in Device settings."
},
"debug_write": {
"title": "Debug: write to a resource",
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
@@ -1207,20 +1587,63 @@
"debug_write": "Write another resource",
"finish": "Finish"
}
},
"cloud_manual": {
"title": "Download cycles",
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Step through each downloaded program in turn, pausing a few seconds on each so LocalThings can see it, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
"data": {
"download_course": "Download cycle course code"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Guided setup walks you through it: in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Pick a cycle there, then come back and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later. Naming works either way, but a named cycle is only offered as a selectable cycle while \"Offer downloaded cycles\" is on in Device settings.",
"menu_options": {
"cloud_guided": "Guided setup",
"cloud_manual": "Edit names"
}
},
"cloud_wait": {
"title": "Download cycles"
},
"cloud_name": {
"title": "Name this cycle",
"description": "The appliance's \"Download cycles\" slot now holds a new cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then in the SmartThings app's \"Download cycles\" screen pick the next one that isn't already marked \"Downloaded\". Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
"data": {
"name": "Name",
"download_course": "Download cycle course code"
}
},
"cloud_timeout": {
"title": "No cycle selected",
"description": "Nothing changed on the appliance. Make sure you tapped \"Download cycles\" in the SmartThings app -- a separate row from \"Cycle\", further down the screen -- and picked one that wasn't already marked \"Downloaded\", then tapped Done.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
"menu_options": {
"cloud_guided": "Wait again",
"cloud_finish": "Finish"
}
}
},
"error": {
"empty_payload": "Enter at least one field to write.",
"write_failed": "The write failed. Check the Home Assistant logs for details."
"write_failed": "The write failed. Check the Home Assistant logs for details.",
"cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.",
"cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list."
},
"abort": {
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
},
"progress": {
"cloud_wait": "In the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, that lists only the cycles this can detect. Pick one that isn't already marked \"Downloaded\", then tap Done.\n\nThe appliance doesn't need to be nearby or running the cycle -- just powered on and connected.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
}
},
"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."
},
"cloud_courses_undiscovered": {
"title": "Downloaded cycles not set up for {device_name}",
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded -- in the SmartThings app's \"Download cycles\" screen, not the \"Cycle\" one -- and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there.\n\nDon't use downloaded cycles? Go to Configure > Device settings instead and turn off \"Offer downloaded cycles\" to silence this reminder for good."
}
},
"exceptions": {
@@ -1241,6 +1664,30 @@
},
"intensive_unavailable_for_cycle": {
"message": "Intensive isn't available on the selected cycle."
},
"command_failed": {
"message": "The command to {href} failed even after reconnecting: {error}"
},
"debug_read_failed": {
"message": "The read from {href} failed: {error}"
},
"debug_too_many_writes": {
"message": "Provide between 1 and 10 writes."
},
"debug_settle_out_of_range": {
"message": "settle must be between 0 and 30 seconds."
},
"debug_verify_after_out_of_range": {
"message": "verify_after must be between 0 and 60 seconds."
},
"service_device_target_invalid": {
"message": "This service requires exactly one target device."
},
"service_device_not_found": {
"message": "No matching device was found for that target."
},
"service_device_not_loaded": {
"message": "This device isn't connected yet. Try again once it has loaded."
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean actief"
},
"after_run_active": {
"name": "Nadraaien actief"
},
@@ -105,6 +108,15 @@
},
"softener_low": {
"name": "Wasverzachter bijna op"
},
"add_wash_available": {
"name": "AddWash toegestaan"
},
"add_wash_indicator": {
"name": "AddWash beschikbaar"
},
"stick_ble_connected": {
"name": "Steel via BLE verbonden"
}
},
"button": {
@@ -114,6 +126,12 @@
"diagnosis_start": {
"name": "Diagnose starten"
},
"auto_clean_stop": {
"name": "Zelfreiniging stoppen"
},
"filter_time_reset": {
"name": "Filterteller op nul zetten"
},
"pause": {
"name": "Pauzeren"
},
@@ -143,11 +161,13 @@
"smart": "Slim",
"speed": "Snel",
"nano": "WindFree",
"sleep": "Slaap",
"nanosleep": "WindFree-slaap",
"longwind": "Lange wind",
"motionindirect": "Beweging indirect",
"motiondirect": "Beweging direct",
"drycomfort": "Droog comfort",
"dlightcool": "d'light Cool",
"2step": "2-Step"
}
}
@@ -188,6 +208,9 @@
"oven_setpoint": {
"name": "Instelpunt"
},
"sensing_interval": {
"name": "AI Purify-interval"
},
"setpoint": {
"name": "Instelpunt"
},
@@ -199,6 +222,9 @@
},
"tropical_night_mode": {
"name": "Tropische nachtmodus"
},
"zone_target_temperature": {
"name": "Doeltemperatuur zone"
}
},
"select": {
@@ -208,6 +234,9 @@
"air_filter_threshold": {
"name": "Filteralarmdrempel"
},
"auto_door_timer": {
"name": "Timer automatische deuropener"
},
"beverage_zone_mode": {
"name": "Modus drankenzone",
"state": {
@@ -238,7 +267,11 @@
"name": "Zoemergeluid",
"state": {
"off": "Uit",
"on": "Aan"
"on": "Aan",
"volume_off": "Uit",
"volume_low": "Laag",
"volume_med": "Gemiddeld",
"volume_high": "Hoog"
}
},
"discharging_time": {
@@ -272,15 +305,24 @@
"name": "Programma",
"state": {
"80": "Delicaat",
"83": "Express 60",
"83": "Normaal",
"84": "Intensief",
"86": "Normaal",
"86": "Express 60",
"90": "Zelfreiniging",
"0e": "AI Wash",
"07": "Voorspoelen",
"8d": "Potten en pannen",
"8e": "Kunststof",
"8f": "Babyverzorging"
"8f": "Babyverzorging",
"82": "Auto",
"8a": "Normaal",
"a7": "Intensief",
"a8": "Express",
"8c": "Extra stil",
"88": "Zelfreiniging",
"85": "Delicaat",
"0c": "Express",
"0d": "Zelfreiniging"
}
},
"dispense_type": {
@@ -295,6 +337,35 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"9c": "Intensief",
"a5": "Beddengoed",
"9e": "Strijkvrij",
"9b": "Stoomhygiëne+",
"27": "Opfrissen",
"a0": "Luchtdrogen",
"a4": "Tijdprogramma",
"a6": "Snel drogen",
"a3": "Sportkleding",
"a2": "Fijne was",
"9a": "Katoen",
"ca": "Luchtverfrissing",
"db": "Super Speed",
"99": "Gemengde was",
"93": "Strijkdroog",
"b5": "Wol",
"d7": "Outdoorverzorging",
"96": "Koude lucht",
"97": "Warme lucht",
"7f": "Tijdprogramma",
"98": "Snel drogen 35",
"eb": "Fijne was",
"b6": "Synthetisch"
}
},
"dryer_cycle_table_03": {
"name": "Programma",
"state": {
@@ -319,7 +390,30 @@
"1e": "Outdoor",
"1f": "Gemengde was",
"2b": "Zelf trommel drogen",
"4c": "Opfrissen"
"4c": "Opfrissen",
"51": "Eco katoen",
"53": "AI drogen+",
"4e": "Zelf drogen",
"26": "Luchtverfrissing",
"2a": "Hygiënische verzorging+",
"02": "AI drogen",
"03": "Super Speed",
"05": "Beddengoed",
"07": "Fijne was",
"09": "Overhemden",
"0b": "Paddingverzorging",
"0c": "Waterafstotende outdoorverzorging",
"0e": "Handdoeken",
"0f": "Wol",
"11": "Koude lucht",
"28": "Interne desinfectie met hete lucht",
"37": "Blouses",
"38": "Strijkdroog",
"39": "Ruimte ontvochtigen",
"3a": "Hygiënisch drogen",
"3b": "Beddengoed/Stof verwijderen",
"3c": "Sportkleding",
"3d": "Spijkergoed"
}
},
"favorite_capacity": {
@@ -328,6 +422,15 @@
"favorite_hotwater_temperature": {
"name": "Favoriete temperatuur warm water"
},
"filter_alarm_time": {
"name": "Filteralarminterval",
"state": {
"180": "180 uur",
"300": "300 uur",
"500": "500 uur",
"700": "700 uur"
}
},
"finish_sound": {
"name": "Eindgeluid",
"state": {
@@ -432,9 +535,11 @@
"kimchi_storage_crunfch": "Knapperige kimchi",
"kimchi_storage_buy": "Gekochte kimchi",
"storage_fridge_normal": "Koelkast",
"storage_fridge": "Koelkast",
"storage_fridge_cold": "Koelkast, sterk",
"storage_fridge_warm": "Koelkast, zwak",
"storage_freezer_normal": "Vriezer",
"storage_freezer": "Vriezer",
"storage_freezer_cold": "Vriezer, sterk",
"storage_freezer_warm": "Vriezer, zwak",
"kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
@@ -447,7 +552,8 @@
"storage_fresh_cereal": "Granen",
"storage_fridge_drink": "Dranken",
"storage_fresh_wine": "Wijn",
"storage_fresh_potato_banana": "Aardappelen en bananen"
"storage_fresh_potato_banana": "Aardappelen en bananen",
"newmode_kimchi_0000": "Nieuwe modus"
}
},
"pantry_zone_mode": {
@@ -458,6 +564,16 @@
"fdr_drinks": "Dranken"
}
},
"winecellar_pantry_zone_mode": {
"name": "Modus voorraadzone",
"state": {
"processed_meat": "Vleeswaren",
"cheese": "Kaas",
"nuts": "Noten",
"fruit": "Fruit",
"wine": "Wijn"
}
},
"range_burner_power_level": {
"name": "Vermogensniveau brander {number}",
"state": {
@@ -476,6 +592,14 @@
"rinse_cycles": {
"name": "Spoelcycli"
},
"sensing_mode": {
"name": "AI Purify-actie",
"state": {
"off": "Alleen meten",
"airpurify": "Automatisch reinigen",
"alarm": "Melding ontvangen"
}
},
"softener_concentration": {
"name": "Concentratie wasverzachter",
"state": {
@@ -501,6 +625,9 @@
"mute": "Stil"
}
},
"energy_saving_mode": {
"name": "Energiebesparingsmodus"
},
"air_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
@@ -537,12 +664,51 @@
"extra_hot": "Extra heet"
}
},
"washer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"70": "Intensief",
"55": "Witte was",
"71": "Beddengoed",
"72": "Hygiëne",
"77": "Strijkvrij",
"57": "Self Clean+",
"73": "Spoelen + centrifugeren",
"74": "Sportkleding",
"75": "Fijne was",
"78": "Snelle was"
}
},
"washer_cycle_table_02": {
"name": "Programma",
"state": {
"01": "Normaal",
"02": "Extra intensief",
"03": "Super Eco",
"04": "Snelle was",
"05": "Wol/Lingerie",
"06": "Beddengoed",
"07": "Outdoor",
"08": "Spoelen+centrifugeren",
"09": "Trommel reinigen",
"0a": "Handdoeken",
"0b": "Kookwas",
"0c": "Babyverzorging",
"0d": "Alleen centrifugeren",
"0e": "Bewolkte dag",
"0f": "Zuivere was",
"10": "Droogcentrifugeren",
"11": "Zomer beddengoed",
"12": "Katoen",
"13": "Zwart katoen",
"14": "Fijn ondergoed",
"15": "Sportkleding",
"16": "Blouses",
"17": "Gedownload",
"18": "Soft Bubble",
"19": "AI Wash",
"1a": "Overhemden",
"1b": "Katoen",
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -552,7 +718,7 @@
"21": "Bonte was",
"22": "Wol",
"23": "Outdoor",
"24": "Handdoeken",
"24": "Beddengoed",
"25": "Synthetisch",
"26": "Fijne was",
"27": "Spoelen+centrifugeren",
@@ -565,8 +731,9 @@
"2f": "Sportkleding",
"30": "Bewolkte dag",
"32": "Overhemden",
"33": "Beddengoed",
"33": "Handdoeken",
"34": "Gemengd",
"35": "Eco katoen",
"36": "Wassen+drogen",
"37": "Air Wash",
"38": "Katoen drogen",
@@ -581,14 +748,34 @@
"60": "Self Clean+",
"65": "Bonte was",
"66": "Spijkergoed",
"69": "AI wassen",
"6a": "Wol",
"6b": "Spijkergoed",
"6c": "Blouses",
"6d": "Fijne was",
"6e": "Sportkleding",
"6f": "Beddengoed",
"70": "Handdoeken",
"71": "Snelle was",
"72": "Overhemden",
"73": "Hygiëne",
"74": "Trommel reinigen",
"75": "Outdoor",
"76": "Babyverzorging",
"77": "Katoen",
"78": "Spoelen + centrifugeren",
"79": "Alleen centrifugeren",
"7c": "Witte was",
"7d": "Beddengoed/waterdicht",
"7e": "Self Clean",
"7f": "Wol/fijne was",
"86": "Diep wassen",
"87": "Gedownload",
"88": "Huisdierverzorging",
"8f": "Intensief koud",
"96": "Minder microvezels"
"96": "Minder microvezels",
"a0": "15' Snelle was",
"b0": "Gemengde was"
}
},
"washer_dry_level": {
@@ -603,9 +790,69 @@
"none": "Uit",
"cupboard": "Kastdroog"
}
},
"zone_mode": {
"name": "Zonemodus"
},
"watertank_light_color": {
"name": "Kleur van het waterreservoirlicht",
"state": {
"warmwhite": "Warm wit",
"naturalwhite": "Natuurlijk wit",
"coolwhite": "Koel wit",
"green": "Groen",
"purple": "Paars",
"blue": "Blauw",
"orange": "Oranje",
"pink": "Roze"
}
},
"watertank_light_brightness": {
"name": "Helderheid van het waterreservoirlicht"
},
"air_filter_pm1_threshold": {
"name": "PM1-filteralarmdrempel"
},
"freezer_temperature_setpoint": {
"name": "Temperatuur vriesgedeelte"
},
"edge_lighting_mode": {
"name": "Randverlichtingsmodus",
"state": {
"smart": "Slim",
"high": "Hoog",
"low": "Laag"
}
},
"edge_lighting_color": {
"name": "Randverlichtingskleur",
"state": {
"3000k": "3000 K",
"4000k": "4000 K",
"6500k": "6500 K"
}
},
"indicator_light_mode": {
"name": "Indicatorlampjemodus",
"state": {
"smart": "Slim",
"high": "Hoog",
"low": "Laag"
}
},
"ventilation_mode": {
"name": "Modus",
"state": {
"purification": "Zuivering",
"ventilation": "Ventilatie",
"smartventilation": "Slimme ventilatie"
}
}
},
"sensor": {
"auto_clean_progress": {
"name": "Auto clean voortgang"
},
"after_run_progress": {
"name": "Voortgang nadraaien"
},
@@ -615,6 +862,9 @@
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"deodor_filter_usage": {
"name": "Filterverbruik"
},
"air_quality_standard": {
"name": "Luchtkwaliteitsnorm"
},
@@ -688,6 +938,12 @@
"stick_status": {
"name": "Status steel"
},
"stick_operation_mode": {
"name": "Bedieningsmodus steel"
},
"stick_cleaning_status": {
"name": "Schoonmaakstatus steel"
},
"uvc_operation_time": {
"name": "UV-C-bedrijfstijd"
},
@@ -722,6 +978,12 @@
"indirect": "Indirect"
}
},
"energy_saving_state": {
"name": "Energiebesparing status"
},
"energy_saving_operating_status": {
"name": "Bedrijfsstatus energiebesparing"
},
"current_temp_c": {
"name": "Temperatuur"
},
@@ -729,10 +991,16 @@
"name": "Temperatuur"
},
"diagnosis": {
"name": "Diagnose"
"name": "Diagnose",
"state": {
"ready": "Gereed"
}
},
"diagnosis_status": {
"name": "Diagnosestatus"
"name": "Diagnosestatus",
"state": {
"ready": "Gereed"
}
},
"drum_clean_cycles_remaining": {
"name": "Trommelreiniging over"
@@ -747,7 +1015,10 @@
"name": "Droogtijd"
},
"dryer_type": {
"name": "Type droger"
"name": "Type droger",
"state": {
"electricity": "Elektriciteit"
}
},
"dust": {
"name": "Stof"
@@ -876,7 +1147,23 @@
"name": "Sondetemperatuur"
},
"progress": {
"name": "Voortgang"
"name": "Voortgang",
"state": {
"idle": "Inactief",
"weightsensing": "Beladingsdetectie",
"wash": "Wassen",
"rinse": "Spoelen",
"spin": "Centrifugeren",
"finish": "Voltooid",
"steaming": "Stomen",
"airwashing": "Luchtreiniging",
"drying": "Drogen",
"dryingwithdooropen": "Ventileren",
"cooling": "Koelen",
"predrain": "Vooraf afpompen",
"prewash": "Voorwas",
"sanitizing": "Ontsmetten"
}
},
"progress_percentage": {
"name": "Voortgangspercentage"
@@ -945,6 +1232,18 @@
"icestatus_stop": "Inactief",
"icestatus_run": "IJs maken"
}
},
"zone_temperature": {
"name": "Zonetemperatuur"
},
"watertank_full_alarm_status": {
"name": "Status alarm waterreservoir vol"
},
"air_filter_pm1_usage": {
"name": "PM1-filtergebruik"
},
"air_filter_pm1_usage_hours": {
"name": "PM1-filtergebruik in uren"
}
},
"switch": {
@@ -963,6 +1262,9 @@
"absence_power_saving_active": {
"name": "Energiebesparing bij afwezigheid actief"
},
"absence_clean": {
"name": "Reiniging bij afwezigheid"
},
"motion_detect_wind_active": {
"name": "Bewegingsdetectie luchtstroomvermijding actief"
},
@@ -972,6 +1274,12 @@
"display": {
"name": "Display"
},
"periodic_air_sensing": {
"name": "AI Purify"
},
"periodic_sensing_skip_status": {
"name": "AI Purify meting overslaan"
},
"pet_filter_activation": {
"name": "Huisdierfilter actief"
},
@@ -990,6 +1298,12 @@
"auto_door_opener": {
"name": "Automatische deuropener"
},
"auto_door_sound_control": {
"name": "Geluid automatische deuropener"
},
"auto_door_voice_control": {
"name": "Spraakbediening automatische deuropener"
},
"auto_release_dry": {
"name": "Auto Release Dry"
},
@@ -1056,6 +1370,18 @@
"intensive": {
"name": "Intensief"
},
"add_wash_alarm": {
"name": "AddWash-melding"
},
"add_wash_alarm_rinse": {
"name": "AddWash bij spoelen"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash laatste spoeling"
},
"add_wash_alarm_spin": {
"name": "AddWash centrifugeren"
},
"lamp": {
"name": "Lamp"
},
@@ -1106,6 +1432,33 @@
},
"wrinkle_prevent": {
"name": "Kreukpreventie"
},
"zone_power": {
"name": "Zonevoeding"
},
"away_mode": {
"name": "Afwezigheidsmodus"
},
"watertank_light": {
"name": "Waterreservoirlicht"
},
"uv_led": {
"name": "Uv-led"
},
"ventilation_alarm": {
"name": "Ventilatiealarm"
},
"edge_lighting": {
"name": "Randverlichting"
},
"indicator_light": {
"name": "Indicatorlampje"
},
"windfree": {
"name": "Wind-Free modus"
},
"windsleep": {
"name": "Slaapmodus"
}
},
"time": {
@@ -1126,6 +1479,17 @@
},
"night_start": {
"name": "Start nachtverlichting"
},
"sensing_skip_end": {
"name": "Einde AI Purify meting overslaan"
},
"sensing_skip_start": {
"name": "Start AI Purify meting overslaan"
}
},
"water_heater": {
"dhw": {
"name": "Warm water"
}
}
},
@@ -1157,10 +1521,18 @@
},
"confirm_unknown_type": {
"title": "Apparaattype niet herkend",
"description": "Het apparaattype van dit apparaat kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
"description": "Dit apparaat meldt model \"{model}\", een type dat LocalThings nog niet herkent. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
}
},
"error": {
"no_response": "Geen reactie van dat IP-adres — er kwam niets terug op enige poort in het bereik van de lokale API van het apparaat (UDP 49152-49160). Controleer of het IP-adres klopt, of het apparaat aanstaat en op hetzelfde netwerk zit als Home Assistant, en of geen firewall het UDP-verkeer ernaartoe blokkeert.",
"ports_closed": "Dat IP-adres is bereikbaar, maar weigerde actief elke poort in het bereik van de lokale API (UDP 49152-49160). Het is óf geen Samsung-apparaat, óf het draait oudere firmware die alleen via TCP 8888 met de cloud van Samsung praat, wat deze integratie niet kan gebruiken.",
"no_dtls_server": "De poorten in het bereik van de lokale API zijn bereikbaar op dat adres, maar niets daar beantwoordde een DTLS-handshake. Controleer of het IP-adres bij het apparaat hoort en niet bij een ander apparaat in je netwerk.",
"handshake_timeout": "Het apparaat antwoordde op zijn lokale API-poort, maar voltooide de DTLS-handshake niet. Meestal houdt het de sessie van een eerdere poging nog vast — wacht ongeveer een minuut en probeer het opnieuw.",
"cert_rejected": "Het apparaat heeft het certificaat geweigerd. Het CA-certificaat en de sleutel zijn waarschijnlijk niet van de AC14K_M-CA die dit apparaat vertrouwt, of ze horen niet bij elkaar. Het Home Assistant-logboek bevat de precieze alert die het apparaat stuurde.",
"handshake_failed": "Het apparaat weigerde de DTLS-handshake om een reden die niets met het certificaat te maken heeft, hoogstwaarschijnlijk een protocol- of cipher-mismatch. Het Home Assistant-logboek bevat de precieze alert die het stuurde.",
"cloud_unreachable": "Kon de cloudgateway van Samsung niet bereiken om de UUID op te halen die nodig is om het certificaat van dit apparaat aan te maken. Controleer de internettoegang van Home Assistant en probeer het opnieuw.",
"unexpected_response": "Verbinding met het apparaat is gelukt, maar het gaf geen bruikbare apparaatbeschrijving terug. Mogelijk is het geen apparaat dat deze integratie ondersteunt. Het Home Assistant-logboek bevat wat het stuurde.",
"cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.",
"invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.",
"unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie."
@@ -1175,17 +1547,25 @@
"title": "LocalThings-opties",
"menu_options": {
"settings": "Apparaatinstellingen",
"cloud_courses": "Gedownloade programma's",
"forget_learned_modes": "Onthouden modi vergeten",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
},
"settings": {
"title": "Apparaatinstellingen",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden. Een wasapparaat met uit de cloud gedownloade programma's krijgt hier een herinnering zodra het er een meldt die Home Assistant nog niet kan aanbieden -- sommige apparaten melden er een, ook als je het scherm \"Gedownloade programma's\" in de SmartThings-app zelf nooit hebt geopend. Zet dit uit om die herinnering te stoppen en al benoemde gedownloade programma's uit de programmalijst te verbergen; niets van wat al is ingesteld gaat verloren, en bij opnieuw inschakelen verschijnt het meteen weer.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft",
"cloud_courses_enabled": "Gedownloade programma's aanbieden"
}
},
"forget_learned_modes": {
"title": "Onthouden modi vergeten",
"description": "Nu onthouden: {codes}\n\nDit zijn modi waarin dit apparaat zichzelf meldde zonder ze als ondersteund op te geven; ze worden bewaard zodat ze selecteerbaar blijven. Vergeten is de oplossing als er een onterecht tussen staat: alles wat het apparaat echt opnieuw meldt, wordt gewoon opnieuw onthouden, tenzij je ook \"Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft\" bij Apparaatinstellingen uitzet."
},
"debug_write": {
"title": "Foutopsporing: naar een resource schrijven",
"description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.",
@@ -1207,20 +1587,63 @@
"debug_write": "Naar een andere resource schrijven",
"finish": "Voltooien"
}
},
"cloud_manual": {
"title": "Gedownloade programma's",
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden zodat LocalThings het kan zien, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
"data": {
"download_course": "Programmacode van \"Gedownload\""
}
},
"cloud_courses": {
"title": "Gedownloade programma's",
"description": "Begeleide installatie loodst je stap voor stap: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Kies daar een programma, kom terug en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren. Namen geven werkt sowieso, maar een benoemd programma wordt alleen als selecteerbaar programma aangeboden zolang \"Gedownloade programma's aanbieden\" aan staat in de apparaatinstellingen.",
"menu_options": {
"cloud_guided": "Begeleide installatie",
"cloud_manual": "Namen bewerken"
}
},
"cloud_wait": {
"title": "Gedownloade programma's"
},
"cloud_name": {
"title": "Geef dit programma een naam",
"description": "Het vak \"Gedownloade programma's\" van het apparaat bevat nu een nieuw programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en kies daarna in het scherm \"Gedownloade programma's\" van de SmartThings-app het volgende programma dat nog niet als \"Gedownload\" is gemarkeerd. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
"data": {
"name": "Naam",
"download_course": "Programmacode van \"Gedownload\""
}
},
"cloud_timeout": {
"title": "Geen programma geselecteerd",
"description": "Er is niets veranderd op het apparaat. Zorg dat je in de SmartThings-app op \"Gedownloade programma's\" hebt getikt -- een aparte regel, los van \"Programma\", verderop op het scherm -- en een programma hebt gekozen dat nog niet als \"Gedownload\" was gemarkeerd, en daarna op Klaar hebt getikt.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
"menu_options": {
"cloud_guided": "Opnieuw wachten",
"cloud_finish": "Voltooien"
}
}
},
"error": {
"empty_payload": "Voer ten minste één veld in om te schrijven.",
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie."
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie.",
"cloud_course_name_duplicate": "Twee programma's hebben dezelfde naam. Namen moeten uniek zijn.",
"cloud_course_unknown_course": "Dat programma biedt dit apparaat niet aan. Kies er een uit de lijst."
},
"abort": {
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
},
"progress": {
"cloud_wait": "Open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, die alleen de programma's toont die op deze manier te herkennen zijn. Kies er een die nog niet als \"Gedownload\" is gemarkeerd en tik op Klaar.\n\nHet apparaat hoeft niet in de buurt te zijn of het programma uit te voeren -- het moet alleen aan staan en verbonden zijn.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
}
},
"issues": {
"device_gap": {
"title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}",
"description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template."
},
"cloud_courses_undiscovered": {
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien -- in het scherm \"Gedownloade programma's\" van de SmartThings-app, niet in \"Programma\" -- en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar.\n\nGebruik je geen gedownloade programma's? Ga dan naar Configureren > Apparaatinstellingen en zet \"Gedownloade programma's aanbieden\" uit om deze herinnering voorgoed te stoppen."
}
},
"exceptions": {
@@ -1241,6 +1664,30 @@
},
"intensive_unavailable_for_cycle": {
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
},
"command_failed": {
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
},
"debug_read_failed": {
"message": "Het lezen van {href} is mislukt: {error}"
},
"debug_too_many_writes": {
"message": "Geef tussen de 1 en 10 schrijfacties op."
},
"debug_settle_out_of_range": {
"message": "settle moet tussen 0 en 30 seconden liggen."
},
"debug_verify_after_out_of_range": {
"message": "verify_after moet tussen 0 en 60 seconden liggen."
},
"service_device_target_invalid": {
"message": "Deze service vereist precies één doelapparaat."
},
"service_device_not_found": {
"message": "Er is geen bijpassend apparaat gevonden voor dat doel."
},
"service_device_not_loaded": {
"message": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
}
}
}
@@ -0,0 +1,258 @@
"""Water heater platform for Local Things.
Second composite entity in this integration (see climate.py's module
docstring for the general pattern): a single HA water_heater card for a
Samsung EHS heat pump's domestic hot water (DHW) loop. It binds the primary
`WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities in
registry/capabilities/ehs.py) and reads the sibling `/power/dhw/vs/0` and
`/temperatures/dhw/vs/0` resources straight from the coordinator snapshot,
the same cross-resource read climate.py uses.
Writes go through `coordinator.async_send_command`: DHW's `write_fn`
(ehs._dhw_write) maps each `(kind, value)` payload to the right
`(path_segs, body)`, applying the optimistic value/settle guard to that
resource's own href rather than the bound `/mode/dhw/vs/0` href.
Operation-mode vocabulary: the DHW loop's four device 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
(`samsungce.ehsThermostat`), just title-cased to match this OCF resource's
spelling. Reusing HA's standard states means no state translation catalog
entry is needed for them.
Naming differs from climate.py's: the AC *is* the device, so its card takes
the bare device name. An EHS unit has two loops, and DHW isn't "the
device" (siblings are named "Zone Mode"/"Zone Target Temperature"), so this
entity is named through the catalog via `translation_key='dhw'`
(entity.water_heater.dhw.name -> "Hot water").
"""
from __future__ import annotations
import logging
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_HEAT_PUMP,
STATE_HIGH_DEMAND,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.common import normalize_temp_unit
from .registry.capabilities.ehs import (
HREF_DHW_MODE as MODE_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_POWER as POWER_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
)
from .registry.entities import WaterHeaterDesc
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# Device mode <-> HA water_heater operation state -- see module docstring.
_DEVICE_TO_STATE: dict[str, str] = {
"Eco": STATE_ECO,
"Std": STATE_HEAT_PUMP,
"Force": STATE_HIGH_DEMAND,
"Power": STATE_PERFORMANCE,
}
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
# Read-side lookup, case-folded: this map is bijective (unlike climate.py's
# 'Wind'/'Fan' -> FAN_ONLY), so the write side uses _STATE_TO_DEVICE
# directly; only the read side needs to absorb a board spelling the same
# code differently ('eco'/'ECO').
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
def _to_state(code) -> str | None:
if code is None:
return None
return _DEVICE_TO_STATE_CI.get(str(code).lower())
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsWaterHeater(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
)
def _num(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _first(value):
"""Samsung `modes` is a single-element list on this resource. Return the
first element of a list, else the value itself."""
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
"""Composite water_heater entity for a Samsung EHS DHW loop."""
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# No _attr_name here: unlike climate.py's AC, this is one loop of a
# two-loop device and takes a catalog name through translation_key.
self._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.ON_OFF
)
# Raw device codes already logged by _warn_unmapped -- read on every
# refresh, so un-deduped would spam the log for an unrecognized code.
self._warned_unmapped: set[str] = set()
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants,
translated through this bound entity's own subdevice (issue #177),
same as climate.py's identical helper."""
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
def _is_on(self) -> bool:
return str(self._rep(POWER_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
def _supported(self) -> list[str]:
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, code: str) -> None:
if code in self._warned_unmapped:
return
self._warned_unmapped.add(code)
_LOGGER.warning(
"%s: device DHW mode %r has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id,
code,
)
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
@property
def current_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.current"))
@property
def target_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
def _range(self) -> list | None:
"""The device's own (minimum, maximum) pair, or None. Both ends
together or neither -- same rule as climate._range(); a board
reporting only minimum would otherwise pair it with HA's own
default maximum, silently wrong."""
rep = self._rep(TEMPERATURE_HREF)
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
def min_temp(self) -> float:
r = self._range()
return r[0] if r else super().min_temp
@property
def max_temp(self) -> float:
r = self._range()
return r[1] if r else super().max_temp
@property
def target_temperature_step(self) -> float:
# `is None`, not `or` -- `or` would collapse a genuine 0 (issue #160).
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
return 0.5 if step is None else step
# -- operation mode -------------------------------------------------------
@property
def current_operation(self) -> str | None:
if not self._is_on():
return STATE_OFF
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
mapped = _to_state(code)
if code is not None and mapped is None:
self._warn_unmapped(code)
return mapped
@property
def operation_list(self) -> list[str]:
modes = [STATE_OFF]
for code in self._supported():
mapped = _to_state(code)
if mapped is None:
self._warn_unmapped(code)
continue
if mapped not in modes:
modes.append(mapped)
return modes
# -- writes ---------------------------------------------------------------
async def async_set_temperature(self, **kwargs) -> None:
# HA's water_heater.set_temperature service can carry an optional
# operation_mode; honor it, setting the mode first (which also
# powers the loop on) so a dashboard "boost to 55" button that
# carries a mode actually changes mode, not just the setpoint. Same
# fix as climate.async_set_temperature.
operation_mode = kwargs.get("operation_mode")
if operation_mode is not None:
await self.async_set_operation_mode(operation_mode)
if operation_mode == STATE_OFF:
return
temp = kwargs.get("temperature")
if temp is None:
return
await self.coordinator.async_send_command(self._bound, ("temperature", temp))
async def async_set_operation_mode(self, operation_mode: str) -> None:
if operation_mode == STATE_OFF:
await self.coordinator.async_send_command(self._bound, ("power", False))
return
device = _STATE_TO_DEVICE.get(operation_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ("power", True))
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ("power", False))
+169
View File
@@ -0,0 +1,169 @@
# AC filter-time counter reset: solved
`registry/capabilities/airconditioner.py`'s `filter_time` sensor
(`FilterTime_<N>` option token, tenths of an hour) has a reset entity now
(`filter_time_reset`, issue-tracked as PR #289): a single-token options write
of `FilterCleanAlarm_Clear` to `/mode/vs/0`, the same merge every other
setting on that href uses. Measured on an ARTIK051_KRAC_18K: `FilterTime_95`
(9h30m) → `FilterTime_0`, still zero on a fresh DTLS session and every poll
after; none of the other 17 tokens moved and the `/alarms/vs/0` entries
stayed `Deleted`.
The rest of this file is kept as-is: the failed attempts below are still the
best record of what *doesn't* work on this generation, and the reasoning
that follows them explains why the reset looked cloud-only for as long as it
did — a genuine trap worth knowing about before the next reset-adjacent
mystery on this board family.
Scope: all of the above applies to boards that carry the counter as a
`FilterTime_<N>` option token on `/mode/vs/0`. Not every AC does. See
"Boards with no `FilterTime` token" at the end for an `ARTIK051_PRAC_20K`
that keeps the counter in its own resource, rejects both the token and a
direct write, and has no local reset at all.
## What the reset actually is
A **command**, not a value write. Samsung's cloud models it as capability
`custom.dustFilter`, command `resetDustFilter`, no arguments (implemented in
several SmartThings HA forks; not in the core integration) — that command
name is real, but see below for why POSTing it directly went nowhere.
## Tried, all against a live unit, all failed (before the token above was found)
- `FilterTime_0` via the single-token options merge that works for every
other setting on this href — accepted with no error, then discarded. Tried
on two units in opposite power states to rule out the obvious confound:
5595 → back to 5595 after 69s (powered off, alarm active), 1925 → back to
1925 after 65s (actively cooling).
- A full `options[]` read-modify-write with `FilterTime_0` substituted,
instead of the single-token merge — zero fields changed anywhere.
- A write to `/consumable/vs/0`, the board's own filter resource
(`items[{name: FilterProgress, state: N}]`) — discarded. `/oic/res`
declares that resource `oic.if.s` (read-only), which fits.
- `/actions/vs/0` (`x.com.samsung.da.actions`, `oic.if.a`) is the obvious
local command channel but publishes no schema: GET returns `{}` on
baseline and on `oic.if.a`, and five POSTs probing the shape (empty map,
empty string, empty array, invalid value, items shape) all returned 4.00
with an empty body — no echo of accepted field names, unlike the laundry
firmware's `"Control fail, <...>"`. Guessed action names were deliberately
not enumerated against a live appliance: an unknown vocabulary on a
channel called "actions" can hold a factory reset next to the one we want.
- `/hass/state/vs/0` and `/hass/command/vs/0` (advertised in `/oic/res`, and
`/opt/data/hass.db` exists in `/file/list`) → 4.04 on every interface, so
unimplemented scaffolding on this firmware.
- `/file/transfer/vs/0` serves only `/mnt/usage.db`; selecting another path
returns 4.05/4.00, so the firmware can't be pulled that way to read the
action vocabulary out of it.
- `/rm/micomdata/vs/0` (channel toward the MICOM board the physical panel
talks to) stays empty even after successfully enabling remote management.
## What the failures are not
Not a transport, permission, or cert problem: a control write of `rmState`
on `/rm/state/vs/0` was accepted (2.04 Changed, value held, restored
afterwards), and `FilterAlarmTime_` is written through the very same options
merge and kept. Writes work; this one value just isn't driven that way.
## The token, and why the dead ends below missed it
`FilterCleanAlarm_Clear` is not derived from anything in this file's earlier
attempts — how it was originally identified isn't recorded here. What is
recorded is why the standard technique (diff the appliance's reported state
before/after triggering the action in Samsung's app) couldn't have found it
on its own: the token is a trigger, never stored and never echoed back in
`x.com.samsung.da.options`, so a before/after diff of stored state shows
only the *effects* (counter zeroing, alarm clearing) and never the token
that caused them.
## Dead ends tried before the token was known (kept for the next unrelated mystery)
- The `/actions/vs/0` action vocabulary from an independent source (a
firmware image, or a capture of what the cloud sends the device).
- The IR path — the physical remote has a filter reset (Options → Filter
Reset → SET), and IRremoteESP8266 decodes this AC family, though issue
#1277's dump doesn't include that button.
## Evidence for the counter's direction and scale
Confirmed counting *up* (running time since last reset, not remaining time):
token 1710 matched the Samsung app's "171 hours 0 minutes" for the same
filter (pins the tenths-of-an-hour scale); seen rising while the unit ran
(171.0 → 171.5); and across two units on one site the `/alarms/vs/0` filter
alarm tracks the counter in the right direction — live (`FilterAlarm`,
`Created`) at `FilterTime_5595`, still the `FilterAlarm_OFF`/`Deleted`
placeholder at `FilterTime_1915`, matching the app's own 500-hour threshold
behavior. `FilterAlarmTime_` in the same options blob is that threshold (500
on every unit on record).
The entity key stays `filter_time` rather than `filter_time_elapsed`:
renaming it would change every existing unit's `entity_id`/`unique_id` for a
wording improvement only.
## Boards with no `FilterTime` token (`ARTIK051_PRAC_20K`)
Negative result, measured 2026-08-11 on integration v0.21.0 / HA 2026.8.1,
against one head of a three-head multi-split (`OptionCode_35880`,
`ExtendOptionCode_199181`). **There is no local reset on this board**, by
either route. Reset appears to be panel-only.
This generation does not put the counter in `/mode/vs/0` at all. Its
options blob carries no `FilterTime`, no `FilterAlarmTime` and no
`FilterCleanAlarm`:
```json
["Sleep_0", "ArtificialWorking_Off", "ComfortAICooling_Off",
"AiTempChanged_Off", "AiTemp_240", "OutdoorTemp_77", "CoolCapa_25",
"WarmCapa_32", "Light_Off", "Volume_100", "OptionCode_35880",
"ExtendOptionCode_199181", "RacInfo_None", "UpdateAllow_NotAllowed",
"DurationOn_0", "WelcomeCoolingState_Off"]
```
The counter lives in its own resource instead, as a **percentage** of a
500-hour interval rather than tenths of an hour —
`/filter/airdustfilter/vs/0`:
```json
{
"x.com.samsung.da.filterUsage": "96",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
}
```
Three attempts, all against a live unit deliberately put in `fan_only`
first — writes to a powered-off head on this board are dropped silently
with no error, which would otherwise be indistinguishable from a rejected
write:
| Target | Payload | Result |
|---|---|---|
| `/mode/vs/0` | `{"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]}` | 4.00, options blob byte-identical |
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": "0"}` | 4.00 |
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": 0}` (integer) | 5.00 |
`filterUsage` stayed at `96` throughout, verified by a live re-read after
each write rather than by the integration's optimistic state.
The last two rows are the informative pair. They differ only in JSON type
and return *different* codes, which rules out both boring explanations: an
unresolved href or an unrecognised field name would fail identically. The
board parses the field, faults on the wrong type, and still refuses the
value when typed as the string its own rep uses. The resource is
**read-only**, not mis-addressed.
One trap worth stating plainly: `filterResetType:
["replaceable","washable"]` describes what the filter *is*, not a reset
command that exists. It reads like a hint that a reset write is available
somewhere. It is not.
Since the integration cannot perform the reset here, it can still observe
it. The counter only climbs in normal use, so a downward crossing is
unambiguous: a `numeric_state` trigger with `below: 10` on
`sensor.<name>_filter_usage`, stamping an `input_datetime`, keeps an
honest "last cleaned" date without pretending a reset entity exists. The
blind spot is a reset performed while HA is down or the entry is
unloaded — no state transition, so that stamp has to be set by hand.
@@ -0,0 +1,184 @@
# Composite AC subdevices: where else a sibling's hrefs could live
Open question behind issue #335 (`ARTIK051_FAC_BORA_19K`, a 2-in-1 floor +
wall AC): the board reports a sibling in `/subdevices/vs/0`'s
`subdeviceIdList`, but every seed `registry/subdevices.enumerate_subdevices`
tries comes back 4.04, so `subdevices` and `subdevices_skipped` are both
empty and the wall unit never becomes an entity.
This file records what that actually rules out (less than it looks like),
why, and which hrefs are worth reading next.
## `/oic/res` does not enumerate the resource tree on modern firmware
This is the finding that reopens the question. Across every fixture that
carries a captured `/oic/res`:
| board | links | `sec:true` | lists `/device/0`? |
| --- | --- | --- | --- |
| `ARTIK051_DONGLE_FAC_18K` | 91 | 78 | yes |
| `TP2X_FAC_BORA_21K` (2-in-1) | 17 | 6 | no |
| `TP2X_FAC_BORA_21K` (#205 flat) | 17 | 6 | no |
| `TP1X_DA_KS_RANGE_0101X` | 10 | 6 | no |
| `AWM-WW-AID-26-ONEBODY` | 15 | 9 | no |
| `ARTIK051_FAC_BORA_19K` (#335) | 18 | 6 | no |
The `ARTIK051_DONGLE_FAC_18K` board — the one Pattern A was built against —
is the outlier, not the model. Everywhere else `/oic/res` lists the
onboarding surface and nothing else: `/oic/d`, `/oic/p`, the security and
EasySetup/WiFiConf/CoapCloudConf/DevConf resources, file transfer, and the
`sec/*` pair. On issue #335's board the six `sec:true` links are exactly
doxm, pstat and the four setup URIs; every other listed link is `sec:false`.
The entire secure operational tree — `/device/0` included, which
demonstrably answers, since the dump comes from it — is absent.
Two consequences, both load-bearing:
1. **Nothing is learned from an href's absence in `/oic/res`.** On the range
board (issue #324) `/oic/res` lists ten onboarding links and no
`/device/0`, yet `/device/1` answers a full indexed dual-cavity sibling.
It was found only by `_SPECULATIVE_DEVICE_INDICES`, never by enumeration
of the links.
2. **Pattern A's `/oic/res` index scan is dead weight on these boards.** It
contributes nothing anywhere except the dongle board, so in practice
indexed siblings are found by the speculative `/device/1`, `/device/2`
probe alone.
## What issue #335 has actually ruled out
All 26 probes in the report returned false. Twenty-three of them are the
issue #205 flat fallback walking the master's own href list under the
sibling's UUID prefix, plus `/<uuid>/device/0`, `/device/1`, `/device/2`
and `/multidevice/vs/0`. Four more were read by hand from the issue thread
(`/<uuid>/information/vs/{1,2}`, `/<uuid>/device/{1,2}`), all 4.04.
So what is ruled out is: the UUID-prefixed namespace (Pattern B/C), and the
indexed **Collection** (`/device/<n>`). What has never been read on this
board — or on any `FAC_BORA` board — is **a bare indexed leaf**:
`/mode/vs/1`, `/temperatures/vs/1`, and friends. Every indexed href ever
probed by this project arrived via a `/device/<n>` batch; none was ever
GETed directly.
That gap matters because the "leaves exist, their Collection does not" shape
is already confirmed on this exact product family, just in the other
namespace: issue #205's `TP2X_FAC_BORA_21K` answers
`/<uuid>/information/vs/0` while `/<uuid>/device/0` comes back empty. A
board that mounts sibling leaves without mounting a sibling Collection is
the documented BORA behavior, so `/device/1`'s 4.04 is evidence about the
Collection and not about `/mode/vs/1`.
## What the OCF spec says about composite devices
The Core/Device specifications model this as a *Composite Device*: one
Platform representing the whole appliance, `/oic/d` carrying the Device
Types of every constituent Device, and — the relevant part — a **Collection
per distinct Device in the composition**, each Collection's `rt` including
the Device Type it represents.
Issue #335's `/oic/d` reports `["oic.wk.d", "oic.d.airconditioner"]`, which
is consistent with a two-indoor-unit composite (both constituents are air
conditioners, so the type appears once) and equally consistent with a single
unit. It does not discriminate.
The Collection half does suggest something untried. `x.com.samsung.devcol`
is Samsung's Collection type, carried by `/device/0` — and on the dongle
board `/oic/res` advertises a second resource with the same
`["x.com.samsung.devcol", "oic.wk.col"]` pair: **`/sec/devices`**. A
collection of devices, sitting alongside `/device/0`, never read by this
project or by any issue thread. If the composite enumeration is exposed
anywhere as a first-class resource, that is the shape it would take.
## Results of the second probe round
The reporter ran these live. Three answers, all informative.
**Indexed leaves do not exist.** `/information/vs/1`, `/power/vs/1`,
`/mode/vs/1` → 4.04. Pattern A is ruled out on this board properly now:
not just the `/device/1` Collection, but the leaf namespace it would have
carried.
**The UUID prefix routes, and is empty of operational resources.** The
control pair settles it:
/c24e25e9-.../file/list/vs/0 → 2.05, two items
/file/list/vs/0 → 2.05, the same two items
(/opt/data/energy.db, /opt/data/hass.db)
So the sibling's prefix is a live, routed namespace — the 23 flat-fallback
4.04s under it are the firmware answering "no such resource", not a dead
prefix swallowing everything. Pattern B/C is ruled out on this board on
positive evidence rather than on absence. That the two listings are
identical is expected either way: one board, one flash, one filesystem.
**`/sec/devices` exists — and this project could not see what's in it.**
It answered `2.05` with `rep: {}`, which reads as "the resource is there and
has nothing in it". It is not. `coordinator._raw_read_blocking` decoded the
CBOR body and then kept it *only if it was a Property map*:
```python
if isinstance(body, dict):
rep = body
```
A Collection answers a **list** — the `[devcol rep, {href, rep}, ...]` batch
`parse_device0_batch` reads. `/device/0` itself would have rendered exactly
the same accepted-but-empty `2.05 {}` through `read_resource`. Fixed: the
read path now returns the decoded body alongside `rep`, and the service
response carries it as `body` whenever it isn't the map already in `rep`.
`/sec/devices` therefore remains the one open lead, and needs one re-read on
a build carrying that fix.
## Still worth reading
**1 — `/sec/devices`, again.** Same `x.com.samsung.devcol` + `oic.wk.col`
pair as `/device/0`, so its body should be a batch naming its members. If a
composite enumeration is exposed anywhere, it is here.
**2 — the file-transfer pair.** `/oic/res` advertises
`/c24e25e9-.../file/transfer/vs/0` alongside the master's, and the prefix is
now known to route. Issue #301 documents the shape: a baseline GET returns
one item, `x.com.samsung.name` plus `x.com.samsung.blob`, no write needed to
see whatever it currently serves. If the prefixed endpoint serves *different
bytes* than the master's, that is the first hard local evidence the wall
unit exists as a data producer, and `/opt/data/energy.db` would be where its
runtime history lives.
/file/transfer/vs/0
/c24e25e9-55dd-ba18-d567-000000000001/file/transfer/vs/0
Mind the blob: #301 measured 2172 B on a `KRAC_18K`, and a raw `bytes` value
in a service response is not guaranteed to survive rendering in Developer
Tools. Ask for `x.com.samsung.name` and whether a blob field appears, not
for the blob pasted into a comment.
## Dead ends, so they aren't re-tried
- `/hass/state/vs/0`, `/hass/command/vs/0` — advertised in `/oic/res` on
every board here, and indexed per subdevice on the dongle board
(`/hass/state/vs/{0,1,2}`), which makes them look like a subdevice-aware
state channel. They are not: 4.04 on every interface on
`ARTIK051_KRAC_18K` (see `ac-filter-reset.md`). Cheap enough to retry once
on #335's newer build, but expect nothing.
- `/multidevice/vs/0` — probed, 4.04. Absent on this board; only the dongle
family exposes it.
- `/actions/vs/0` — GET returns `{}` on baseline and `oic.if.a`; publishes
no schema (`ac-filter-reset.md`).
## Where this lands if `/sec/devices` is empty too
Then the sibling is named in `subdeviceIdList` for the cloud's benefit and
has no local operational surface at all on this firmware — every namespace
it could occupy has now been read directly, and the UUID one was confirmed
routable first, so the negatives mean what they say. That closes issue #335
as a firmware limitation rather than leaving it open against a probe
strategy that was never actually exercised.
Worth keeping in view for the enumeration code either way: both remaining
patterns hinge on a Collection, and this board answers neither `/device/1`
nor a prefixed `/device/0`. An indexed flat-probe fallback — the mirror of
issue #205's prefixed one, gated on a board that claims a sibling but
materialized nothing — would have cost 8 round trips here and returned the
same 4.04s the reporter got by hand. It is worth building only if some
other board turns out to serve indexed leaves without their Collection;
this one does not.
+279
View File
@@ -0,0 +1,279 @@
# Laundry cloud "Download" cycles: solved, with one dead end
`registry/capabilities/laundry.py`'s cycle select offers a washer's
downloaded ("Download" / "Downloaded") programs alongside its ordinary
courses now, driven by the store in `cloudcourse.py` (issue #342). This file
records the byte-level work behind it, including a decode that fit one
device perfectly and collapsed on the second — the reason nothing in the
shipped code interprets a program payload at all.
Four devices in the corpus carry these tokens (a survey of every laundry
diagnostics dump attached to an issue turned up 14 devices; the other 10 have
no cloud tokens at all, so this is a minority feature):
| dump | model | slots advertised | payloads seen | blob width |
| --- | --- | --- | --- | --- |
| `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes |
| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes |
| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 (but see below) | **0** | — |
| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes |
Three things follow immediately from that table:
- **`CloudExtraCourse_` does not mean the same thing on every family.** On
the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that
dishwasher's *own* course list, three already translated (Plastic, Pots and
pans, Baby Care). There it tags which ordinary courses came from the cloud;
they select with a plain `Course_` write and need no payload — consistent
with it carrying no payload token at all. It also has a
`DownloadCourseList_8F` token the washers lack.
On both washers the slots share **zero** overlap with the course list and a
payload is required. Subtracting the course list is what tells the two
apart (`cloudcourse.cloud_slots`), so the feature engages on the washers
and correctly does nothing on the dishwasher.
- **A device can advertise a slot it has never loaded.** True on the washers
too — nothing about a program is learnable until its owner runs it, which
is what the Repairs issue exists to explain.
- **Both WW5000C units advertise the byte-identical slot list**
(`0A5C286B2D0C55301A`, same nine slots in the same order) despite different
firmware builds. Either the set is a factory/regional default rather than
something each owner curates, or the two dumps share an owner — unresolved,
but worth knowing before assuming a user picked their own programs.
## The three tokens
All on `/course/vs/0`'s `x.com.samsung.da.options` array, same
prefix-match/replace merge as every other token there.
- `CloudExtraCourse_<slot><slot>…` — the device's own list of downloaded
program slots, one byte each. The cloud counterpart of `EditCourseList_`.
- `CloudCourse_<blob>` — the persisted default program.
- `OneTimeCloudCourse_<blob>` — a this-run-only override.
### `CloudExtraCourse_` is an enumeration, and byte 2 of a blob is its slot
The WW5000C reports `CloudExtraCourse_0A5C286B2D0C55301A` — nine bytes for
its nine downloaded programs. Byte 2 of each of the nine blobs its owner
captured is exactly one of those nine, no repeats, sets equal:
```
blob byte2 program
00 21 55 04 49 28 4D 13 4A A0 4C 00 … 55 Sports
00 20 28 04 49 00 4D 00 4A B8 4C 00 … 28 Spin only
00 02 5C 04 49 28 4D 13 4A B0 4C 00 … 5C Outdoor
00 1F 6B 04 49 28 4D 13 4A A0 4C 00 … 6B Jeans
00 2E 2D 04 49 30 4D 12 4A A0 4C 00 … 2D Super quiet
00 2F 0C 04 49 58 4D 14 4A B8 4C 00 … 0C Baby care intensive
00 0D 30 04 49 30 4D 12 4A B8 4C 00 … 30 Cloudy heaven
00 30 1A 04 49 28 4D 12 4A A0 4C 00 … 1A Shirts
00 04 0A 04 49 40 4D 13 4A B8 4C 00 … 0A Towels
CloudExtraCourse_ 0A 5C 28 6B 2D 0C 55 30 1A
```
Confirmed independently on the WA55A7700AV: `CloudExtraCourse_5958`, and its
`CloudCourse` blob `00 1C 59 05 …` has byte 2 = `59`. Its
`OneTimeCloudCourse` is `FF FF 01 …` — byte 2 = `01`, which is *not* an
advertised slot, and the `FFFF` prefix marks it as "nothing loaded" rather
than naming a program. That sentinel is why `cloudcourse.is_loaded` exists.
This is what makes "3 of 9 discovered" answerable, and it is why no catalog
of program ids is hardcoded anywhere: the appliance already knows which
programs it has.
## Writing: the two-token rule
Confirmed on hardware by the issue #342 reporter. Writing
`OneTimeCloudCourse_<blob>` alone while some other course is selected is
accepted at the protocol level (no error) and then silently ignored by the
machine. It takes effect only when the same write also switches `Course_` to
the Download course — which is what `laundry._cloud_cycle_write` does, and
the only two-token options write in the codebase:
```yaml
x.com.samsung.da.options:
- Course_87
- OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00
```
`CloudCourse_` was separately confirmed writable on its own: set while on
Download it changes the running program; set from another course it becomes
what gets preselected the next time Download is chosen. The integration
doesn't write it today — a "default download cycle" control is a possible
follow-up, deliberately left out of the first pass.
### There is no single "Download" course code
The WW5000C's Download is `Course_87`; the WA55A7700AV's is `17`
("Downloaded" in `washer_cycle_table_02`). **Same course table, different
code.** Any per-table lookup of "the Download code" would have been wrong on
one of the only two devices available to check it against, which is why the
code is learned by observation and confirmed by the user in the options flow
instead of tabled.
The observation signal is "whatever `Course_` reads at the moment a
non-sentinel `OneTimeCloudCourse_` *appears or changes*" — a transition that
was actually watched, not a state. Tokens in this array are replaced by
prefix and never evicted, so a payload merely sitting there says nothing
about when it got there; on the first rep after a restart it is equally
consistent with "just loaded" and "left over from last week". Believing it
would propose whatever ordinary course the appliance happens to be sitting
on, and accepting that prefill starts a real wash cycle. Even a genuine
transition is only ever a *candidate*, confirmed by the user before use.
Both dumps in the corpus taken while off the Download course
(`washer_wa55a7700av` on `Course_01`, the `_B048` washer on `Course_1C`)
show the appliance clearing its one-time token to the `FFFF` sentinel, so
the saved default persists but the one-shot does not. That makes the stale
case unlikely on these boards — which is a reason to expect it to behave,
not a reason to depend on it.
## The dead end: bytes 5/7/9 do not decode portably
With the nine WW5000C programs and their app-reported settings side by side,
three of the varying bytes fit perfectly:
| byte | meaning | formula | fit |
| --- | --- | --- | --- |
| 5 | wash temperature | `(b - 0x10) / 0.8` °C, `0x00` = n/a | 9/9 |
| 7 | rinse count | `b - 0x10`, `0x00` = off | 9/9 |
| 9 | spin level | `(b - 0x90) / 8` | 9/9 |
Nine for nine, including the internally consistent case: "Spin only" is the
only program with `0x00` in *both* byte 5 and byte 7, matching a cycle that
skips washing entirely while still reporting a spin level.
It does not survive the second device. Against the WA55A7700AV's
`CloudCourse` blob `00 1C 59 05 49 16 4D 11 4A 22 4C 20 37 F0 AC 22`:
- temperature: `(0x16 - 0x10) / 0.8` = **7.5 °C**
- spin: `(0x22 - 0x90) / 8` = **negative**
- rinse: `0x11 - 0x10` = 1 — the only plausible one
### What *does* survive is the grammar
The two boards' payloads are different lengths (20 vs 16 bytes) but not a
different format — same header, same leading fields, two fewer optional
trailing ones:
```
WW5000C 00 | 2155 | 04 | 49:28 4D:13 4A:A0 4C:00 | 35:F0 04:F0 05:F0 | AC:00
WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC:22
```
- `00`, then the 2-byte program id, then one byte (`04` vs `05`) — identical
layout on both.
- Then a tag/value stream whose **first four tags are the same, in the same
order, at the same offsets**: `49`, `4D`, `4A`, `4C`. These are exactly the
four whose values vary per program.
- Then a fixed tail, terminated on both by an `AC:<value>` pair. The entire
width difference is two trailing pairs the WA55 doesn't carry.
The tail is not program data. Across all nine WW5000C programs bytes 12–19
are byte-identical — every trailing pair carries value `F0` except the `AC`
terminator, and `35` vs `37` looks like a board or profile marker rather than
a field. (It is not a field count either: the board with the *higher* leading
byte has *fewer* pairs.)
### Byte 3 is not part of a program's identity
Worth its own heading, because it is the single strongest argument against
ever shipping a table of payloads. The second WW5000C (issues #259/#343,
firmware `_B048`) has its saved `CloudCourse` set to the same program as the
first one's "Towels" capture — and the two payloads differ at exactly one
byte:
```
_B06C "Towels" 00 04 0A 04 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
_B048 CloudCourse 00 04 0A 06 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
^^
```
Same program id, same slot, same values on all four varying tags, same tail.
Only byte 3 moves, `04` → `06`. So it is neither a per-board constant (both
are WW5000C) nor a property of the program (identical in every other
respect) — most likely a download revision or sequence counter.
A hardcoded catalog keyed on program id would therefore have shipped one
unit's byte 3 to the other unit. Whether the appliance would reject that, or
accept it and do something unintended, is untested and does not need to be:
every payload is learned from the device it will be replayed to.
### Sentinels
The `FFFF` "nothing loaded" payload takes its board's own width (16 bytes on
the WA55, 20 on the WW5000C `_B048`) and always carries byte 3 = `00`. Its
byte 2 is *not* reliably meaningful: it equals the currently selected course
on the WA55 (`01`, on `Course_01`) and does not on the `_B048` (`1B`, on
`Course_1C`). Nothing keys off it — a sentinel is rejected on its `FFFF`
prefix, and its byte 2 is not an advertised slot in either dump anyway.
So the payload is tag/value, not fixed offsets — but knowing the grammar
doesn't recover the values. The same four tags carry non-overlapping ranges
between the two boards:
| tag | WW5000C (9 programs) | WA55 |
| --- | --- | --- |
| `49` | `00, 28, 30, 40, 58` | `16` |
| `4D` | `00, 12, 13, 14` | `11` |
| `4A` | `A0, B0, B8` | `22` |
| `4C` | `00` (all nine) | `20` |
Same field, board-specific encoding. Decoding it properly needs a third
device; one device's fit is a coincidence-shaped hypothesis, not a format.
**A trap for whoever picks this up:** the WA55's `/washer/vs/0` reads
Warm / High / 1, which looks like it could confirm a decode of that unit's
`CloudCourse`. It can't — that appliance is sitting on `Course_01` (Normal),
not on its cloud course, so those values describe the local cycle it has
selected, not the saved cloud program. A cross-check like this is only
evidence when the machine is actually loaded with the program being decoded.
So the shipped code never interprets a payload: a blob is recorded whole and
replayed byte-for-byte, exactly as the device reported it, and never
decomposed or rebuilt. The read-only "loaded program's temperature/spin"
sensors this decode would have enabled were dropped for the same reason.
## What is deliberately not done
- **No hardcoded program catalog.** Blobs are cloud-assigned per
account/region. One owner's captured payload is not evidence about anyone
else's appliance, and a table of them would offer options that write
another household's wash settings.
- **No invented names.** The appliance reports an opaque slot id and nothing
else. Names come from the user in the options flow, the same rule that
stops an unrecognized local course code from getting a made-up English
label (PR #251 review).
- **No blob synthesis.** Even with the byte 5/7/9 decode in hand, nothing
builds a payload from parts — the device was never tested with one, and a
fabricated blob is an untested write to a wash cycle.
## Open questions for the next dump
1. Does `OneTimeCloudCourse_` clear itself when a cycle finishes, or when the
course changes? Behavior suggests the appliance falls back to
`CloudCourse_` when Download is re-entered, but the token's own lifecycle
is unconfirmed. `laundry.cloud_current` is written to be correct either
way.
2. What does byte 1 mean? It is distinct per program and *sometimes*
coincides with a plausible local course code for that program (`2E` Baby
Care for "Baby care intensive", `30` Cloudy Day for "Cloudy heaven") and
sometimes doesn't (`21` Colors for "Sports"). Probably a base-course
reference; not reliable enough to use.
3. What does byte 3 count? It moves between two units holding the identical
program (`04` vs `06`) and is `00` on every sentinel. A revision or
download counter is the obvious guess; a dump taken before and after
re-downloading the same program would confirm it.
4. **The value encoding is still open, and a third *washer* won't
necessarily settle it.** The survey found one, but its saved program is a
duplicate of one already captured, so it adds no new tag values. What is
actually needed is a dump from a board whose `/washer/vs/0` speaks in
named levels (Cold/Warm/Hot, Low/High) *while that unit is sitting on a
downloaded program* — then the payload's `49`/`4A` values can be read
against settings that describe the same program. The WA55 is such a
board but was captured on a local course, which is why it can't be used
(see the trap above).
5. The DW5000C's four slots (`8E 8D 8F 02`) are in the same numeric range as
dishwasher course codes (its selected course is `86`), unlike the
washers' slots. One payload from that machine would show whether slot ids
are drawn from the course-code space on some boards.
+213
View File
@@ -0,0 +1,213 @@
# Loading a config entry while the appliance is offline
Issue #295 asks for faster recovery when a powered-off appliance comes back,
instead of waiting out HA's `ConfigEntryNotReady` backoff. PR #303 tried to
get there by catching the first-refresh failure in `async_setup_entry` and
loading the entry anyway.
That doesn't work here, and the reason is worth writing down: this
integration has no static entity list. Every entity comes from discovery,
and discovery only happens inside a successful poll.
(The issue's "up to 15 minutes" is out of date, incidentally. Current HA
retries on `2 ** min(tries, 4) * 5` seconds — capped at 80s, not 900. The
backoff was never the worst part; a device card reading "Retrying setup" with
no entities behind it is.)
## What PR #303 produces today
Measured on the PR's branch — set up with `_poll_once` raising, then advance
the clock four summary intervals:
| | |
| --- | --- |
| entry state | `LOADED` |
| `coordinator.bound` | 0 |
| entities in the state machine | 0 |
| registry entries | 1 (the disabled connection-mode sensor) |
| coordinator listeners | 0 |
| `_unsub_refresh` | `None` |
| poll attempts over the next 4 intervals | **0** |
The entry loads and then never polls again. `DataUpdateCoordinator._async_refresh`
reschedules only `if not auth_failed and self._listeners and not
self.hass.is_stopping`; with no bound entities the only unconditional entity
is `LocalThingsConnectionModeSensor`, which is
`entity_registry_enabled_default = False` and so never added and never
subscribes. Nothing reloads the entry either. The device comes back online to
an entry that is permanently empty until a manual reload — strictly worse
than the backoff it replaces, which did recover on its own within 15 minutes.
## Why entities can't just be created offline
Four independent gates, all of which need live device data:
1. `bound` is only ever assigned in `_run_discovery` (`coordinator.py:1081`),
which runs on a poll's `resources` dict.
2. All ten platforms enumerate `coordinator.bound` exactly once, at forward
time (`sensor.py:34` and siblings). Nothing adds entities later — the
invariant is already documented at `coordinator.py:1283-1286`.
3. `_is_included` (`entity.py:39`) returns False whenever `last_resources`
has no rep for the href. Even a fully reconstructed `bound` filters to
nothing while `StateCache` is empty.
4. `LocalThingsEntity` is a bare `CoordinatorEntity` with no `available`
override, no `RestoreEntity`, and no `Store` anywhere in the component. An
entity that did exist offline would be `unavailable` with no state.
The issue cites ESPHome, Shelly, LIFX and WLED as precedent for setup that
never fails. Those integrations can do it because each one has a *persisted
device description* to build entities from — ESPHome keeps its entity list in
`.storage`, Shelly caches device info. The pattern is portable; the mechanism
underneath it is the part PR #303 is missing.
## How the implemented version works
Three pieces, plus a gating rule.
### 1. A persisted discovery snapshot
After each successful first cycle, `_save_snapshot` banks exactly the
`resources` dict that cycle handed `_run_discovery`, along with the
pre-narrowing subdevice candidate list and the `DeviceIdentity` read from
`/oic/*`.
Storing the poll input rather than a rendered entity list is the decision
that keeps this honest. `BoundEntity` holds live
`Capability`/`SamsungEntityDescription` objects and isn't serializable, so
the alternative was a parallel format plus a re-resolution path — a second
implementation of discovery that could drift from the real one. Replaying the
input through `_run_discovery` means the same code, the same registry
resolution, and no second source of truth.
Three things ride along because `_run_discovery` reads them off `self`
rather than out of `resources`, and getting them wrong would silently resolve
a *different* registry offline than online — which reconciliation below would
then see as a real change and reload on every restart:
- `_identity.device_types` routes `resolve_registry`.
- `self.subdevices` is the candidate list `discover_partitioned` narrows;
replaying against the already-narrowed list finds no siblings at all.
- `_identity.manufacturer`/`model` feed `device_info`.
It lives in `.storage` (`Store`, keyed on entry_id) rather than on the config
entry: it's device state, not configuration, and runs to tens of kilobytes.
`async_remove_entry` deletes it with the entry.
The write is awaited, not `async_delay_save`d. A deferred write outlives
whatever queued it: it lands after `async_remove_entry` has deleted the file
and recreates it orphaned, and a reload scheduled by the reconcile below
would read the pre-reload snapshot back off disk. It runs once per entry
load, so there's nothing worth deferring. A write that fails is logged and
swallowed — a board reporting something the JSON encoder rejects must not
break polling.
### 2. Reconcile on reconnect
The snapshot is a claim about a device we haven't talked to yet. When the
first live poll lands, `_reconcile_rehydrated` compares the live entity set
against the rehydrated one — as `(subdevice key, _key(bound))` pairs, which
is the unique_id identity — and calls `async_schedule_reload` if they differ.
Gate 2 above is why this has to be a reload rather than an in-place fixup.
It's what makes the feature safe against a firmware update, a sibling
subdevice that starts answering, or a different appliance at the same IP.
### 3. Keep polling with no listeners
`async_setup_entry` holds one listener for the entry's lifetime:
```python
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
```
Registered *before* the first refresh, so scheduling survives a refresh that
fails. This alone fixes the measured "never polls again" bug, and covers a
rehydrated set whose entities are all registry-disabled. Removing the last
listener unschedules the timer, and HA runs `async_on_unload` callbacks when
setup raises, so the setup-retry path doesn't leak a polling coordinator.
### Gating rule: only load offline when there's a snapshot
An entry that has never successfully polled has nothing to restore and keeps
raising `ConfigEntryNotReady`. This is what answers the objection in the PR
thread — with a snapshot we *do* have metadata to build a device from, and
without one HA's backoff is still the right behavior. It also leaves room for
the #168-style flows that need to interact with the device during setup: a
device that never completed setup still blocks.
It also means `async_remove_config_entry_device` is no longer reachable with
an empty `coordinator.subdevices`, so an offline load can't offer to delete a
real-but-unreachable subdevice.
### The coverage-gap Repair stays live-only
`_run_discovery(..., from_snapshot=True)` skips `_update_coverage_gap_issue`.
The Repair points the user at a diagnostics download, which is empty until
the appliance answers, and a device name that drifts between the snapshot and
the live poll would churn the issue for no reason.
Not a de-duplication measure — HA already handles that. `async_create_issue`
is keyed on `(domain, issue_id)`, `dataclasses.replace` in
`async_get_or_create` leaves `dismissed_version` alone, and the registry
reloads non-persistent issues with their dismissal intact, so one row per
entry survives restarts and an "Ignore" sticks.
### What a cycle costs while the appliance stays dark (issue #269)
An appliance switched off at the wall isn't a one-cycle blip: it fails the
same way every 30s for hours, and both halves of that failure were being paid
twice.
`_poll_once` opens the session itself when there isn't one, so a switched-off
appliance fails *in the handshake* — 12s (`DtlsCoapSession.HANDSHAKE_TIMEOUT_S`)
with nothing to show for it. The poll path then treated that like any other
poll failure and ran its reconnect: close the session, pause
`_RECONNECT_PAUSE_S`, poll again. There is no session to close and no
association for the device to clean up, so the "reconnect" was the identical
handshake five seconds later — 29s of the 30s interval spent proving the
appliance is off, twice over, and the same again on every `SETUP_RETRY`
attempt for an entry with no snapshot to load from. `_handshake_failed` marks
that case in `_poll_once` so the poll path can skip the retry; a session that
opened and *then* broke still reconnects within the cycle.
The log was the half the reporters actually saw: `poll failed after
reconnect` at ERROR every cycle, plus a `reconnect_is_frequent` WARNING once
three piled up, for a state this integration is specifically built to sit
through. Issue #269's reporter read that repetition as the integration having
failed. It's one ERROR per outage now, DEBUG for the cycles after it, and one
INFO when the device answers again — HA's own coordinator already logs the
transition into and out of a failed update.
## What this still won't do
Entities will be present and `unavailable` — not showing their last values.
Gate 4 means last-known values require either `RestoreEntity` per platform or
persisting `StateCache`, and both mean asserting state the integration cannot
verify: a washer unplugged for a week would read "Running". HA's convention
is that unreachable means unavailable, and the recorder keeps the history
either way, so long-term statistics and history graphs are unaffected by this
choice.
Worth being explicit about, because it is the gap between what PR #303
promises in the thread ("load their previously recorded states") and what any
correct version can deliver.
## Rejected: zeroconf
The issue's other suggestion — wire zeroconf so the device's own boot
announcement triggers a retry, which is the genuinely idiomatic HA answer —
is a non-starter as things stand: there is no `zeroconf` or `dhcp` key in
`manifest.json` and the config flow is user-driven only, so HA has no
discovery signal for this integration to hang a retry on. It would first need
a confirmed mDNS service on the appliance. Worth revisiting if one turns up;
it would make recovery near-instant instead of within one poll interval.
## Rejected: the cheap version
Keeping `ConfigEntryNotReady` and adding a probe that calls
`async_schedule_reload` on first success would have fixed the recovery *time*
in about twenty lines, with no persistence and no reconcile. It was rejected
because it leaves the device reading as broken for as long as the appliance
is off, which is the half of issue #295 that actually bites — an appliance
switched off at the wall is offline for days, not seconds, and a whole
integration that looks failed for that entire window is the complaint.
+33
View File
@@ -3,3 +3,36 @@ requires-python = ">=3.13"
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"RUF", # ruff-specific
"ASYNC", # flake8-async
"LOG", # flake8-logging
"G", # flake8-logging-format
"PIE", # flake8-pie
"RET", # flake8-return
"PERF", # perflint
"N", # pep8-naming
]
ignore = [
"N818", # exception name doesn't need an Error suffix in this codebase
]
[tool.ruff.lint.isort]
known-first-party = ["custom_components"]
[tool.ty.environment]
python-version = "3.13"
+5 -1
View File
@@ -6,7 +6,11 @@ pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test
# (also declared in custom_components/localthings/manifest.json).
smartthings-local>=0.1.0
smartthings-local>=0.1.8
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
# Lint / format / type-check tooling, pinned to match CI.
ruff==0.16.1
ty==0.0.65
+31 -20
View File
@@ -3,16 +3,17 @@ from pathlib import Path
import pytest
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def _resources_from_dump(dump: dict) -> dict[str, dict]:
from custom_components.localthings.registry.batch import parse_device0_batch
return parse_device0_batch(dump['device0'])
return parse_device0_batch(dump["device0"])
def _load_device(name: str) -> dict[str, dict]:
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
return _resources_from_dump(data)
@@ -33,10 +34,10 @@ def _load_device_full(name: str):
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
FakeCoapSession answers both shapes off the same href key.
"""
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
resources = _resources_from_dump(data)
seeds = {**data.get('seeds', {}), **data.get('probes', {})}
return resources, data.get('oic_res', []), seeds
seeds = {**data.get("seeds", {}), **data.get("probes", {})}
return resources, data.get("oic_res", []), seeds
class FakeCoapSession:
@@ -57,18 +58,19 @@ class FakeCoapSession:
self.seeds = seeds or {}
def get(self, path, timeout=None):
href = '/' + '/'.join(path)
href = "/" + "/".join(path)
body = self.seeds.get(href)
if body is None:
return 0x84, b'' # 4.04 not found -- tolerated absence
return 0x84, b"" # 4.04 not found -- tolerated absence
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
pass
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list], device_types=()):
"""Run the *whole* subdevice-aware discovery pipeline against fixture
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
@@ -76,6 +78,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
it. See the adding-device-support skill's section 2 for the plain
(non-subdevice) equivalent this extends.
`device_types` is the master's own /oic/d `rt` (see
discover_partitioned's `oic_device_types` param) -- only needed for a
board with no /information/vs/0 at all to route from (issue #324's
range, whose modelNum-based fallback has nothing to read), so it
defaults to () for every fixture that resolves by board token instead.
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
- `bound`: every BoundEntity, main + every materialized subdevice.
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
@@ -87,14 +95,19 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.registry import CAPABILITIES
from custom_components.localthings.registry.subdevices import (
discover_partitioned, enumerate_subdevices,
discover_partitioned,
enumerate_subdevices,
)
sess = FakeCoapSession(seeds)
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
full_resources = {**resources, **extra}
bound, device_type_name, materialized, skipped = discover_partitioned(
full_resources, candidates, resolve, CAPABILITIES,
full_resources,
candidates,
resolve,
CAPABILITIES,
oic_device_types=device_types,
)
return bound, materialized, skipped, full_resources, device_type_name
@@ -102,8 +115,8 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
def _load_resources(ip: str) -> dict[str, dict]:
"""Legacy IP-based loader — maps known IPs to named fixtures."""
_ip_to_name = {
'10.0.0.129': 'dishwasher',
'10.0.0.254': 'refrigerator',
"10.0.0.129": "dishwasher",
"10.0.0.254": "refrigerator",
}
name = _ip_to_name.get(ip)
if name is None:
@@ -113,17 +126,17 @@ def _load_resources(ip: str) -> dict[str, dict]:
@pytest.fixture
def dishwasher_resources() -> dict[str, dict]:
return _load_device('dishwasher')
return _load_device("dishwasher")
@pytest.fixture
def fridge_resources() -> dict[str, dict]:
return _load_device('refrigerator')
return _load_device("refrigerator")
@pytest.fixture
def washer_resources() -> dict[str, dict]:
return _load_device('washer')
return _load_device("washer")
@pytest.fixture
@@ -134,8 +147,6 @@ def all_device_fixtures() -> dict[str, dict[str, dict]]:
device -- so a newly added dump exercises them automatically.
"""
return {
path.name[:-len('_device.json')]: _resources_from_dump(
json.loads(path.read_text())
)
for path in sorted(FIXTURES.glob('*_device.json'))
path.name[: -len("_device.json")]: _resources_from_dump(json.loads(path.read_text()))
for path in sorted(FIXTURES.glob("*_device.json"))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "224",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "224",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"112",
"224",
"336",
"448"
]
}
},
{
"href": "/filter/airdustPM1filter/vs/0",
"rep": {
"x.com.samsung.da.filterCapacity": "3",
"x.com.samsung.da.filterCapacityUnit": "Month",
"x.com.samsung.da.filterResetType": [
"notresetable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "0.5"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"SpeedClean",
"QuietClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"SpeedClean",
"QuietClean",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "12",
"x.com.samsung.da.supportedModes": [
"0",
"11",
"12",
"13",
"14",
"15",
"16"
],
"x.com.samsung.da.modesName": [
"Auto",
"1",
"2",
"3",
"4",
"5",
"Max"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": "**REDACTED**"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "AC_V_0002_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "27.0",
"x.com.samsung.da.current": "28.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 28.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 27.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "400.000000",
"x.com.samsung.da.instantaneousPower": "14.000000",
"x.com.samsung.da.cumulativePower": "882861",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeDate": "1785798000",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 400.0,
"power": 14.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"ArtificialWorking_Off",
"welcomecare_Off",
"Volume_Mute",
"Operation_Family",
"ComfortAICooling_Off",
"OnTimer_0",
"OffTimer_0",
"Sleep_0",
"Booster_Off",
"OutdoorSilence_Off",
"AiTempChanged_Off",
"AiTemp_250",
"StopAutoClean_Idle",
"Weather_Off",
"Service_Off",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"InDoorVentil_Off",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"ProtectOperation_Off",
"EnergySaveIcon_Off",
"ExtendOptionCode_16975",
"OptionCode_2589",
"RacInfo_First",
"ModelInfo_16K_BORA_VENT2",
"RacInfo_None_Second",
"OutdoorConnection_Connected",
"DurationOn_0",
"UpdateAllow_NotAllowed",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"causeSource": "SMTS"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_FAC_TIME_23K|10250841|600007110017112BCE00CE2200937000",
"x.com.samsung.da.description": "TP1X_FAC_TIME_23K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AF2",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02517A260330",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02508A23020200,02509A24030701",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "02502A23011300,02299A10000300"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "LED",
"x.com.samsung.da.number": "02510A22121900,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "BLERemoteController",
"x.com.samsung.da.number": "22021760"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1138000000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"CLEAN_OPTION_SUPPORT",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "71"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"durationminutes": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000000D04000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/uvled/vs/0",
"rep": {
"x.com.samsung.da.modes": "On",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AFA-WW-TP1-23-AFXX00",
"versions": [
"10260330"
],
"visVersion": "260330"
},
{
"type": "Micom",
"modelId": "845110250241FFFFFFFF",
"versions": [
"23011300",
"FFFFFFFF"
],
"visVersion": "230113"
},
{
"type": "Micom",
"modelId": "04511025084110250941",
"versions": [
"23020200",
"24030701"
],
"visVersion": "240307"
},
{
"type": "Micom",
"modelId": "04511025024110229941",
"versions": [
"23011300",
"10000300"
],
"visVersion": "230113"
},
{
"type": "Micom",
"modelId": "045110240441FFFFFFFF",
"versions": [
"23121900",
"FFFFFFFF"
],
"visVersion": "231219"
},
{
"type": "Micom",
"modelId": "045110251041FFFFFFFF",
"versions": [
"22121900",
"FFFFFFFF"
],
"visVersion": "221219"
}
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "1",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1DFFFFFFFF121E121EFFFFFFFFFFFF01000000013F0100013F000000001C0C",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/ventilation/setting/vs/0",
"rep": {
"alarm": "Off"
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "Off",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+426
View File
@@ -0,0 +1,426 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "224",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "224",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"224",
"300",
"500",
"700"
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"cleaningTime": "30",
"minCleaningTime": "10",
"maxCleaningTime": "60",
"cleaningTimeUnit": "10",
"x.com.samsung.da.supportedStatus": [
"Start",
"TimedClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"TimedClean",
"Off"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "WaterTankFull_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "145.000000",
"x.com.samsung.da.cumulativePower": "101100",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Smart",
"Max",
"High",
"Medium",
"Quiet",
"ClothesDrying"
],
"x.com.samsung.da.modes": [
"Quiet"
],
"x.com.samsung.da.options": [
"Volume_Mute",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "1",
"displayCondition": "Enable"
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA_AC_DHM_01001_0000|10283341|77000000001700000A00000000000000",
"x.com.samsung.da.description": "TP1X_DA_AC_DHM_01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AY0",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02544A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02833A26010600,FFFFFFFFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "02590A10000100,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Touch IC",
"x.com.samsung.da.number": "02552A23020200,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_2.0",
"AI_Heat"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "63",
"x.com.samsung.da.desiredHumidity": "60",
"increment": "5",
"x.com.samsung.da.fivepercentHumidity": "64"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "04001E46070A5A00000000000000",
"x.com.samsung.da.id": "DHM",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-06-24",
"otnList": [
{
"type": "WIFI",
"modelId": "ADH-WW-TP1-23-AY7100",
"versions": [
"18260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "042110283341FFFFFFFF",
"versions": [
"26010600",
"FFFFFFFF"
],
"visVersion": "260106"
},
{
"type": "Micom",
"modelId": "042110259041FFFFFFFF",
"versions": [
"10000100",
"FFFFFFFF"
],
"visVersion": "100001"
},
{
"type": "Micom",
"modelId": "042110255241FFFFFFFF",
"versions": [
"23020200",
"FFFFFFFF"
],
"visVersion": "230202"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "3F1E461E461E46FFFFFFFF1E4600",
"x.com.samsung.da.id": "DHM",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/display/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/welcome/humidity/vs/0",
"rep": {
"requestId": "0000",
"operatingStatus": "None"
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/watertank/lighting/vs/0",
"rep": {
"status": "On",
"colorOption": "NaturalWhite",
"mode": "High",
"waterfullAlarmStatus": "On",
"colorSupportedList": [
"WarmWhite",
"NaturalWhite",
"CoolWhite",
"Green",
"Purple",
"Blue",
"Orange",
"Pink"
],
"modeSupportedList": [
"High",
"Low"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+297
View File
@@ -0,0 +1,297 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "DishA_Disable",
"x.com.samsung.da.triggeredTime": "2022-07-28T13:45:57",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0812",
"UpdateAllow_NotAllowed",
"CourseDefaultTimeSet_008B008E009A006D003C000A006600A50059009D",
"CourseDefaultTempSet_3C41363F41413737373700003C3C464649493C3C",
"CourseDefaultFahrenheitSet_8C95819195958383838300008C8C9E9EA3A38C8C",
"Course_86",
"DetergentOnce_0",
"DetergentLeft_0",
"DetergentBase_0",
"DetergentAlarm_Off",
"DetergentType_0",
"DetergentTotal_0",
"ProgressTimeSet_420622820456B20384",
"SendToDevice_Off",
"GMT_F2",
"SavingModeCondition_01010207828485868E8D020300",
"SavingMode_Off",
"DownloadCourseList_8F",
"StormWashZone_Off",
"AutoDoorRelease_On",
"Sound_On",
"WaterLevelSet_050404050302010205010400",
"CloudExtraCourse_8E8D8F02",
"EnergyLevelSet_050403050202010305040300",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_Empty",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"482830CB002C002D00283830CB002C002D00284830CB002C002D00285830CB000C000D00086830CB002C002D002908308B000C000D0008E830CB000C000D0008D830CB002C002D0028F8308B000C000D00002830CB002C002D002"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Run",
"x.com.samsung.da.remainingTime": "00:44:00",
"x.com.samsung.da.progressPercentage": "28",
"x.com.samsung.da.delayStartTime": "00:00:00",
"x.com.samsung.da.progress": "Wash",
"x.com.samsung.da.supportedProgress": [
"None",
"Predrain",
"Wash",
"Rinse",
"Drying",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Predrain",
"Wash",
"Rinse",
"Drying",
"Finish"
],
"currentJobState": "Wash",
"remainingTime": "00:44:00",
"progressPercentage": "28"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON_DW5000C/DD92-0010741_0001",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "WD0",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "00081A230213(A214)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_30010741230602142FFFFFFFFFFFFFFFFFFFFFFFFFFE(081230010741FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "23060214,FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/dishwasher/vs/0",
"rep": {
"x.com.samsung.da.highTemperatureDry": "Off",
"x.com.samsung.da.sanitize": "Off",
"x.com.samsung.da.selectedZone": "ON_ON",
"x.com.samsung.da.rinseLevel": "0",
"x.com.samsung.da.supportedSelectedZone": [
"OFF_ON",
"ON_ON"
],
"x.com.samsung.da.supportedSanitize": [
"Off",
"On"
],
"x.com.samsung.da.supportedHighTemperatureDry": [
"Off",
"On"
],
"x.com.samsung.da.supportedRinseLevel": [
"0",
"1",
"2",
"3",
"4",
"5",
"6"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": true
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "CA"
}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/water/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeWater": "2662000"
}
},
{
"href": "/wm/submode/vs/0",
"rep": {
"setTemperatureUnit": "F"
}
}
]
}
+244
View File
@@ -0,0 +1,244 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "US\u0001"
}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0165",
"UpdateAllow_NotAllowed",
"Course_9A",
"MixedLoadBell_Disable",
"MixedLoadBellNoti_Nothing",
"LaundryOutTime_0",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"DetergentOnce_0",
"DetergentLeft_0",
"DetergentBase_0",
"DetergentAlarm_Off",
"DetergentType_0",
"DetergentTotal_0",
"SpecialFunction_4",
"AvailableDelayTime_188",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_B22D00B4003C",
"WrinklePreventRunning_Off",
"EnergyLevelSet_050502050503020304010204030203",
"MostUsed_9AD20EE000",
"MixedLoadBellSet_02FFFF02FFFFFFFFFFFFFFFFFF02",
"UsagesDB_ok",
"EnergyKW_396",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"29AD20EE000CAD10EE000DBD204E00099D20EE00093D102E000B5D102E000D7D204E000A5D204E00096D000E10E97D000E10E7FD000E33E98D000E000EBD204E000B6D20EE000"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.cumulativePower": "692600",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1786964400",
"x.com.samsung.da.cumulativeDateUTC": "1786960800"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02198A230708(E257)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "17111305,17122616",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Drying",
"Cooling",
"Finish"
],
"currentJobState": "None",
"remainingTime": "03:08:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "03:08:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Drying",
"Cooling",
"Finish"
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/setting/vs/0",
"rep": {}
},
{
"href": "/st/dryercourse/vs/0",
"rep": {
"x.com.samsung.da.st.dryerMode": "Table_00_Course_9A",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off",
"x.com.samsung.da.dryLevel": "2",
"x.com.samsung.da.supportedDryLevel": [
"None",
"1",
"2",
"3"
],
"x.com.samsung.da.dryTime": "00:00:00",
"x.com.samsung.da.supportedDryTime": [
"00:00:00",
"00:30:00",
"01:00:00",
"01:30:00",
"02:00:00",
"02:30:00"
],
"x.com.samsung.da.dryerType": "Electricity"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
}
]
}

Some files were not shown because too many files have changed in this diff Show More