An independent review turned up the real bug behind the climate lag:
async_send_command applied the optimistic value and settle guard to
bound_entity.href, but write_fn's path_segs -- the resource actually
POSTed to -- can point somewhere else entirely. The AC's composite
climate entity is bound to /mode/vs/0, yet a power/temperature/fan/
swing/preset command writes to its own sibling resource (/power/0,
/temperature/desired/0, ...), which is also what climate.py reads the
displayed state from. The optimistic value landed on /mode/vs/0
instead, so the resource the entity actually shows stayed stale until
the next unrelated read of it -- surviving the earlier optimistic-apply
fix (issue #27), which applied to the same wrong href.
Derive the write's target from path_segs and apply/guard/log against
that instead. Reverts the previous commit's settle-window-sizing
change on this branch: that was chasing a real but speculative edge
case (a confirm poll slower than the settle window) that a follow-up
review couldn't confirm matches the reported symptom, and its
early-release mechanism had its own races (a debounced refresh that
hadn't actually run yet, overlapping writes to the same href) for
marginal benefit once this fix lands. Simpler to drop it than carry
that risk for a case not in evidence.
Added a coordinator-level regression test against the real AC CLIMATE
capability (not just a synthetic mismatched-path descriptor) sending a
power command and asserting the optimistic value lands on /power/0,
not /mode/vs/0.
Ovens with modelNum like TP1X_DA-KS-OVEN-0107X report no oneUiVersion
and don't match any consumer-prefix or other fallback token in
for_device_by_model, so the device came back as "unknown" and every
resource fell through to the global capability registry instead of
oven.py's own -- explaining the unbound /connected/vs/0 href, the
unrelated-looking entities, and the non-functional controls reported
in the issue. Add a '-OVEN-' modelNum token fallback, mirroring the
existing '-RANGE-' fallback from issue #44.
Rewires /mode/vs/0's packed-options parsing (display_light/operating_mode/
blooming_level) onto laundry.py's existing option_value/replace_in_options/
bool_option_exists/bool_option_value instead of hand-rolled reimplementations,
hoists the duplicated int-conversion helper into common.py (shared by
range_hood.py too), collapses the five near-identical AIR_QUALITY sensors
into a table-driven loop, extracts the /consumable/vs/0 item lookup into a
named helper, and moves the humidity ignore list into capabilities/
air_purifier.py's own COVERAGE list to match the airconditioner/range_hood
convention of keeping by_type files as pure composition.
No behavior change; golden state keys and all existing tests are unaffected.
Adds a by_type registry for the AX60R5080WD/SE air purifier family, verified
against two independent diagnostics dumps (issue #56 and its comment). Binds
power, alarms, energy, diagnosis (reusing dishwasher.DIAGNOSIS), the dust/
fine-dust/super-fine-dust/odor/clean-level sensors off /sensors/vs/0, filter
progress, a device-active diagnostic, and a display-light switch parsed out
of /mode/vs/0's packed options list.
Fan speed/direction (/airflow/0, /airflow/vs/0) and two other /mode/vs/0
tokens (Comode_*, Blooming_*) are exposed as read-only diagnostics rather
than full controls -- neither dump has a supported-values list to confirm
their write contracts, so they're left for a follow-up once that's
clarified in the issue thread.
Hoists range_hood's items[]-sensor-value helper into common.py
(sensor_item_value) since air_purifier now reads the same /sensors/vs/0
shape.
mark_write_pending's window was a fixed few seconds, started before the
PUT and the confirming /device/0 refresh that follows it -- but that
refresh is a full summary poll, which can legitimately take far longer
than that on these AC devices. The window routinely expired while the
confirm poll was still in flight, so a stale read (the device's own
resource tree hadn't caught up to the instant physical change yet)
landed unprotected and reverted the optimistic write, with nothing to
correct it again until the next scheduled summary poll. That's the
20-60s lag both issues report even after the earlier optimistic-apply
fix.
Size the window to cover the PUT and confirm-poll timeouts combined,
and release it as soon as that round trip actually completes (success
or failure) instead of leaving it open for the rest of a now much
longer window.
mark_write_pending's settle window was dropping every update for a
just-written href, including the coordinator's own post-write refresh,
because nothing ever wrote the optimistic value into the cache for it
to protect. The write reflected on the device immediately but reverted
in HA until the next 30s summary sweep.
- NumberDesc gains native_min_fn/native_max_fn/step_fn hooks (mirroring the
existing unit_fn pattern) so an entity's slider bounds can track the live
rep instead of staying pinned to whatever unit the descriptor was written
against. Oven setpoint was hardcoded to Celsius bounds (30-270), which
silently capped issue #44's Fahrenheit range at 270F -- below a normal
350F bake temp. Verified Fahrenheit bounds (175-550, step 5) come from
that dump's /mode/vs/0 Bake modeSpec.
- Wire oven.OVEN_SPEC into the oven registry, not just range -- it was only
reachable from range before, so a standalone oven reporting
/oven/spec/vs/0 would have false-tripped the coverage-gap repair.
- Fix a docstring in test_golden_regression.py left over from the
cooktop.py -> range.py rename.
PR #23 independently adds registry/capabilities/cooktop.py for an unrelated
standalone-cooktop product (NA9300K-class, burner state encoded in
/mode/vs/0's options array) -- different hardware and a different OCF
surface than issue #44's oven+cooktop combo range, but the same file path.
Rename ours to range.py to keep both mergeable.
TP1X_DA-KS-RANGE-0102X (model NSI6DG9100SRAA) reports no oneUiVersion and
previously fell through to the unknown-device fallback, leaving /connected,
/cooktop/spec, /cooktop/settings/status, /cooktop/status, and /oven/spec
unbound. Add a 'range' device registry that reuses the oven family's
cavity/setpoint/mode/operational-state/door capabilities and adds a new
cooktop.py module modeling per-burner power level, state, and hot-surface
entities (gated so unreported burner slots don't appear), plus a hot-surface
auto-shutoff config sensor. Route range/cooktop models to it via a
'-RANGE-' modelNum token, mirroring the existing RAC/PRAC air-conditioner
fallback pattern.
The dryer (DA_WM_TP1_21_COMMON) pause/stop buttons mentioned in the same
issue are working as intended -- the reporter confirmed that's an expected
in-person-only limitation, not a bug.
Move the on/off interpretation into a single remote_control_enabled()
in registry/capabilities/common.py so the write-guard added in the
previous commit can't silently drift from the Smart Control binary
sensor's own reading of the same hrefs. Also promotes both
/remotectrl hrefs to poll_tier='warm' so the coordinator's cached
state backing that write guard doesn't lag up to a full 30s cold
summary poll behind the device's actual toggle state.
Devices with a /remotectrl href already surface it as a read-only
"Smart Control" binary sensor, but writes weren't checking it before
now. async_send_command now blocks every write (any platform) with a
ServiceValidationError telling the user to enable remote control via
the appliance's manual, ahead of any per-description validate_fn.
Issue #27: the flex-zone/cooler-drawer select vanished after a device
stopped including supportedOptions on an update for /mode/vs/0.
ObserveManager.apply() handed reps straight to StateCache.apply_rep,
which fully replaces the cached rep -- so a partial update (missing a
field the select's exists_fn/options_field gate on) silently erased
data a fuller update had previously supplied. apply() now merges
incoming reps onto whatever's already cached instead.
Also give ice-maker entities (and any future pattern-cap instance) a
device-given display name instead of the href-derived "Icemaker
One"/"Icemaker Two": Capability.name_field lets a pattern capability
read and normalize an instance name (e.g. iceMaker.name's "CUBED_ICE")
for use as the entity name prefix, independent of the stable
key/unique_id.
The switch and select shared a stub-time asymmetry: only the select had a
`not rep` carve-out, so an unfetched-stub rep at the moment platforms are
set up (entity creation runs once, ever) would instantiate a Select, while
flatten() re-evaluates exists_fn every poll against live data -- once the
resource populated to a single-level list, the switch's exists_fn would win
instead and feed the already-created Select a bool through their shared
'ai_energy_level' key, which isn't a valid select option.
Dropped the stub carve-out from the select's exists_fn so both sides
require real, populated data to decide the platform -- on a device that
stubs this cold-tier href on its very first poll, the entity now simply
doesn't appear until a reload, instead of appearing as the wrong widget
type. Added tests for the missing/empty-list supportedAiLevel shapes on
both widgets and a regression test locking in the stub behavior.
Consolidate the AC/power-exclusion rationale (previously spelled out nearly
verbatim in three places) down to one canonical explanation next to
common.POWER, with one-line pointers elsewhere. Dedupe the switch/select
test classes' identical _desc() lookup into one shared helper.
FIRMWARE_UPDATE and ALARMS were already copy-pasted into all 6 device-type
registries by hand; POWER/KIDS_LOCK/REMOTE_CONTROL into 5 of 6. Consolidate
into two bundles in common.py, unpacked via *common.UNIVERSAL / *common.POWER
the same way ignored.IGNORED already is:
- UNIVERSAL: ALARMS, ENERGY_METER, FIRMWARE_UPDATE (moved from fridge.py),
SELF_CHECK (moved from fridge.py), AI_ENERGY_LEVEL, and the kids-lock/
remote-control pairs. Safe everywhere -- discover() only binds a href
actually present in a device's dump, so a capability with no known
conflicting family is a no-op where the href is absent and a real,
wanted entity where it's present. This also broadens AI_ENERGY_LEVEL,
ENERGY_METER, and SELF_CHECK to device types they weren't confirmed on
before, on the same reasoning.
- POWER: just POWER_GENERIC/POWER_VS_FALLBACK, applied to the 5 non-AC
registries. Airconditioner keeps its own opt-out: its climate entity
already owns /power/0 and /power/vs/0 via a bare, no-entity claim
(airconditioner.COVERAGE), and a real power capability on the same
href would make _build() raise (a href with multiple caps requires
every cap to have rt_filter or match_fn; the bare COVERAGE cap has
neither).
Full test suite (327 tests, all 6 device-type golden fixtures) passes
unchanged -- none of the newly-broadened capabilities bind on any existing
fixture, confirming the no-op reasoning held in practice, not just theory.
Issue #40: /energy/ailevel/vs/0 was unbound on a plain washer. The
capability already existed for fridges but was gated off entirely on
single-level hardware (the common case), so it's moved to common.py
(cross-family, like fridge + washer now) and split into two entities:
a switch when supportedAiLevel has exactly one entry (aiLevel is really
just an on/off toggle there), and a select otherwise, with '0' (off)
synthesized back into the select's options since supportedAiLevel never
lists it but it's a real observed value.
Also drops the translation_key/strings.json entries -- aiLevel's raw
digit values already render fine untranslated, and translating a
handful of levels can't cover devices with more.
Add a validate_fn hook to SwitchDesc, checked in switch.py before dispatch
and surfaced as a ServiceValidationError so an unsupported write shows a
real error in the UI instead of the coordinator's silent log-only rejection.
Wired it into the three course-gated washer switches using their
availability bitmaps (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet),
which line up positionally with editCourseList. Turning a toggle off is
never blocked, and the check fails open whenever the course or bitmap can't
be resolved.
Also fixes a bug in _bool_option_write: it took a `p and 'On' or 'Off'`-style
truthy check, but switch.py always calls it with the string 'On' or 'Off' --
both truthy, so every write landed as 'On' regardless of intent.
A follow-up dump confirmed these ride as plain BubbleSoak_On/Off,
PreWashSetting_On/Off, and IntensiveSetting_On/Off tokens in the same
/course/vs/0 options array as the cycle select, so they're exposed as
self-gating config switches the same way other options-array fields are.
Per-cycle availability (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet)
lines up positionally with editCourseList but isn't used for gating, since
exists_fn only runs once at setup against whatever course happened to be
active then.
Rebased onto PR #36's merge (temperature fallback for /temperatures/vs/0,
display-light switch). Reconciliation, per Opus review:
- Dropped our duplicate LIGHT capability in favor of PR #36's DISPLAY_LIGHT
(same href/entity, cosmetic differences only).
- Dropped /option/muteonce/vs/0 and /selfcheck/vs/0 from the ignore list
PR #36 added -- both have confirmed, cleanly modelable contracts (a plain
On/Off field, and a documented Start/Cancel self-check action already
covered by the existing fridge.SELF_CHECK pattern), so keeping our bound
versions instead of ignoring them. This also picks up mute_once/selfcheck_*
on PR #36's own TP1X_DA-AC-RAC-01011 fixture -- regenerated its golden.
- Deduped the ~10 housekeeping hrefs both branches independently ignored.
- Added a unit test for climate.py's _temps_vs_item (the temperature-
fallback selection logic), which had no coverage -- climate.py isn't
importable in the registry-level AC tests, and the existing "no unbound
hrefs" tests only exercise binding, not the fallback itself.
Closes the coverage-gap repair for two more room-AC boards (issues #37,
- TP2X_RAC_20K reports no oneUiVersion and no '_PRAC_' modelNum token, so
its device type went unrecognized -- add a '_RAC_' fallback token to
for_device_by_model.
- Bind the resources both dumps actually populate: mute-once (switch),
display light (switch), self-check (reused from fridge.SELF_CHECK), and
the circuit-breaker current-limit setting (read-only diagnostic sensors,
since its write contract isn't confirmed from the dump).
- Ignore the rest as plumbing/opaque/unconfirmed: control-set descriptor,
keepnormalstate flag, software-reset trigger, AI Sleep handshake,
absence-monitoring stubs, remote-data-control, remote-temperature,
reservation ruleset, and the welcome-temperature tracker.
Adds scrubbed fixtures + goldens for both dumps and extends the AC
capability/golden-regression tests to assert zero unbound hrefs.
Adds two small, additive fridge capabilities:
- AI_ENERGY_LEVEL: a select on /energy/ailevel/vs/0 exposing the AI
energy-saving level, gated behind supportedAiLevel actually offering
more than one choice (previously globally ignored since every dump
seen only reported a single supported level).
- selfcheck_error: a diagnostic sensor on the existing SELF_CHECK
capability surfacing x.com.samsung.da.error from the last self-check,
for hardware that reports it.
This does not touch FLEX_ZONE, /mode/vs/0, or /icemaker/status/0.
- Parameterize FakeObserveSession.notify_on_subscribe as dict[str, Any] | None
to match the surrounding fully-typed attributes (the dict is delivered as
the OBSERVE rep to on_notification); add the typing.Any import.
- Reword the test_coordinator comment: subscribe() delivers a notification
for every href it subscribes to (when notify_on_subscribe is set), not one.
The observe tests raced a background thread (5 ms wall-clock sleep)
against the coordinator reaching its resubscribe. try_enter_observe_mode
clears _notified before subscribing, so a notify only counts if it lands
between that clear and the end of the grace sleep. When the intervening
update cycle (reconnect, cache sweep, executor hops) took longer than
5 ms, every notify was cleared and the mode stayed 'poll' —
test_reconnect_from_observe_mode_resubscribes_immediately failed roughly
1 in 7 runs.
Replace the threads with FakeObserveSession.notify_on_subscribe, which
delivers the rep synchronously from subscribe() the way a real device
answers a subscription. That puts the notify inside the grace window by
construction rather than by timing. Tests that require the resubscribe to
fail now set it to None explicitly instead of relying on the absence of a
racing thread.
No production code changed; no assertion weakened, and no sleeps, retries
or timeouts added.
Claude-Session: https://claude.ai/code/session_01PUSU6tDjHjtbExtyXDPT3N
Review follow-up (Opus + /simplify) on the previous commit:
- power_energy_kwh/energy_saved_kwh/energy_last_month_kwh/
energy_this_month_kwh used a plain `field in rep` exists_fn, unlike their
siblings power_watts/energy_kwh in the same capability. Per
entity._is_included, an explicit exists_fn bypasses the stub carve-out
entirely -- an empty {} rep at platform setup (device/0 returned a
not-yet-fetched stub) would permanently drop these entities for the
session instead of picking them up once a sub-poll populates the
resource. Restore the same `not rep or ...` guard used above.
- fridge._flex_zone_current/_flex_zone_write independently rebuilt the same
supportedOptions set; factor into _flex_zone_supported.
- note in a comment that the flex-zone match assumes at most one
modes/supportedOptions overlap (true on every dump seen); add the
missing negative assertion that dry_level self-gates off on a plain
washer (was only positively asserted on the combo fixture).
Six new diagnostics dumps, six gaps closed:
- refrigerator: bind /diagnosis/vs/0 (reuse dishwasher.DIAGNOSIS -- same
shape) into the refrigerator registry; it was never wired up there,
tripping the coverage repair on any fridge that reports it (#20, #26).
- refrigerator: add PANTRY_ZONE for the Cool Select Zone pantry compartment
(/status/pantry/one/vs/0, x.com.samsung.da.mode/supportedOptions) -- same
shape as BEVERAGE_ZONE but a distinct resource/field set (#20).
- refrigerator: generalize FLEX_ZONE to identify the current mode by list
membership in supportedOptions instead of a hardcoded
CV_TTYPE_RF9000A_ prefix check. TP1X/Bespoke-class fridges use a
CV_FDR_ prefix instead, which the old code didn't recognize -- the
select existed but always read as unknown, and writing to it would have
appended a duplicate CV_FDR_ flag rather than replacing the existing one
(#26, #27; also closes#32).
- common: add energy_saved_kwh (x.com.samsung.da.cumulativeSavedPower) and
power_energy_kwh (x.com.samsung.da.cumulativeConsumption) to the shared
energy meter, plus fridge-only energy_last_month_kwh/energy_this_month_kwh
(monthlyConsumption/thismonthlyConsumption) -- all self-gating on field
presence (#26).
- by_type: add the WV consumer-model prefix (FlexWash twin washers, e.g.
WV55M9600AW) to the by-model fallback map. These report no oneUiVersion
and previously matched no prefix at all, so they fell all the way
through to the unrecognized-device registry with zero capabilities
bound (#19).
- washer: add a self-gating dry_level select to WASHER_SETTINGS for
washer/dryer combo units, which carry a writable dryLevel field
directly on /washer/vs/0 with no separate dryer resource or course
(#22).
The reset-water-filter button and "ice type vs. two named icemakers"
requests from #26/#27 are left alone -- no write contract or exclusivity
behavior is evidenced in either dump, and both icemakers report On
simultaneously on the Bespoke unit, so synthesizing a single-select would
be a guess rather than a fix.
Adds scrubbed fixtures + goldens for FlexWash, a washer/dryer combo,
ARTIK051_REF_17K, and TP2X_REF_20K, plus unit tests for the new/changed
capabilities.
Two fixes for issue #9 (WW90T634DHE washer):
- washer dosing selects: the four detergent/softener dosing selects read their
current value from `<Prefix>LevelCtrl_<code>` (un-padded, e.g. "3") but their
options from `Supported<Prefix>LevelCtrl_<hexpairs>` (zero-padded, e.g. "03").
HA's SelectEntity renders a select "unknown" whenever current_option is not in
options, so all four sat "unknown" (idle and running) even though every other
select worked -- which is why it was only those four. Normalize the current
value to the supported code with the same integer value so it matches an
option (and its translation); convert back to the device's native un-padded
format on write.
- diagnostics: pkg_version("smartthings-local") reads package metadata off disk
(listdir + open + read_text), tripping HA's event-loop blocking-call detector.
Offload it to the executor. Audited the rest of the package: config_flow's
socket/crypto and every coordinator DTLS call are already offloaded via
async_add_executor_job -- this was the only blocking call left on the loop.
Also documents air-conditioner support in the README (device table, capability
module list, platform list), missed when that support landed.
Updates the washer dosing tests to the corrected value/write format and adds a
current-option-is-a-valid-option regression; adds a diagnostics test asserting
the version lookup runs off the event loop.
Add support for Samsung room air conditioners (ARTIK051_PRAC-class), the
first device whose core controls map onto a single Home Assistant `climate`
entity rather than a scatter of switches/selects/numbers.
- New `climate` platform + `ClimateDesc`: one composite entity that reads
power, HVAC mode, current/target temperature, fan (wind) strength, swing
(wind direction) and the convenient-mode preset across several OCF
resources and writes back to each. On/off folds into HVACMode.OFF /
TURN_ON/OFF; convenient mode folds into preset_mode. The entity binds one
primary resource (/mode/vs/0) and reads its siblings from the coordinator
snapshot, reusing the cross-resource read pattern from number/select.
- New `airconditioner` capability module + by_type registry, routed via the
`_PRAC_` modelNum token. Reuses common ALARMS/ENERGY_METER,
fridge.FIRMWARE_UPDATE and dishwasher.DIAGNOSIS; air purify and auto clean
as config switches; air dust filter status/usage as diagnostics (usage
normalized to a percentage of rated capacity).
- Climate-consumed and all-zero/ambiguous resources (temperature/wind,
/sensors, /humidity) are declared as AC-scoped coverage so every href in
the dump binds or is covered -- no coverage-gap repair.
- Fan/swing modes map onto HA standard constants (auto-localized); the
custom fan `turbo`, presets `quiet/smart/speed`, and the filter-status
enum get translations in strings.json + translations/en.json.
- Scrubbed fixture, golden, and tests: registry routing, zero unbound
hrefs, the climate write contract, and filter-% normalization.
The suite was never run in CI (only hassfest/HACS), and requirements-dev
listed only pytest + smartthings-local while conftest.py needs the full
Home Assistant test harness.
- requirements-dev.txt: add pytest-homeassistant-custom-component (pulls
in home-assistant + pytest + pytest-socket) and the integration's
runtime deps (cbor2, pyOpenSSL, cryptography) needed to import it.
- .github/workflows/validate.yml: add a Pytest job on Python 3.14
(current Home Assistant's floor) that installs requirements-dev and
runs the suite.
- test_config_flow.py: the UDP liveness-sweep test needs real loopback
sockets, which the HA harness blocks by default; take the pytest-socket
socket_enabled fixture so it runs instead of erroring.
Full suite: 249 passed.
Review follow-up. An explicit exists_fn bypasses entity._is_included's
empty-{} stub carve-out, so the sentinel-aware energy meter would drop
power_watts/energy_kwh when /device/0 returns a not-yet-fetched stub for
/energy/consumption/vs/0. Restore the carve-out (`not rep or ...`), and
hide power_watts when instantaneousPower is absent from a populated rep
(not just when it's the -500 sentinel) so a partial rep can't spawn a
phantom power sensor.
First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound
hrefs and an "incomplete capability coverage" repair. Handle them and,
while here, make the washer/dryer/dishwasher families consistent instead
of each carrying a bespoke variant of the same controls.
Dryer coverage:
- /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor
fallback pairs (prefer the standard OCF resource, fall back to -vs).
- /buzzersound/vs/0: new Buzzer sound select.
- /course/vs/0: cycle select shared with washer/dishwasher; ignore the
/st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0).
Consistency / de-duplication:
- Move generic OCF controls (power/kids-lock/remote-control fallback
pairs, energy meter) into common.py; every registry uses them.
- Move shared laundry controls (buzzer, job-beginning-status, and the
/course/vs/0 cycle-select machinery) into laundry.py; washer and
dishwasher stop hand-rolling their own copies.
- Energy meter is now sentinel-aware everywhere: the dead '-500'
instantaneousPower reading no longer shows a misleading 0 W (fixes it
on dryers and dishwashers, matching the earlier washer fix).
- Job-beginning-status reads x.com.samsung.da.currentStatus, the field
every dump actually carries; the dryer sensor was previously blank.
Adds a scrubbed dryer fixture, golden, and capability tests, plus an
.claude/skills/adding-device-support skill capturing the dump-reading,
OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
The config flow only probed 49154/49155, so appliances whose local
CoAP/DTLS API binds elsewhere in the ephemeral range (e.g. a dishwasher
answering on 49153) could never be added.
Add a fast UDP liveness sweep across 49152-49160 that uses the ICMP
port-unreachable / ECONNREFUSED asymmetry to find the live port(s)
before attempting the expensive DTLS handshake. Closed ports fall out
immediately; a live-but-silent port is kept as a candidate. The real
handshake then runs only against discovered ports, preferring the
historically known 49154/49155 when several look live.
Also drop the probe's /device/0 GET deadline from a bare 15s literal to
a named PROBE_GET_TIMEOUT_S constant (10s) — the slowest observed full
dump is ~8s, and 10s matches the per-resource read timeout used
elsewhere.
Bumps version to 0.5.0.
Assumes the LevelCtrl code scheme is None/Low/Medium/High (00-03) on both
dispensers -- code 00 has no on-screen equivalent in the app's 3-choice
Faible/Moyen/Élevé picker, assumed to be what "Activation" off collapses
to -- and Level2Ctrl is Soft/Medium/Hard for detergent water hardness,
1x/2x/3x for softener concentration. detergent_quantity and
softener_quantity share one translation_key (same vocabulary), same
pattern as fridge.py's shared 'brightness_level' key.
Not cross-device verified: only one dump + screenshot set (issue #9) to go
on, and the softener concentration reading doesn't cleanly match its
screenshot (assumed to be a setting changed between dump and screenshots,
not a different code scheme -- see the comment in washer.py).