Fixes#345. progress and progress_percentage were gated on machine_state
alone, falling straight to "Idle"/0 the instant state left 'active' --
but a washer can flip state away from 'active' within the same poll
interval progress reaches 'Finish' (more reliably than the same-family
dryer, per the report), so an automation watching for a real 'Finish'
value could go a whole cycle without ever observing one.
Implemented read-side, per-entity (sensor.py's new _apply_sticky),
generalizing the existing _hysteresis_value/_apply_hysteresis pattern
finish_time already uses, rather than writing a synthetic override back
into the coordinator's cache. That cache is this integration's record of
what the device actually said, and is read by several unrelated
consumers -- write_fn, validate_fn, diagnostics, the observe-mode sweep
comparison against /device/0, _completion_minutes' own stale-remainingTime
workaround -- all of which would otherwise see fabricated state.
A first pass gated the hold's arm condition on state=='active' AND
progress=='Finish' occurring in the same rep, mirroring _is_active. A
second review caught that this could make the whole fix a no-op on the
one device #345 reports it for: if state has already reset by the time
progress is ever observed at 'Finish' -- exactly what #345 describes --
the arm condition never fires. _just_finished now arms on progress==
'Finish' alone. That reopens the staleness risk the state check existed
to guard against (a progress field stuck at 'Finish' forever would then
arm forever too), so _apply_sticky is edge-triggered: only a fresh
False->True transition (re)starts the window, and expiry is still
checked on every call even while the condition keeps matching -- a
stuck value still won't hold past sticky_seconds.
Dropping the state requirement also exposed a second gap: the bypass
that lets a new cycle's own real progress override a stale hold was
keyed on machine_state=='active', so it missed a new cycle immediately
paused (e.g. adding a sock) -- machine_state isn't 'active' while
paused. It's keyed on a live, non-Finish progress code instead
(_live_progress_code), independent of state, same reasoning as
_just_finished. And since the bypass needs the *real* live value, not
whatever rep_fn's own (differently gated) result says, SensorDesc grew
sticky_live_fn alongside sticky_value_fn: rep_fn's progress gate still
shows "Idle" while paused, but the real progress value read ungated
must win over the hold regardless.
registry/entities.py: SensorDesc gains sticky_fn/sticky_value_fn/
sticky_live_fn/sticky_bypass_fn/sticky_seconds -- see sensor.py's
_apply_sticky docstring for the full contract.
registry/capabilities/operational.py: progress/progress_percentage's
rep_fn is unchanged; they gain the sticky_* wiring above. machine_state
and the Running binary sensor are untouched -- still gated on real-time
state (cycle_active now shares _is_active's rep_fn directly rather than
a duplicate inline copy), so they never claim the appliance is still
running once it isn't.
async_send_command handed write_fn/validate_fn the raw cache snapshot
(real, on-the-wire hrefs), not a subdevice-scoped view. Every other
consumer of a full resources dict (exists_fn, rep_fn, is_legacy_board,
...) reads through coordinator.canonical_resources() specifically to
avoid this; write_fn/validate_fn didn't, so on a composite AC (issue
#177) _temperature_step's resources.get(HREF_TEMP_CONTROL) saw the
master's /temperature/control/vs/0 instead of the subdevice's own
/temperature/control/vs/1, silently rounding a subdevice's 0.5-degree
write to a whole degree. The remote-control gate stays on the raw
snapshot -- /remotectrl/* is a shared, MAIN-only resource a
subdevice's owned-hrefs-only canonical view would drop entirely.
climate.py's target_temperature_step duplicated this same
read-in-order logic; pointed it at airconditioner._temperature_step
so the read and write paths can't drift again.
Also, from the same review:
- _quantize_temperature rejects non-finite floats (nan/inf survive
float() but raise out of round()/division, escaping write_fn's
documented None-on-bad-payload contract).
- Deduplicated the quantize-and-check block shared by the
temperature_ocf/temperature branches of _climate_write.
- Added the /temperatures/vs/0 items[]-fallback test that was
previously unreachable (every increment-carrying fixture also has
/temperature/control/vs/0, which _temperature_step checks first).
- Added a coordinator-level test seeding an indexed subdevice with its
own step, distinct from the master's, covering the fix above.
- The Towels/Bedding regression test now checks all 7 locale catalogs,
not just English -- the bug is a code-mapping error, and
test_every_language_mirrors_the_english_catalog only checks key
topology, not values.
ClimateDesc.write_fn is typed as WriteFn (Callable[[Any, dict], ...]),
which only covers the (payload, rep) shape every other capability's
write_fn honors -- calling it through that alias with the climate-only
href/resources args, without first narrowing away the | None, failed
ty two ways: the missing "is not None" check and the extra positional
args past WriteFn's declared arity. Call _climate_write directly
instead, same as test_coordinator_send_command.py and
test_airconditioner_artik051_krac.py already do.
Issue #343: DA_WM_TP1_21_COMMON's washer_cycle_table_02 had course
codes 24 and 33 transposed -- selecting "Towels" in HA ran the
washer's Bedding cycle and vice versa (confirmed against the
reporter's diagnostics dump: Course_24 selected, courseTable
Table_02). Swapped both codes' labels back in line with the 69/6A-
79/88 family's own Bedding/Towels pair (6f/70), across every locale
catalog.
Issue #342: added the four course codes the reporter's editCourseList
carried with no catalog entry -- 06 (XXL Laundry), 08 (Rinse+Spin),
and a0 (15' Quick Wash) were missing outright; 74 (Drum Clean) turned
out to already be translated by the time this landed.
The download-course request in the same issue (selecting which
program a "Download" cycle fetches) is left for a follow-up -- still
waiting on a confirmed local write path before building anything on
top of the OneTimeCloudCourse/CloudCourse fields.
Samsung local AC temperature writes always rounded to the nearest
whole degree, dropping half-degree setpoints on boards that advertise
a 0.5 step (CAC and TP1X FAC). Squashed from moridew's PR #276 with
the review fixes applied:
- The /temperatures/vs/0 fallback never matched: its increment lives
inside the resource's items[] array, not at the top level (same
shape _temps_vs_item() already unwraps for current/unit). The
original fix only ever worked through /temperature/control/vs/0.
- With no increment advertised anywhere (e.g. ARTIK051), writes went
out unrounded instead of falling back to whole degrees the way
climate.py's target_temperature_step already does.
- A non-numeric payload now rejects the write (returns None) instead
of posting {"temperature": null} -- coordinator.py's
async_send_command already drops a write_fn result of None.
- int/float normalization now happens once, in _quantize_temperature,
instead of being duplicated (and skipped) per branch; a round(...,
2) guards against float division noise (e.g. 21.7 / 0.1).
Tests rebuilt against real fixture resources (airconditioner_cac,
airconditioner_artik051_krac_18k) instead of a fabricated flat
resource shape no device produces.
My local ty runs only covered custom_components, not tests -- CI runs
'ty check custom_components tests', which this branch had been failing
since the version-bump commit. All 13 diagnostics were the same two
established idioms this test suite already uses elsewhere, just missing
here:
- desc.write_fn/options_field are SelectDesc-only fields, unresolved on
the SamsungEntityDescription base a bare 'next(e for e in ... if
e.key == ...)' infers -- needs 'and isinstance(e, SelectDesc)' in the
filter, same as test_fridge_capabilities.py's existing selects.
- rep_fn/match_fn are typed Optional even after narrowing to a concrete
descriptor/capability, so calling one needs an explicit
'assert x.rep_fn is not None' first, same as
test_common_capabilities.py's POWER_VS_FALLBACK.match_fn precedent.
No behavior change -- test bodies are identical, just type-checkable.
DEODOR_FILTER reused AIR_FILTER.entities verbatim, so both capabilities
produced identically-keyed entities (air_filter_usage/air_filter_status)
despite living at different hrefs. adapter.flatten()'s key derivation has
no href component, so a unit reporting both /filter/airdustfilter/vs/0
and /filter/deodorfilter/vs/0 would silently clobber one filter's reading
with the other's -- the exact collision AIR_FILTER's own 'air_' prefix
was chosen to avoid against WATER_FILTER's filter_usage/filter_status.
Gives DEODOR_FILTER its own deodor_filter_usage/deodor_filter_status keys
(status still shares the filter_status translation_key, same as AIR_FILTER
already does). Updated the winecellar fixture's golden and test, and added
deodor_filter_usage to all seven translation catalogs.
AUTO_DOOR_SINGLE/KIMCHI/WINECELLAR were identical one-line no-entity
Capability declarations differing only by href. Replaced with
AUTO_DOOR_VARIANT, a pattern cap keyed on href_prefix='/autodoor/' and
gated by match_fn (presence of ado.openOptions) rather than the prefix
alone, so it only claims the variant-declaration hrefs and not
/autodoor/timer/vs/0 -- which doesn't matter in practice anyway, since
that href's own exact-href AUTO_DOOR_TIMER cap always wins first.
Registry-scoped (refrigerator.py's own pattern_capabilities list), not
global ignored.py -- the unknown-device-type fallback that motivates
ignored.py's 'exact hrefs only' rule never reaches this registry, so the
same constraint doesn't apply. A fourth fridge sub-type reporting this
feature at a new href now needs no code change to stay covered.
This board (NE63T8751SG/AA-class) reports no /information/vs/0 at all --
the modelNum-based routing fallback has nothing to read -- so it fell
back to 'unknown' and lost the whole range registry (oven mode/setpoint/
door/connected, cooktop monitoring). /oic/d does carry oic.d.range,
though, so this is a routing fix, not a new capability: adds 'oic.d.range'
to _OIC_TYPE_TO_KEY.
The second oven cavity is a genuine Pattern A indexed subdevice at
/device/1 (issue #177's mechanism) -- once routing resolves the master to
the range registry, the same registry already applies to the subdevice's
canonical view and every href on both binds with zero gaps.
_discover_full gains an optional device_types param (default (), every
other fixture unaffected) so a fixture that can only route via /oic/d can
exercise the same subdevice-aware pipeline the other composite fixtures
already do.
Three new dumps from one household's TP1X_REF_21K fleet (regular
single-door, kimchi, wine cellar) exposed the Auto Door Open feature's
timer and voice/sound feedback toggles, plus wine-cellar-specific
coverage: a deodorizing filter at its own href, a multi-compartment
pantry select, and a table-revision info resource.
- STATUS_LOCK gains auto_door_voice_control/auto_door_sound_control,
gated on each field's own presence.
- New AUTO_DOOR_TIMER (a discrete-options select, same shape as the
DEFINITE_TEMPERATURE_COOLER/FREEZER pattern) and three no-entity
AUTO_DOOR_SINGLE/KIMCHI/WINECELLAR coverage hrefs -- every dump seen
reports exactly one openOptions value with no paired current/desired
field to choose against.
- New DEODOR_FILTER (reuses AIR_FILTER's entities at a different href),
WINECELLAR_PANTRY_ZONE, and WINECELLAR_INFO.
- by_type: oic.d.krefrigerator and x.com.st.d.winecellar routed to the
refrigerator registry via /oic/d, alongside the existing modelNum-based
routing.
- kimchi_zone_mode's translation catalog gains three supportMode codes
(bare storage_fridge/storage_freezer without the _normal suffix, and
the apparently-placeholder newmode_kimchi_0000) surfaced by the kimchi
fixture, across all seven languages.
Three new scrubbed fixtures + goldens + tests, one per variant.
filterUsage is already a 0-100 percentage on every confirmed family,
including ARTIK051_PRAC: filterStatus flips to 'wash' at
filterUsage == '100' regardless of filterCapacity (60/224/500 across
other fixtures), which only holds if filterUsage is already a percent.
filter_usage_percent() divided by filterCapacity again, reading a
filter due for washing as 20% fresh.
air_filter_usage_hours had the mirror problem: it read filterUsage
directly as an hour count with device_class=duration, when the field
is a percent. It's now derived from the percentage and filterCapacity
(new filter_usage_hours() in common.py) instead.
_display_option read self._bound.desc.display_fn without narrowing
desc's type first, unlike every other method in this class -- desc is
typed as the base SamsungEntityDescription, which has no display_fn
(only SelectDesc does). Cast it, matching the rest of the class.
washer_cycle_fallback no longer wraps an unrecognized code in an
'Unknown (0xNN)' label -- that baked untranslatable English into a
component built to be fully translatable. It now only ever surfaces a
device-provided personal-course name; an unrecognized standard code
displays as its raw value, same as before PR #251.
Also translates nl.json's '69'/'88' washer labels left in English (same
review), and makes de.json's own 'smart' states consistent with the
'Intelligente Lüftung' translation already used for smartventilation.
PR #251 and PR #275 predate this repo's ruff-format adoption on those
files; running the formatter (single->double quotes, line wrapping,
trailing-comma cleanup) keeps the merged code consistent with the rest
of the codebase. No behavior change.
- Add German (de) translation catalog (PR #312, by @edenhaus)
- Backfill cs/nl with the washer/dishwasher/dryer course codes PR #275
added to en.json (85, 0c, 0d, 26, 2a, 35) so every shipped language
still mirrors the English catalog key-for-key
- Backfill German with every catalog key added to main since PR #312
was opened: the PR #251/#275 course-code additions, plus AC/fan
preset states, kimchi zone mode, edge/indicator lighting, energy
saving mode, the learned-modes options flow, and newer exception
messages
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
The href alone was not a sufficient key. /mode/convenient/vs/0 is
declared by three family registries with three meanings: a real preset
resource on the AC, explicitly unmodeled on the dehumidifier (no live
current-value field), empty on the air purifier. Matching on the href
globally meant a dehumidifier reporting a mode there would learn it,
persist it, and show it in diagnostics for a resource nothing offers.
The coordinator now narrows LEARNABLE to the hrefs a climate entity is
actually bound to, at discovery -- which also retires the per-rep
subdevice walk, since those hrefs are already actual.
With that, LEARNABLE is a plain frozenset of hrefs and the per-href
LearnRule goes away: its two fields were the same module constants for
its only entry. observe() now returns the codes it learned rather than a
bool the caller re-reads the store to interpret, so the log names what
was new instead of everything ever learned.
learned.py also takes ownership of the entry key and persisted shape --
the options flow was the second module that knew both, and the shape has
already changed once.
Comment trims throughout, per CONTRIBUTING: the LEARNABLE entry no
longer recounts how many reporters there were, and three copies of the
same test-stub comment are gone.
Flatten the store to {href: [codes]}. One href carries one LEARNABLE
rule, so keying the codes by the rule's supported field too let the
write side (rule.supported_field) and both read sides (the module-level
SUPPORTED_FIELD) disagree the moment a rule used a different field --
codes learned and persisted, then never offered.
The options flow's reset step read the persisted value raw in the
entry-not-loaded branch, so malformed data aborted the one screen that
can clear it; route it through LearnedModes like every other reader.
For the same reason forget_learned_modes() now persists whenever the
entry carries a record, not only when the in-memory store had one: a
record _coerce rejected at startup exists only on the entry.
Some firmware reports a current mode that is missing from the same
resource's supportedModes. An ARTIK051 air conditioner sits in Quiet
while advertising only [Off, Sleep, Speed, Nano, NanoSleep], so HA
showed preset_mode: quiet and then refused to select it. A second
reporter has three identical units where only the two sharing an
outdoor unit hide it, which rules out a real capability difference.
learned.py remembers any such code and the coordinator persists it on
the config entry, so a mode the device only names while it is active
survives a restart. climate._supported unions it into the resource's
own list, which fixes the read and the write together --
async_set_preset_mode reverse-resolves the device code from that same
list.
Learning is allowlisted per canonical href rather than global. Across
the fixture corpus 17 dumps already report a current mode that is not
in supportedModes: an oven idling in NoOperation, a fridge's
/mode/vs/0 carrying capability tokens like WATERFILTER_DISABLE. Those
are not selectable options, and remembering one permanently would put
an option in the UI that the device can only reject. Only
/mode/convenient/vs/0 is learnable today.
On by default, with a per-device option that stops offering and
learning at once, and a reset step in the options flow for a code that
turns out to be bogus. Diagnostics report what was learned separately
from `resources`, which stays exactly what the device said.
Holding _session_lock for a whole write sequence buys certainty about what
the appliance saw and when, but blocks every poll and entity write for the
sequence's full length -- up to 10 x 30s. Which of those matters more
depends on what is being probed, so it is now hold_session_lock on
async_raw_write_sequence and a field on the service, defaulting to the
holding behavior that shipped.
Off, the lock is taken per write and released across the settle waits, so
entities keep updating through a long sequence. Exactly one of the two
context managers is ever the real lock -- asyncio.Lock isn't reentrant.
Tests assert the lock's actual state during the settle wait in both modes,
rather than just that the flag is accepted.
Hassfest rejects a filtered device target outright ("Services do not
support device filters on target, use a device selector instead"), and an
unfiltered one would offer every device in the installation. Both services
now take device_id as a required field with a device selector scoped to
this integration -- the shape fully_kiosk, guardian and unifi already use.
No schema change needed: cv.TARGET_SERVICE_FIELDS already accepts
device_id, so the options-flow panel's target= call keeps working.
Also trims the comments added with the review fixes back to the one or two
sentences CONTRIBUTING asks for.
- services.py: normalize an href before handing it to Subdevice.to_actual.
That transform is textual and rewrites only a trailing '0' segment, so
'/mode/vs/0/' passed through it untouched and normalized downstream to
the master's '/mode/vs/0' -- landing the write on the wrong oven cavity
while still answering 2.04, with nothing in the response to give it
away. Same order now on the read path.
- services.py: key `verified` off those same normalized canonicals. It was
built from un-normalized to_actual output against the coordinator's
normalized hrefs, so a non-canonical input missed the lookup and handed
back actual hrefs where the documented contract promises canonical ones.
- coordinator.py: report `held: None` when the verify re-read itself
didn't come back. A non-2.05 yields an empty rep, against which every
payload comparison is False, so a 4.04 or dropped read was reported as
`held: false` -- indistinguishable from the board reverting the write,
which is the one distinction verify_after exists to draw.
- coordinator.py: on a mid-sequence failure, say how many writes landed
and which, and still kick the refresh. Raising bare threw that away, and
the appliance is left holding a partial sequence.
Also documents why `settle` waits inside the session lock while
verify_after's wait deliberately doesn't: a poll landing between two
writes is exactly what the sequence exists to rule out, and the caps
bound the worst case at 10 x 30s.
The README's worked example and services.yaml's field example both wrote
`x.com.samsung.da.mode: "Bake"` to /mode/vs/0 -- singular, and a bare
string. That resource takes `modes` as an array (issue #300's own dump
shows `["NoOperation"]`), so both examples were a shape the device would
have ignored, in the one place a user is most likely to copy from. The
README's other two steps were invented the same way; replaced with the
mode -> state: Run sequence issue #300 is actually trying to prove out.
Also adds a short note that payloads go out verbatim, so field names and
types have to match what the resource really uses, pointing at
read_resource with no href as the way to check first -- and aligns the
two new Repo layout rows with the column their neighbors use.
The options-flow "Debug write" panel could only ever do one write to one
href per pass -- not enough for the issue #300 wall oven, whose board
discards settings writes while idle and only keeps them once a cycle is
already running. Finding what starts a cycle needs an ordered sequence of
writes across resources, with real settle delays between them, and a way
to check afterward whether anything actually held.
- coordinator.py: async_raw_write_sequence owns a whole ordered sequence
under one _session_lock hold (so a poll can't interleave mid-sequence),
with per-step settle and an optional delayed verify_after re-read done
outside the lock. async_raw_write is now a one-item wrapper over it, so
tests/test_coordinator_raw_write.py keeps passing unmodified. Also adds
async_raw_read, a live GET bypassing the cache -- staleness is exactly
what makes revert-testing unreliable.
- services.py (new): the two HA services. Device-target resolution scans
loaded coordinators' MAIN/subdevice identifiers and requires exactly one
match, so an area/label target can't silently fan a raw write out across
several appliances. Canonical->actual href translation happens here, not
in the coordinator, which stays subdevice-agnostic.
- services.yaml (new): selectors/descriptions for both services, inline
per HA's custom-integration support -- keeps translations/en.json's
mirror test (test_translations.py) green without touching all 6
languages for a services block. New exception keys (write caps, device
target resolution) still went into translations/*.json's existing
exceptions section, mirrored across all 6 languages.
- __init__.py: adds async_setup to register the services once, process-wide.
- config_flow.py: the debug panel's async_step_debug_edit now calls
write_resource instead of coord.async_raw_write directly, so there is
exactly one code path that performs a raw write.
- README.md: new Part 5 documenting both services, with a worked
write_resource example; points the capability-gap section at them.
tests/test_services.py (new): sequencing/ordering, settle timing, changed
vs. held (the reverted case is issue #300's own symptom), exactly-one-
device resolution, subdevice href translation, validation caps, and the
options-flow panel end to end through the service.
Same feature name as the WindFree already modeled via climate.py's preset
system on regular AC boards, but a genuinely different wire mechanism --
this device's fields live on their own dedicated hrefs with no evidenced
coupling to hvac_mode, unlike the Comode_Nano token's real gating rules on
legacy boards. Recorded in-line so this doesn't come up as a 'why isn't
this a preset' question again without the answer already being there.
PR #316 (fork stale by several months, most of its ~2200-line diff was drift
against main rather than real changes) proposed device support for the
Samsung System Fresh Air Ventilator (ACA-KR-TP2-21-AN9000). Extracted what
holds up, adapted to this project's conventions, and left out what doesn't:
Extracted:
- ventilation_mode select on CLIMATE's own href, gated via
_is_ventilation_mode_device so it can only ever bind on a device whose
entire supportedModes set is Purification/Ventilation/SmartVentilation --
verified against every real AC fixture in the corpus to confirm it can't
false-positive on an actual air conditioner's climate card.
- WINDFREE / WINDSLEEP switches on their own dedicated hrefs.
- A CO2 sensor on AIR_QUALITY, matching air_monitor.SENSORS' already-bound
device_class='carbon_dioxide'/unit='ppm' descriptor for the same field
shape rather than guessing fresh.
- HEPA_FILTER / DEVICE_ACTIVE reuse from air_purifier.py.
- Removing /airlevelcheck/vs/0 from _AC_IGNORED and binding
air_purifier.AIR_LEVEL_CHECK in its place: the PR's claim that this
project's old "scheduler plumbing" description was wrong turned out to
be independently verifiable against two of our own existing fixtures
(airconditioner_cac and airconditioner_tp1x_da_ac_rac_01011 both already
carry real, populated periodicSensingActivationState/autoExeState
values), so this benefits existing users, not just the one new device.
Left out:
- Unit/device_class ('ug/m3', pm10/pm25/pm1) on the existing dust/
fine_dust/super_fine_dust sensors, sourced from an unverified third-party
screenshot description. air_monitor.py already has an explicit, reasoned
rejection of this exact mapping for the exact same three fields:
Samsung's PM10/PM2.5 convention doesn't confirm where a third tier or a
PM1 reading fits, and a wrong guess mislabels the reading forever.
- A standalone common.POWER switch -- contradicts this registry's own
documented design (power is deliberately the climate entity's job) and
would affect every AC user, not just this device.
- Promoting wind/swing to independent selects for every AC user -- a UX
opinion, not a coverage necessity, and out of scope for this device's
own support.
- A model-name diagnostic sensor -- /information/vs/0 is already covered
via the global ignore list, so this wasn't closing an actual gap.
No raw diagnostics dump for this model was ever attached to PR #316, so
there's no fixture for it here (fabricating one would violate this
project's fixture-integrity rule) -- see
tests/test_airconditioner_ventilation_windfree.py's module docstring.
resolve()/for_device_by_model() return DeviceRegistry | None; four new
test files used reg.capabilities/reg.pattern_capabilities without
narrowing away None first. Add the same 'assert reg is not None' idiom
test_dehumidifier_tp1x_dhm01001_capabilities.py already uses.
Verified against a clean venv running the exact CI commands (ruff format
--check, ruff check, ty check, pytest) rather than trusting a stale local
venv that had picked up a mismatched python3.11/3.13 site-packages split.
- Fix a real bug: airconditioner.SOUND_MODE had no exists_fn, so on
boards (issue #319's FAC) that never report a live 'mode' value,
entity.py's default field-presence gate silently kept the select from
ever registering in HA -- while adapter.flatten() (what the golden/tests
read) has no such gate, so the tests passed while documenting behavior
the opposite of what shipped. Gate on supportedModes' presence instead.
- Add airconditioner.MDS_ABSENCE_CLEAN for the CAC-class board's
/mds/absenceclean/vs/0 -- byte-identical shape to issue #319's
/csi/absenceclean/vs/0, confirmed rather than guessed, closing one more
of that board's documented coverage-gap hrefs.
- Add missing translation state labels (all 6 languages) for
edge_lighting_mode/edge_lighting_color/indicator_light_mode's raw device
codes, so they render as real words instead of a raw '3000K' -> '3000 K'
fallback.
- Fix an orphaned comment above SOUND_MODE that actually described the
unrelated DISPLAY reuse, and correct two inaccurate rationale comments:
the sound/voice ignore reason claimed a distinction from SOUND_MODE that
this same dump contradicts, and the /csi/* ignore block's 'same
reasoning as air_purifier.COVERAGE' precedent only actually covers 1 of
its 5 hrefs.
- Correct the false 'no board-token match' claim in the FAC test file and
golden-regression docstring -- 'FAC' is a real _BOARD_TOKEN_TO_KEY entry
(for_device_by_model alone already resolves this board); add a test
that actually exercises that path, which nothing previously did despite
the docstring's claim.
- Drop a tautological burner-slot test that only re-asserted what the
golden regression test already covers via the same code path.
Six System A/C cassette units on the same board test_airconditioner_cac.py
already documented as having an incomplete coverage gap gave real dump
evidence for two of its remaining unbound hrefs:
- /edgelighting/vs/0: an accent-light strip with on/off, a Smart/High/Low
mode, and a Kelvin color-temperature select (3000K/4000K/6500K), all read
from the device's own live supported-value lists.
- /light/stateful/vs/0: a second, distinct light resource with its own
on/off and Smart/Low/High mode -- not to be confused with EDGE_LIGHTING
or DISPLAY_LIGHT's ambient mood light.
convenientMode/operatingOption on /edgelighting/vs/0 stay unexposed: present
on every dump but no evidence of what either actually controls.
Only three hrefs remain in test_airconditioner_cac.py's documented gap now
(absence-clean, sound-optimization, smart-sensing-cooling).
/diagnosis/vs/0 was the dump's only unbound href, now covered via
dishwasher.DIAGNOSIS (same shape already reused by airconditioner.py).
This steam-oven-class board's /mode/vs/0 options[] carries no UpperLamp_
token at all, unlike the NV7000BS-class board LAMP was proven against --
LAMP had no exists_fn, so it registered anyway, always read Off, and any
write to it was a no-op the device had no reason to honor. Gives it the
same options-token exists_fn gate issue #183 already added to
fast_preheat/natural_steam/energy_saving/cooktop_on_alert.
/alarms/vs/0 and /kidslock/vs/0 were the dump's two unbound hrefs -- both
are the exact shapes common.UNIVERSAL already models elsewhere
(common.ALARMS, common.KIDS_LOCK_VS_FALLBACK), picked individually rather
than pulling in all of UNIVERSAL to match this registry's existing
hand-picked-common style.
The six-vs-three burner count the reporter originally asked about is
expected behavior (the board's own /mode/vs/0 options genuinely advertise
six OperationState slots on hardware with three physical burners, with no
per-device signal to tell real slots from phantom ones) -- already
explained on the issue; this commit is scoped to the coverage warning.
/filter/airdustfilter/vs/0 was the dump's only unbound href -- this board's
internal deodorizing filter, same filterUsage/filterStatus field pair as
common.WATER_FILTER, but filterUsage here is already a 0-100 percentage
with no filterCapacity to divide by (confirmed by filterStatus=="wash" at
filterUsage=="100"). Uses air_-prefixed keys so a fridge with both a
water and an air filter gets two distinct entities.
This board routes purely via /oic/d's oic.d.airconditioner type (no
board-token match) and reports several resources the sibling
TP1X_DA-AC-CAC-01001 board (issue #191) left as a documented gap:
- /display/vs/0, /settings/sound/output/vs/0, /settings/sound/volume/vs/0
now reuse air_purifier.py's identical-shape capabilities instead of
duplicating them.
- /settings/sound/mode/vs/0 gets a new airconditioner.SOUND_MODE reading
the live supportedModes field, sharing laundry.py's existing
voice/tone/mute translation catalog since the value vocabulary matches.
- /csi/absenceclean/vs/0 and /csi/energysaving/vs/0 are new, genuinely
useful controls (absence auto-clean toggle, energy-saving mode select
plus its state/operatingStatus diagnostics).
- /dnd/autosleep/vs/0, /outdoorsharing/vs/0, /lifestyle/survey/vs/0,
/settings/sound/voice/vs/0 and /csi/information/vs/0 are ignored as
plumbing/unconfirmed data with no user-actionable state.
Also fixes a latent bug in air_purifier.SOUND_VOLUME: boards that report
minLevel/resolution but no maxLevel (this one) would have produced a
min=0/max=0 number entity instead of self-gating off.
Updates test_airconditioner_cac.py's documented coverage gap now that
sound_mode/sound_output/sound_volume are covered there too.