81dbc6fa03bfaae80eef8b72d674bb50186b63de
412
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
711a71876d |
Merge pull request #350 from galaxysj/codex/fix-washer-course-enum-display
Fix AC setup timeout and verify appliance course mappings |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
17cd78d975 | Format subdevice regression test | ||
|
|
863fe32526 | Cover reported washer course table | ||
|
|
13d2a38f5d | Avoid probing UUID file-transfer namespaces | ||
|
|
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. |
||
|
|
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(). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
d6534c788a |
Change format delay to always zero-pad number of hours.
Resolves #338 |