Commit Graph
615 Commits
Author SHA1 Message Date
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 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 v0.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