Compare commits

...
Author SHA1 Message Date
Marc Billow afea1fd9ca Clean up microwave PR after main merge: dedupe skill doc, derive cook modes live
The merge from main had left the adding-device-support skill with a stale
duplicate "Enum selects need translation support" section (referencing a
nonexistent strings.json) sitting alongside the canonical version, and
main's new "never hard-code the dump" guidance hadn't landed on this
branch at all -- restore it and drop the duplicate.

Apply that guidance to the microwave capability itself: MICROWAVE_MODE's
cook_mode select was carrying a hardcoded _MICROWAVE_MODES tuple lifted
from the single issue #66 dump, even though /mode/vs/0 reports its own
live supportedModes list. Switch to options_field and validate writes
against the live rep instead of the static vocabulary, so a microwave
with a narrower or wider mode set (grill-less models, etc.) isn't stuck
with this one unit's options.

The microwave entities also shipped with no translations/en.json entries
at all (cook_mode, cavity_state, power_level, cavity_temp) -- add them,
mirrored into nl.json, and update the affected tests.
2026-07-27 00:03:55 +00:00
Marc Billow a8eb4ff98a Merge remote-tracking branch 'origin/main' into claude/device-support-issue-66-caztzs 2026-07-27 00:03:41 +00:00
Marc Billow aff7647460 Merge pull request #65 from Metal-Eagle/feature/add-completion-time
Feature/add completion time
2026-07-24 23:46:47 -05:00
Jeroen Hof 705da781f1 Merge branch 'main' into feature/add-completion-time 2026-07-25 06:41:17 +02:00
Jeroen Hof 6de1a4090e i18n: simplify completion time translation in English and Dutch 2026-07-25 06:02:45 +02:00
Jeroen Hof ef75db6697 i18n: update completion time translation in English and Dutch 2026-07-25 05:55:47 +02:00
Jeroen Hof 9e290f27ee refactor: update sensor descriptions for completion time and delay start 2026-07-25 05:24:10 +02:00
Jeroen Hof 37bfff152d refactor: remove unused strings.json file 2026-07-25 05:15:34 +02:00
Jeroen Hof 317b7d0c1b fix: add missing newline at end of en.json file 2026-07-25 05:09:55 +02:00
Jeroen Hof ba8e41d310 Implement code changes to enhance functionality and improve performance 2026-07-25 05:08:18 +02:00
Jeroen Hof 724686c42c i18n: add translations for AI Wash and Jeans 2026-07-25 05:01:40 +02:00
Jeroen Hof edccb6ae3f i18n: add new wash options for AI Wash and Jeans 2026-07-25 04:59:37 +02:00
Jeroen Hof 4a91f62a36 i18n: add new translations for AI Wash and Spijkerbroek 2026-07-25 04:49:40 +02:00
Jeroen Hof 1d4d38db14 Merge branch 'main' into feature/add-completion-time 2026-07-25 04:47:22 +02:00
Marc Billow 35e2c79b14 brand: use updated localthing logo 2026-07-24 20:23:48 -05:00
Marc Billow 26ca6179bd bug: remove name kwarg references 2026-07-24 15:52:28 -05:00
Marc Billow 92d64bd4f1 Merge branch 'main' into claude/device-support-issue-66-caztzs 2026-07-24 15:43:47 -05:00
Marc Billow f185951a8a i18n: missed a quick wash translation 2026-07-24 15:42:24 -05:00
Marc Billow adcb8a0ecb chore: add additional translations provided in #1 dutch 2026-07-24 15:33:31 -05:00
Marc Billow dc86c7f0ef Merge pull request #72 from mbillow/claude/home-assistant-i18n-iwwyvr
feat (i18n): refactor and improve translation support
2026-07-24 15:23:39 -05:00
Marc Billow 30d38d855c Merge branch 'main' into claude/home-assistant-i18n-iwwyvr 2026-07-24 15:23:10 -05:00
Marc Billow ee77e3bda8 chore: add additional translations provided in #1 2026-07-24 15:20:19 -05:00
Marc Billow ea986fe8e5 refactor: adopt cleaner write pattern for options arrays 2026-07-24 15:05:26 -05:00
Marc Billow 10c850f2b9 refactor(i18n): drop the vestigial descriptor name field
Since entity.py started routing named descriptors through the catalog
under desc.key, SamsungEntityDescription.name has been read for its
value nowhere -- only twice as a flag, to decide whether an entity was
translated at all. That left 148 English names duplicated between Python
and translations/en.json with nothing keeping them honest: six had
already drifted, invisibly, because editing the Python side changes
nothing a user sees.

So the field is gone, and translation_key defaults to desc.key. A
descriptor now sets translation_key only to share one catalog entry
across descriptors or to point at a differently-named one, and the
catalog is the only place an entity name exists.

Every descriptor resolves to exactly the translation key, icon, entity
category, enabled-default and gating it did before -- with one
deliberate exception: the hood fan, previously the sole descriptor with
no key at all, now resolves to 'fan'. That is inert, because fan.py sets
_attr_name = None so the entity presents as the device itself.

The three helpers that forwarded a name into a descriptor
(laundry.bool_option_switch, washer._bool_option_switch, air_purifier's
sensor table) lose that parameter. test_translations.py now requires a
catalog entry for every descriptor rather than only translated ones.

Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
2026-07-24 19:45:37 +00:00
Marc Billow 6281b40549 refactor(i18n): make the shipped catalog the single source of truth
PR #68 restated its own translation data in Python: a 60-line
TRANSLATED_SELECT_STATES table of frozensets duplicating every
entity.select.*.state key, a second _TRANSLATED_COURSE_TABLES table
naming which course tables have translations, and a strings.json that
was a 835-line byte-for-byte copy of translations/en.json save 43
[%key:...%] references. Each needed hand-syncing, and one was already
drifting.

Home Assistant loads exactly one file per language for a custom
integration -- translations/<lang>.json. It never reads strings.json and
never resolves [%key:...%]; both belong to Core's build tooling, which
custom integrations don't run through (hassfest skips a missing
strings.json and validates translations/en.json instead). So en.json is
the source, and the new catalog.py reads the keys and states back out of
it for the two decisions Python genuinely has to make:

  - select._display() normalizes a raw Samsung option to a lowercase
    state key only when the catalog knows it, else leaves the vendor's
    casing alone. Derived sets are identical to the removed literals.
  - laundry.cycle_select() keys off a device-reported course table only
    when that table has an entry, else falls back to the name-only
    'cycle' key. Translating Table_00 is now a translations-only change.

Also fixes six names that had already drifted between the Python
descriptors and the catalog, restoring HA's sentence case for two
generic ones (Auto release dry, Bubble soak) and taking the catalog's
wording for the rest, and adds a test so the vestigial descriptor names
can't silently disagree with the UI again.

Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
2026-07-24 19:32:09 +00:00
Marc Billow 0b6cc6aa9f Merge branch 'pr68' into claude/home-assistant-i18n-iwwyvr 2026-07-24 19:24:59 +00:00
Hmmbob 5d86dbe7e8 Resolve runtime translation references 2026-07-24 21:13:07 +02:00
Marc Billow e47a71689d Merge pull request #71 from splitsec2/fix/switch-off-inversion
fix(fridge): switch turn-off silently sends 'On' (truthy-string guard)
2026-07-24 13:50:53 -05:00
Rob Martin 9cf1e46bf6 fix(fridge): stop switch turn-off silently sending 'On'
Six fridge SwitchDesc write_fns built their payload with
`'On' if p else 'Off'`. The switch platform passes the literal
string 'Off' on turn-off, which is truthy, so the guard always
produced 'On' -- turning these switches off silently re-sent On
and they could never be turned off:

  - ICEMAKER_NIGHTTIME (ice.night.status)
  - STATUS_LOCK helper (devicecontrol + device.sound)
  - DEFROST_DELAY (delayDefrost)
  - WELCOME_LIGHTING (status)
  - CABINET_LIGHT dim (light.dimming.status)
  - ICEMAKER_STATUS_FALLBACK (iceMaker)

Compare `p == 'On'` instead, matching the pattern the other
capability files already use. Adds a regression test asserting
every affected write_fn sends 'Off' on 'Off' and 'On' on 'On'.
2026-07-24 12:11:53 -06:00
Hmmbob 82acc05d38 Limit changes to translation support 2026-07-24 18:40:44 +02:00
Hmmbob da49825e2e Harden unknown vendor value fallbacks 2026-07-24 18:12:01 +02:00
Hmmbob b85af10ae1 Test translation coverage and dynamic fallbacks 2026-07-24 18:08:29 +02:00
Hmmbob f9ebc8e286 Make LocalThings UI fully translatable 2026-07-24 18:08:29 +02:00
Hmmbob d0cfaeebc1 Add Dutch translations 2026-07-24 17:29:50 +02:00
Marc Billow 4a5d6bca59 Parse the wattage out of microwave power_level instead of a raw string
"700W"/"0W" was passed straight through as an untyped string sensor.
Parse the numeral and set device_class/unit so HA treats it as a power
measurement. Add a unit-handling step to the adding-device-support skill
so future dumps get this right the first time.
2026-07-24 15:28:46 +00:00
Marc Billow ede8719636 Add Samsung microwave support (TP2X_DA-KS-MICROWAVE-01011-class, issue #66)
Reuses the oven family's door/connected/operational-state capabilities
wholesale (identical resource shapes) and adds microwave-specific mode/
cavity/temperature capabilities for the fields that differ (cook-mode
vocabulary, powerLevel, no setpoint write). Resolves via a new '-MICROWAVE-'
modelNum token fallback, mirroring the existing '-OVEN-'/'-RANGE-' rules.
2026-07-24 15:26:21 +00:00
Marc Billow 7276ff65a8 Merge pull request #67 from mbillow/claude/arbitrary-href-writes-debug-5wjtm3
feat: add debug options panel for arbitrary resource-href writes (#54)
2026-07-24 10:17:35 -05:00
Marc Billow c731ecefe5 feat: add debug options panel for arbitrary resource-href writes (#54)
Power users can now pick a resource href from a live dropdown, view its
current value, and POST a minimal patch straight to the device -- to pin
down device-specific write behavior without waiting on a new release.
Bypasses the remote-control block and all write_fn/validate_fn logic by
design; the existing remote-control settings toggle moves behind the same
options-flow menu.
2026-07-24 14:02:15 +00:00
Jeroen Hof 365a1c4722 feat: enhance operational capabilities by adding completion_minutes and updating related logic; remove completion_time references 2026-07-24 15:35:17 +02:00
Jeroen Hof 7be91b56e8 Merge remote-tracking branch 'upstream/main' into feature/add-completion-time 2026-07-24 08:23:42 +02:00
Marc Billow 77cc875608 Merge pull request #64 from mbillow/claude/course-list-supportedoptions-fallback
feat: derive washer/dryer/dishwasher cycle list from supportedOptions when editCourseList is empty
2026-07-24 01:18:02 -05:00
Marc Billow ae711038ca Merge pull request #63 from mbillow/claude/issue-56-followup
Drop unexplained Blooming_* diagnostic, confirm remaining air purifier fields (issue #56)
2026-07-24 01:17:33 -05:00
Jeroen Hof 2c283218b0 feat: add completion_minutes and completion_time to state_keys in JSON fixtures 2026-07-24 08:10:17 +02:00
Marc Billow 3b3dd54372 Simplify table-scoped translation key; fix stale resolution; correct docstring
Simplification (feedback: this was overcomplicated): drop the
validated_table gate entirely. cycle_select's table_href now just builds
the translation key directly from whatever course table the device
reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of
comparing against a hardcoded known-good value and falling back to no key
on any mismatch. A table we haven't shipped translations for yet (e.g.
FlexWash's Table_00) still gets a key built for it -- Home Assistant's own
missing-translation handling takes it from there, the same graceful
fallback already relied on for any individual untranslated code within an
existing table. Adding a newly-confirmed table later is just new
strings.json entries, no code change.

Independent (Opus) review of the prior version caught two real issues,
fixed here regardless of the simplification above:

- translation_key was resolved once at entity construction from whatever
  coordinator.last_resources held at that moment. Discovery can run while
  a sibling resource is still an empty stub (documented precedent: see
  _is_included), so a callable translation_key could permanently bake in
  a stale value for the entity's lifetime. Moved resolution into a
  translation_key property override (Entity.translation_key is a property
  upstream, not a plain attribute), re-evaluated against live coordinator
  data on every access, matching how options/current_option already work.

- The supportedOptions fallback's "smallest passing K wins" docstring
  claimed every larger passing K is an exact multiple of the true one.
  False: the shipped dishwasher fixture has passing K=7 (true) alongside
  10, 14, and 35, none of which are multiples of 7 -- position 0 always
  lands on the same real course code regardless of K, which alone
  satisfies the current-course guard for several unrelated splits.
  Corrected the reasoning to what's actually true (an empirically-matched
  heuristic across six real dumps, not a proof) and added a regression
  test locking in the real dishwasher case so this isn't silently lost.
2026-07-24 05:55:04 +00:00
Jeroen Hof ab874797fc Merge branch 'main' into feature/add-completion-time 2026-07-24 07:48:57 +02:00
Jeroen Hof f5d4e2219a fix: remove redundant course codes from parse_edit_course_list test case 2026-07-24 07:47:53 +02:00
Marc Billow b2c32a90b6 Scope washer/dryer cycle translations to the device's own course table
Course codes on the shared /course/vs/0 contract aren't guaranteed
consistent across board generations: washer/combo devices report course
table Table_02, dryer devices Table_03 (x.com.samsung.da.st.courseTable,
previously fully ignored), and every code in washer_cycle/dryer_cycle was
confirmed exclusively against those. FlexWash's older DA_WM_A51 board
reports Table_00 instead -- applying the same translations there risked
showing a wrong name for any code that happens to numerically collide
between tables, not just an untranslated one.

SelectDesc.translation_key can now be a callable (resources -> key or
None), mirroring the existing pattern for `options`. laundry.cycle_select
gains optional table_href/validated_table params: when given, the renamed
washer_cycle_table_02/dryer_cycle_table_03 keys only apply when the
device's own course table matches exactly -- a different table, or no
table id at all, gets no translation_key (raw code display) rather than
a guess. dishwasher's call site is unchanged (static key, unconditional):
no equivalent table-id resource exists in any dump seen, and no evidence
its course codes vary by table the way washer/dryer's do.

entity.py and select.py resolve a callable translation_key once (via
coordinator.last_resources) and reuse that resolved value everywhere
_display() needs it, rather than re-checking the raw descriptor field.
2026-07-24 05:42:12 +00:00
Marc Billow b2c0517115 feat: derive washer/dryer/dishwasher cycle list from supportedOptions when editCourseList is empty
Some DA_WM_TP1/TP2-class boards populate /wm/editcourse/vs/0 without ever
filling in editCourseList itself (issue #1), so the Cycle select never gets
created even though the device clearly has one (confirmed via SmartThings
app screenshots and a currently-selected course).

/course/vs/0's own x.com.samsung.da.supportedOptions turns out to already
carry the course list, just undocumented: a 1-hex-nibble header followed by
one fixed-width record per course, self-indexed by a course-code first byte
rather than positional like editCourseList. Confirmed against six
independent real-world dumps pulled from open and closed GitHub issues.

cycle_options() now falls back to deriving this when editCourseList is
empty, gated on two checks: the derived codes must all be distinct, and
must include whatever course is currently selected. Larger multiples of
the true record width trivially re-pass both checks too (they're just a
sparser sampling of the same table), so the smallest passing width wins
rather than requiring one unambiguous match.

Also fires on the washer_flexwash fixture, newly creating a Cycle select
there -- unconfirmed against any ground truth for that device (a different,
older board generation with no editCourseList and no screenshots to check
against), flagged for follow-up discussion rather than silently accepted.
2026-07-24 05:06:39 +00:00
Jeroen Hof 86f07abf30 fix: correct expected output for parse_edit_course_list test case 2026-07-24 06:29:53 +02:00
Jeroen Hof b00390c093 fix: correct key for Jeans cycle in strings and translations 2026-07-24 06:27:28 +02:00
Jeroen Hof e913a3c2c0 feat: add new washer cycle options for AI Wash and Jeans 2026-07-24 06:26:24 +02:00
Jeroen Hof b2db4bf357 feat: add completion time and minutes sensors with parsing logic 2026-07-24 06:23:53 +02:00
Marc Billow 39dcf6a7b8 Drop unexplained Blooming_* diagnostic, confirm remaining air purifier fields (issue #56)
Per the five running-state diagnostics dumps (Auto/Sleep/Low/Medium/High)
gathered in the issue thread:
- Blooming_* has no corresponding SmartThings app setting, so it's dropped
  entirely rather than kept as an unexplained diagnostic.
- Comode_* reads 'Off' on all five, ruling out the original guess that it
  was the fan-speed selector -- still exposed read-only, purpose unconfirmed.
- OptionCode_60282 and the missing humidity sensor are confirmed correct as
  already modeled.
- /airflow's speed doesn't map monotonically to the five settings and the
  dumps were all captured within one ~30s poll cycle of each other, so it
  stays read-only pending a cleaner, time-spaced capture.

FilterProgress is untouched here: an earlier pass on this issue read the
thread as confirming 100 means "fresh" and renamed the sensor to filter_life
to match, but that reading was backwards -- the reporter clarified 100
means fully used and needs replacing, which is what filter_progress (the
already-shipped name) already implies. That rename was caught before
merging and is not part of this change.
2026-07-24 04:20:42 +00:00
65 changed files with 4262 additions and 928 deletions
+99 -12
View File
@@ -103,18 +103,105 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule).
## 5. Enum selects need translation support
## 5. Never hard-code the one dump's values
Any select whose options are raw device codes (course/cycle, and code-valued
settings) must render through translations, not Python:
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`;
`options`/`options_field` supply the **raw** codes.
- Add the labels to **both** `strings.json` and `translations/en.json` under
`entity.select.<translation_key>.state.<code>`, with the code **lowercased**
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's
the cue to identify and name them.
A single `/device/0` dump is **one device on one firmware** — its select options,
temperature range/increment, and any other "what values are valid here" data are
**that unit's snapshot**, not the field's universe. Other units of the same model
(different region, firmware, board revision) can support more, fewer, or
differently-stepped values. If the dump reports the live option/range list, wire
the descriptor to read it live — don't transcribe what you saw into a Python
literal:
## 6. Coverage discipline: bound or ignored
- **Selects**: use `options_field` (a resource field holding the live options
list, e.g. `supportedWaterTemperature`, `iceType.supported`) so `select.py`
reads the current device's real options every time, not `options=(...)` typed
from the dump. Reach for a callable `options` only when the values require
cross-resource computation the field alone can't give you — a static tuple is
right only for genuinely fixed, spec-defined enums (e.g. an OCF-standard field
with a closed value set), never for vendor `supported*` lists.
- **Number ranges/steps**: use `range_field` (a `[min, max]`-shaped field) or
`native_min_fn`/`native_max_fn`/`step_fn` to read bounds from the live rep —
see `oven.py`'s `_setpoint_bounds`. Only fall back to static `native_min`/
`native_max`/`step` when the dump has no such field and the bound is genuinely
fixed by spec, not just "the only value this one unit happened to report."
- **Anywhere else** a field's presence, count, or shape looks like it could vary
by model/config (course lists, capability flags, supported-mode arrays):
check whether the resource carries its own `supported*` companion field before
assuming the observed value is exhaustive.
When you do hard-code something (a genuinely fixed enum, a spec constant), that's
a judgement call worth a one-line comment saying why it's safe — the default
assumption should be "derive it," not "copy it."
## 6. Parse units out of the value — don't ship them embedded in a string
Samsung reps sometimes encode a numeral and its unit as one string
(`x.com.samsung.da.powerLevel: "700W"`; a `desired`/`current` temperature whose
unit lives in a sibling field instead). The lazy fix — a plain `SensorDesc`
with no `device_class`/`unit` that passes the raw string straight through —
binds the href and looks done, but HA then sees text, not a measurement: no
unit conversion, no long-term statistics, no graphing. This shipped once
already (microwave `power_level` went out as the literal string `"700W"`
before being caught in review) — treat that as the standard failure mode to
check for, not a one-off.
Before wiring up a numeric-looking field:
- **Check where the unit actually lives.** Embedded in the same string
(`powerLevel`)? A fixed, undocumented assumption (most wattages/energy
fields)? Or reported live in a sibling field (`/temperatures/vs/0`'s
per-item `unit`, `'Celsius'`/`'Fahrenheit'`)? Don't guess C vs F or W vs kW
without checking the dump.
- **Parse the numeral in `value_fn`** (regex or strip the unit suffix) so the
entity's state is a number, not `"700W"`.
- **Set `unit`** (static) **or `unit_fn`** (reads a live sibling field — see
`common.normalize_temp_unit`, used by both `fridge.py` and `oven.py` for a
per-device C/F reading) **plus the matching `device_class`/`state_class`**
so HA treats it as a real measurement.
- A plain string `SensorDesc` (no unit/device_class) is still correct for
genuinely non-numeric state (mode names, enum-like text) — reserve it for
that, not as a shortcut past parsing a numeral.
## 7. Names and enum labels live in translations, never in Python
Descriptors have **no `name` field**. Every entity is named from the shipped
catalog, keyed by `translation_key` — which defaults to the descriptor's own
`key`. So adding `SensorDesc(key='filter_status', ...)` obliges you to add:
```json
"entity": { "sensor": { "filter_status": { "name": "Filter status" } } }
```
to `translations/en.json`. Skip it and the entity ships nameless;
`tests/test_translations.py` fails the build instead.
- **Sentence case** ("Filter status", not "Filter Status"), per HA's style
guide — capitalize only proper nouns and Samsung feature names ("AI Energy
Mode", "Storm Wash+").
- Set `translation_key` explicitly only to **share** one catalog entry across
descriptors, or to point at a differently-named one. Two descriptors on the
same platform with the same `key` already share an entry — intended for
`common.py`'s OCF/vendor fallback pairs, a silent mislabel otherwise.
- Prefer HA's own vocabulary where it fits: a `device_class` gives you
translated states for free (`binary_sensor` door/running, `sensor`
timestamp/enum), so don't restate them.
Selects whose options are raw device codes (course/cycle, code-valued
settings) additionally need those codes labelled:
- `options`/`options_field` supply the **raw** codes; the catalog maps them.
- Add labels under `entity.select.<translation_key>.state.<code>`, code
**lowercased** (e.g. `"16": "Cotton"`). `select.py` derives which values it
normalizes from the catalog itself, so there is no Python list to keep in
sync — a code with no entry simply renders as the raw code, which is the cue
to identify and name it.
`translations/en.json` is the only place any of this lives: there is no
`strings.json` (Home Assistant doesn't read one from a custom integration) and
no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
## 8. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
@@ -128,7 +215,7 @@ friendlier href**.
ignored because washers bind it. When only one family should ignore an href
that another binds, scope the ignore to that family's registry.
## 7. Reuse before writing new code
## 9. Reuse before writing new code
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
@@ -137,7 +224,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
shared module rather than copying.
## 8. Lock it in
## 10. Lock it in
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
+12 -3
View File
@@ -1,3 +1,8 @@
<!-- dark mode -->
<img src="custom_components/localthings/brand/dark_logo@2x.png#gh-dark-mode-only" alt="LocalThings Logo"/>
<!-- light mode -->
<img src="custom_components/localthings/brand/logo@2x.png#gh-light-mode-only" alt="LocalThings Logo"/>
# LocalThings
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
@@ -19,6 +24,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Air conditioner | `by_type/airconditioner.py` |
| Air purifier | `by_type/air_purifier.py` |
| Dryer | `by_type/dryer.py` |
| Microwave | `by_type/microwave.py` |
| Oven | `by_type/oven.py` |
| Cooktop (read-only burner status) | `by_type/cooktop.py` |
| Range hood | `by_type/range_hood.py` |
@@ -106,7 +112,10 @@ custom_components/localthings/
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
One module per HA platform
strings.json / translations/ Config-flow copy + entity state translations
catalog.py Reads the shipped translation catalog (which keys/states exist)
translations/ Config-flow copy + entity name/state translations, one file per
language; en.json is the source of truth (no strings.json —
Home Assistant never reads one from a custom integration)
registry/
registry.py Builds the global capability registry, validates href collisions
capability.py Capability dataclass (href, entities, transforms)
@@ -116,8 +125,8 @@ custom_components/localthings/
identity.py Reads device identity for type detection
redact.py Strips account/identity data before diagnostics leave HA
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
cooktop, range_hood, dryer, oven, dishwasher, fridge, washer,
laundry, operational, ignored)
cooktop, range_hood, dryer, microwave, oven, dishwasher, fridge,
washer, laundry, operational, ignored)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
behavior, and golden-file regression against captured device dumps
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 146 KiB

+49
View File
@@ -0,0 +1,49 @@
"""The integration's own shipped translation catalog, as data.
Home Assistant loads exactly one file per language for a custom integration:
``translations/<lang>.json``. It does not read ``strings.json`` and it does
not resolve ``[%key:...%]`` references -- both of those belong to Core's
build tooling (``script/translations``), which custom integrations never run
through. So ``translations/en.json`` is not a generated artifact here, it is
the source of truth, and English is the one catalog required to be complete
(hassfest validates it for custom integrations).
That makes the English catalog the authority on *which* translation keys and
states exist, which is something the Python side genuinely needs to know:
* ``select`` has to decide whether to normalize a raw Samsung option into
a lowercase state key (so Home Assistant can look it up) or leave the
vendor's own casing alone as a readable fallback.
* ``laundry.cycle_select`` has to decide whether a device-reported course
table has translations at all before keying off it.
Reading it from the catalog instead of restating it in Python means adding a
state or a course table is a one-file change, and the two can't drift.
"""
from __future__ import annotations
import json
from pathlib import Path
# Read at import, not lazily: custom integrations are imported in an executor
# thread, so this stays off the event loop no matter who asks first.
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
).get('entity', {})
def has_entity_translation(platform: str, translation_key: str) -> bool:
"""Whether `platform`.`translation_key` has an entry in the catalog."""
return translation_key in _ENTITY_CATALOG.get(platform, {})
def translated_states(platform: str, translation_key: str) -> frozenset[str]:
"""The state keys translated for `platform`.`translation_key`.
Empty when the entity has no translation at all, and also when it has a
translated *name* but deliberately no state table (a course list whose
codes we can't map, for instance) -- callers treat both the same way:
leave the device's value untouched.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get('state', ())) if entry else frozenset()
+149 -10
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import datetime
import json
import logging
import re
import selectors
@@ -15,6 +16,10 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
ObjectSelector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
@@ -363,22 +368,27 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if has_creds:
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
reuse_note = "CA credentials will be reused from the existing device."
step_id = "user_reuse"
else:
schema = vol.Schema({
vol.Required(CONF_HOST): _TEXT,
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
})
reuse_note = ""
step_id = "user"
return self.async_show_form(
step_id="user",
step_id=step_id,
data_schema=schema,
errors=errors,
description_placeholders={"reuse_note": reuse_note},
)
async def async_step_user_reuse(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the localized host-only form for additional appliances."""
return await self.async_step_user(user_input)
async def async_step_confirm_unknown_type(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@@ -386,32 +396,68 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
return self._create_entry(self._pending_info)
if not self._pending_info["one_ui_version"]:
return await self.async_step_confirm_unknown_type_no_version()
return self.async_show_form(
step_id="confirm_unknown_type",
data_schema=vol.Schema({}),
description_placeholders={
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
"one_ui_version": self._pending_info["one_ui_version"],
},
)
async def async_step_confirm_unknown_type_no_version(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm an unknown appliance that did not report oneUiVersion."""
if user_input is not None:
return self._create_entry(self._pending_info)
return self.async_show_form(
step_id="confirm_unknown_type_no_version",
data_schema=vol.Schema({}),
)
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"""Per-device override of the remote-control-off write block (issue
#54). The block exists because most devices reject writes outright
while remote control is off and a clear error beats a silent
"""Per-device options: the remote-control-off write-block override
(issue #54) plus a debug panel for writing an arbitrary body to an
arbitrary resource href, so a user can pin down device-specific write
behavior without waiting on a new release.
The remote-control override exists because most devices reject writes
outright while remote control is off and a clear error beats a silent
device-side rejection -- but not every model actually enforces that,
so this lets a user who's confirmed their device accepts writes anyway
turn the block off for just that device rather than it being
hardcoded on for everyone."""
hardcoded on for everyone. The debug panel goes further: it bypasses
that block (and every write_fn/validate_fn) entirely, sending exactly
the body the user types to whatever href they pick.
"""
def __init__(self) -> None:
self._debug_href: str = ""
self._debug_result: tuple[int, dict] | None = None
def _coordinator(self):
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
return self.async_show_menu(
step_id="init",
menu_options=["settings", "debug_write"],
)
async def async_step_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
step_id="settings",
data_schema=vol.Schema({
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
@@ -421,3 +467,96 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
): bool,
}),
)
async def async_step_debug_write(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
if user_input is not None:
self._debug_href = user_input["href"]
return await self.async_step_debug_edit()
hrefs = sorted(coord.last_resources.keys())
return self.async_show_form(
step_id="debug_write",
data_schema=vol.Schema({
vol.Required("href"): SelectSelector(SelectSelectorConfig(
options=hrefs,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)),
}),
)
def _show_debug_edit_form(
self, href: str, current: dict, errors: dict[str, str], payload,
) -> FlowResult:
return self.async_show_form(
step_id="debug_edit",
data_schema=vol.Schema({
vol.Required(
"payload", default=(payload if payload is not None else {}),
): ObjectSelector(),
}),
errors=errors,
description_placeholders={
"href": href,
"current_value": (
json.dumps(current, indent=2, ensure_ascii=False)
if current else "{}"
),
},
)
async def async_step_debug_edit(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
href = self._debug_href
current = coord.resource(href)
if user_input is not None:
payload = user_input.get("payload") or {}
if not isinstance(payload, dict) or not payload:
return self._show_debug_edit_form(
href, current, {"payload": "empty_payload"}, payload
)
try:
code, new_rep = await coord.async_raw_write(href, payload)
except Exception: # noqa: BLE001 - surfaced to the user below
_LOGGER.exception("debug raw write failed for %s", href)
return self._show_debug_edit_form(
href, current, {"base": "write_failed"}, payload
)
self._debug_result = (code, new_rep)
return await self.async_step_debug_result()
return self._show_debug_edit_form(href, current, {}, None)
async def async_step_debug_result(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
code, new_rep = self._debug_result or (0, {})
return self.async_show_menu(
step_id="debug_result",
menu_options=["debug_write", "finish"],
description_placeholders={
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
"new_value": (
json.dumps(new_rep, indent=2, ensure_ascii=False)
if new_rep else "{}"
),
},
)
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
# Close the flow without altering saved options.
return self.async_create_entry(data=dict(self.config_entry.options))
+88 -11
View File
@@ -22,7 +22,7 @@ from smartthings_local.ocf.state_cache import StateCache
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model, for_device_by_resources
from .registry.capabilities.common import remote_control_enabled
from .registry.capabilities.common import merge_options_field, remote_control_enabled
from .registry.discovery import discover, BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
@@ -38,13 +38,6 @@ _LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0']
_REMOTE_CONTROL_DISABLED_MESSAGE = (
"Remote control is turned off on this device. Check your appliance's "
"manual for how to enable remote control before Home Assistant can "
"control it."
)
class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This
integration doesn't use per-capability observation hooks, so this is a
@@ -548,12 +541,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
resources = self._cache.snapshot()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if not bypass_remote_control and not remote_control_enabled(resources):
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_control_disabled",
)
validate_fn = getattr(desc, 'validate_fn', None)
if validate_fn is not None:
error = validate_fn(payload, rep, resources)
if error:
raise ServiceValidationError(error)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=error,
)
result = write_fn(payload, rep, href)
if result is None:
self._log.warning("write_fn rejected payload %r for %s", payload, href)
@@ -612,7 +611,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# introduced its own races around overlapping writes to the same
# href. Simpler and safer to just hold the guard for the full,
# generously-sized window and let it expire on its own.
self._observe.apply(write_href, body, source='optimistic')
# write_fn bodies that touch x.com.samsung.da.options carry only the
# changed token(s) now (issue #54: confirmed sufficient on the wire --
# the device merges by prefix itself), not the whole packed array.
# observe.apply()'s field-level {**cached, **rep} merge doesn't know
# that -- handed the bare token list, it would replace the cached
# field outright and wipe every sibling option for the rest of the
# settle window. Pre-merge it here the same way the device does, so
# the optimistic cache entry stays complete; the minimal `body` below
# is still exactly what goes out over the wire.
optimistic_body = body
new_options = body.get('x.com.samsung.da.options')
if isinstance(new_options, list):
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
optimistic_body = {
**body,
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
}
self._observe.apply(write_href, optimistic_body, source='optimistic')
self._observe.mark_write_pending(
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
)
@@ -630,3 +646,64 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.error("command failed for %s: %s", write_href, e)
else:
await self.async_request_refresh()
# ------------------------------------------------------------------
# Debug raw write (issue #54): a power-user escape hatch for the
# options-flow debug panel, letting a user POST an arbitrary partial
# body to an arbitrary href to pin down device-specific write behavior
# without waiting on a new release. Deliberately bypasses the
# remote-control block and every write_fn/validate_fn above -- that's
# the whole point, so use with care.
# ------------------------------------------------------------------
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
"""Debug primitive: POST an arbitrary patch, then read the href
back for ground truth. Blocking -- runs in executor."""
if self._session is None:
self._connect_session()
sess = self._session
if sess is None:
raise RuntimeError("no session")
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
self._log.warning("DEBUG raw write POST %s %r → code %#04x", href, body, code)
new_rep: dict = {}
try:
sess.pace()
rcode, payload = sess.get(path_segs, timeout=10.0)
if rcode == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
self._observe.apply(href, rep, source='poll')
new_rep = rep
except Exception as e:
self._log.debug("raw write follow-up read failed: %s", e)
return code, new_rep
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
"""Debug-only arbitrary write (issue #54). Bypasses the
remote-control block and all write_fn/validate_fn logic; sends
`body` verbatim as a partial-rep PATCH to `href`. Returns
(coap_code, new_rep) where new_rep is the href's value read back
right after the write. Used by the options-flow debug panel to
help users pin down device-specific write behavior without a new
release."""
if not isinstance(body, dict) or not body:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="debug_payload_empty",
)
path_segs = [s for s in href.strip('/').split('/') if s]
if not path_segs:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="resource_href_required",
)
norm_href = '/' + '/'.join(path_segs)
async with self._session_lock:
code, new_rep = await self.hass.async_add_executor_job(
self._raw_write_blocking, path_segs, body, norm_href
)
# Hasten a full summary poll so entities on other resources catch
# up too -- a debug write can affect siblings, not just its href.
await self.async_request_refresh()
return code, new_rep
+55 -11
View File
@@ -38,15 +38,32 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
def _derive_name(state_key: str) -> str:
"""Turn a snake_case state key into a title-cased display name.
"""Turn a snake_case state key into a title-cased label.
Strips a trailing instance number of 0 (singleton), promotes any other
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
Entity names themselves come from the translation catalog; this only
builds the {instance_name} placeholder those translations interpolate,
for a device that named its own compartments/ice makers.
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
return _snake_to_title(name).strip()
def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
"""Return the stable vendor/href instance label used in a name placeholder."""
if bound.instance_name:
return bound.instance_name
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[:-len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"""Base class for all Local Things entities."""
@@ -57,21 +74,48 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._bound = bound
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
if bound.desc.name is not None:
self._attr_name = bound.desc.name
elif bound.instance_name:
# A device-given instance name (e.g. an ice maker's "Cubed
# Ice") takes the place of the href-derived instance label,
# keeping the same entity-specific suffix (issue #27).
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
else:
self._attr_name = _derive_name(self._state_key)
self._attr_translation_key = bound.desc.translation_key
if bound.desc.translation_placeholders is not None:
self._attr_translation_placeholders = dict(
bound.desc.translation_placeholders
)
elif bound.desc.use_instance_name:
self._attr_translation_placeholders = {
"instance_name": _instance_display_name(bound, self._state_key)
}
# _attr_name is deliberately left unset: Home Assistant gives an
# explicitly-set name precedence over the translation catalog, so
# setting it here would make every entity untranslatable. Every
# descriptor resolves to a catalog entry (see translation_key below);
# a platform that wants the bare device name instead sets
# _attr_name = None itself, as fan.py does for the hood's main entity.
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
@property
def translation_key(self) -> str | None:
"""The descriptor's catalog key, defaulting to its own `key`.
Overrides Entity.translation_key (a property upstream, not a plain
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
table-id-gated resolver -- is re-evaluated against live coordinator
data on every access, not resolved once at construction time.
Discovery runs on the first /device/0 poll, which the entity
registry already documents can hand a sibling resource an empty
stub rep before it's actually been fetched (see _is_included's
docstring) -- a static one-time resolution here would risk baking
in a permanent None (no translation) for the entity's whole
lifetime if that stub hadn't populated yet, even once the real
value arrives on a later poll.
"""
tk = self._bound.desc.translation_key
if callable(tk):
return tk(self.coordinator.last_resources)
return tk if tk is not None else self._bound.desc.key
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info
@@ -3,7 +3,7 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
air_purifier, airconditioner, cooktop, dishwasher, dryer, microwave, oven,
range as _range, range_hood, refrigerator, washer,
)
@@ -21,6 +21,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'cooktop': cooktop.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
@@ -141,6 +142,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-OVEN-' in (model_num or '').upper():
key = 'oven'
# Microwaves (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same
# board-family naming as the range/oven combos above; reports no
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-MICROWAVE-' in (model_num or '').upper():
key = 'microwave'
return _REGISTRY_BY_KEY.get(key) if key else None
@@ -0,0 +1,24 @@
"""Microwave device registry — issue #66.
Reuses the oven family's door/connected/operational-state capabilities
wholesale (byte-for-byte identical resource shapes, all generically-keyed)
and adds microwave-specific mode/cavity/temperature capabilities for the
fields that differ. See capabilities/microwave.py's module docstring.
"""
from ..capabilities import common, ignored, microwave, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='microwave',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_OPERATIONAL_STATE,
microwave.MICROWAVE_MODE,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_TEMPERATURE,
]),
)
@@ -1,6 +1,5 @@
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
(model AX60R5080WD/SE, issue #56 -- verified against two independent
diagnostics dumps of the same internal model).
(model AX60R5080WD/SE, issue #56).
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
@@ -8,53 +7,60 @@ pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
dishwasher.DIAGNOSIS -- identical field/write contract
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
Two things are deliberately left as raw, unwritable diagnostic sensors rather
than modeled as real controls, per the "don't guess" rule:
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
'<Prefix>_<value>' flags into one list -- the same packed-list contract
laundry.py's option_value/option_write already model for /course/vs/0's
options[] (reused directly below, just against this family's own href). Per
issue #56's follow-up (five diagnostics dumps captured with the physical unit
set to Auto/Sleep/Low/Medium/High):
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
real switch, RMW-replacing just that one entry.
Comode_Off -- read 'Off' on *every* one of the five dumps,
including High/Low/Medium/Auto -- confirms this
is NOT the fan-speed selector (ruling out the
original guess); exposed read-only since its
actual purpose is still unconfirmed.
OptionCode_60282 -- confirmed opaque/not user-facing in the
SmartThings app; not modeled (same treatment as
range_hood's OptionCode_* token on the same
href).
Blooming_* -- confirmed to have no corresponding SmartThings
app setting; dropped entirely rather than kept
as an unexplained diagnostic (it did track 1:1
with Sleep mode across the five dumps -- 0 in
Sleep, 6 otherwise -- so it's plausibly an
automatic side effect of sleep mode, e.g. a
display-dimming level, but that's still a guess).
/airflow/0, /airflow/vs/0 -- OCF-standard + vendor pair for fan speed/
direction, both zeroed/'Off' on every dump seen (device was off in both).
No supportedSpeed/supportedModes list is present anywhere in either dump
to confirm the valid range, so a write-capable fan/select isn't safe to
build yet -- see the issue #56 request for a running-state dump.
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
'<Prefix>_<value>' flags into one list -- the same packed-list/RMW
contract laundry.py's option_value/replace_in_options already model for
/course/vs/0's options[] (reused directly below, just against this
family's own href). Of the tokens seen:
Light_On / Light_Off -- read as a plain on/off flag; MODE below
models it as a real switch, RMW-
replacing just that one list entry.
Comode_Off -- never seen non-'Off' on these dumps;
likely the fan operating mode the issue
describes (Auto/Sleep/1/2/3), but
unconfirmed -- exposed read-only.
Blooming_0 / Blooming_6 -- meaning unconfirmed; exposed read-only.
OptionCode_60282 -- opaque, unchanged across both dumps;
not modeled (same treatment as
range_hood's OptionCode_* token on the
same href).
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
fan-speed control: across the same five dumps it read 0 for both Auto *and*
High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any
selectable level, and the dumps were all captured within about three minutes
of each other (only one poll cycle apart at this integration's 30s summary
interval), so the values may not have settled after each change before the
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
stable capture -- see the issue #56 discussion for what's needed.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
from .common import int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, replace_in_options
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
_AIR_QUALITY_SENSORS = (
('dust', 'Dust', 'mdi:blur', 'Dust'),
('fine_dust', 'Fine dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'Super fine dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'Odor', 'mdi:scent', 'Odor'),
('clean_level', 'Clean level', 'mdi:air-filter', 'CleanLevel'),
('dust', 'mdi:blur', 'Dust'),
('fine_dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'mdi:scent', 'Odor'),
('clean_level', 'mdi:air-filter', 'CleanLevel'),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', name=name, icon=icon,
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, name, icon, sensor_type in _AIR_QUALITY_SENSORS
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
),
)
@@ -68,16 +74,18 @@ def _consumable_state(items, name):
return None
# FilterProgress is a raw 0-100 percentage in both dumps (100 and 62); which
# end of that scale means "replace me" isn't confirmed from the dump alone,
# so the entity is named after the raw field rather than asserting a
# direction (see issue #56 follow-up questions).
# FilterProgress is a 0-100 percentage counting up as the filter wears --
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
# once this reaches 100, so 100 means fully used, not "brand new." Named
# after the raw field (matching the AC/range_hood filterUsage convention,
# which counts the same direction) rather than "filter life," which would
# imply the opposite direction.
FILTER = Capability(
href='/consumable/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
name='Filter progress', unit='%', state_class='measurement',
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))),
@@ -89,7 +97,7 @@ DEVICE_ACTIVE = Capability(
poll_tier='cold',
entities=(
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
name='Device active', icon='mdi:check-network-outline',
icon='mdi:check-network-outline',
entity_category='diagnostic',
value_fn=lambda v: bool(v)),
),
@@ -102,10 +110,10 @@ AIRFLOW_GENERIC = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='fan_speed_level', field='speed',
name='Fan speed level', icon='mdi:fan',
icon='mdi:fan',
state_class='measurement', entity_category='diagnostic'),
SensorDesc(key='fan_direction', field='direction',
name='Fan direction', icon='mdi:rotate-3d-variant',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
@@ -116,20 +124,25 @@ AIRFLOW_VS_FALLBACK = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
name='Fan speed level', icon='mdi:fan',
icon='mdi:fan',
state_class='measurement', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
name='Fan direction', icon='mdi:rotate-3d-variant',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
def _light_write(payload, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
# option_write's single-token write is confirmed on a washer's
# /course/vs/0 (issue #54), NOT independently on this family's
# /mode/vs/0 -- extrapolated on the assumption the same vendor field
# merges the same way everywhere. If some unit replaces the field
# outright instead, this would drop Comode/OptionCode alongside it on
# the next light toggle; revisit if a real device report surfaces that.
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, 'Light', payload),
'x.com.samsung.da.options': option_write('Light', payload),
}
@@ -137,20 +150,17 @@ MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='display_light', name='Display light', icon='mdi:led-on',
SwitchDesc(key='display_light', icon='mdi:led-on',
entity_category='config',
rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'),
write_fn=_light_write),
# Read-only pending issue #56 follow-up -- see module docstring.
SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan',
# Read-only -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed.
SensorDesc(key='operating_mode', icon='mdi:fan',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')),
SensorDesc(key='blooming_level', name='Blooming level', icon='mdi:flower',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Blooming'),
exists_fn=bool_option_exists('Blooming')),
),
)
@@ -109,7 +109,7 @@ AIR_PURIFY = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
name='Air purification', icon='mdi:air-purifier',
icon='mdi:air-purifier',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
@@ -123,7 +123,7 @@ AUTO_CLEAN = Capability(
poll_tier='cold',
entities=(
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
name='Auto clean', icon='mdi:spray-bottle',
icon='mdi:spray-bottle',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
@@ -137,12 +137,12 @@ AIR_FILTER = Capability(
poll_tier='cold',
entities=(
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
name='Filter usage', unit='%', state_class='measurement',
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
name='Filter status', device_class='enum',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='air_filter_status',
translation_key='filter_status',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
@@ -153,7 +153,7 @@ DISPLAY_LIGHT = Capability(
poll_tier='cold',
entities=(
SwitchDesc(key='display_light', field='mode',
name='Display light', icon='mdi:led-on',
icon='mdi:led-on',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
@@ -170,7 +170,7 @@ MUTE_ONCE = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='mute_once', field='muteonce',
name='Mute once', icon='mdi:volume-mute',
icon='mdi:volume-mute',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
@@ -190,11 +190,11 @@ CURRENT_LIMIT = Capability(
poll_tier='cold',
entities=(
BinarySensorDesc(key='current_limit_enabled', field='operation',
name='Current limit enabled', icon='mdi:current-ac',
icon='mdi:current-ac',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(key='current_limit_level', field='modes',
name='Current limit level', icon='mdi:current-ac',
icon='mdi:current-ac',
entity_category='diagnostic'),
),
)
@@ -65,6 +65,33 @@ def _active_alarm_codes(items):
return ', '.join(codes) if codes else 'none'
def merge_options_field(cached, new_tokens):
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
x.com.samsung.da.options[]-style array the same way the device itself
merges them: match by prefix, replace if present, append if not.
Confirmed on real hardware (issue #54) that a write only needs to carry
the changed token(s), not the whole array -- see laundry.option_write /
oven._option_write for the write side. This is the read side of that
same fact: coordinator.async_send_command uses it to keep the
optimistic cache entry for the written href complete (every sibling
option still present) during the write-settle window, since the wire
body it applies straight to the cache no longer carries them."""
merged = list(cached or [])
for token in new_tokens or ():
if not isinstance(token, str) or '_' not in token:
continue
prefix = token.split('_', 1)[0]
replaced = False
for i, o in enumerate(merged):
if isinstance(o, str) and o.startswith(prefix + '_'):
merged[i] = token
replaced = True
if not replaced:
merged.append(token)
return merged
def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
@@ -102,7 +129,6 @@ POWER_GENERIC = Capability(
href='/power/0',
entities=(
SwitchDesc(key='power_switch', field='value',
name='Power',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
@@ -114,7 +140,6 @@ POWER_VS_FALLBACK = Capability(
match_fn=lambda rep, resources: '/power/0' not in resources,
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
@@ -126,7 +151,7 @@ KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
name='Child lock', device_class='lock',
device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
@@ -138,7 +163,7 @@ KIDS_LOCK_VS_FALLBACK = Capability(
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
name='Child lock', device_class='lock',
device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
@@ -172,7 +197,7 @@ REMOTE_CONTROL_GENERIC = Capability(
poll_tier='warm',
entities=(
BinarySensorDesc(key='remote_control', field='value',
name='Smart Control', device_class='connectivity',
device_class='connectivity',
value_fn=lambda v: bool(v)),
),
)
@@ -184,7 +209,7 @@ REMOTE_CONTROL_VS_FALLBACK = Capability(
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
name='Smart Control', device_class='connectivity',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
@@ -194,7 +219,7 @@ ALARMS = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
name='Alarm code', icon='mdi:alert',
icon='mdi:alert',
entity_category='diagnostic', value_fn=_active_alarm_codes),
),
)
@@ -217,13 +242,13 @@ ENERGY_METER = Capability(
# entity when /device/0 returns a not-yet-fetched stub. On a populated
# rep, hide power only for the dead sentinel or an absent field.
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
name='Power', device_class='power', state_class='measurement',
device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: not rep or (
rep.get('x.com.samsung.da.instantaneousPower')
not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
name='Energy', device_class='energy',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
@@ -234,7 +259,7 @@ ENERGY_METER = Capability(
# energy_kwh above -- without it, an exists_fn permanently drops the
# entity if setup happens to land on a not-yet-fetched stub.
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
name='Power energy', device_class='energy',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
@@ -242,7 +267,7 @@ ENERGY_METER = Capability(
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
name='Energy saved', device_class='energy',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
@@ -250,12 +275,12 @@ ENERGY_METER = Capability(
# in-progress current month. Not ever-increasing (each resets at
# month boundary), so no state_class.
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
name='Energy (last month)', device_class='energy',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
name='Energy (this month)', device_class='energy',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
@@ -266,7 +291,7 @@ WATER_METER = Capability(
href='/water/consumption/vs/0',
entities=(
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
name='Water consumption', device_class='water',
device_class='water',
state_class='total_increasing', unit='L', icon='mdi:water',
value_fn=_ml_to_l),
),
@@ -277,10 +302,14 @@ WATER_FILTER = Capability(
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
entities=(
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
name='Filter usage', unit='%', state_class='measurement',
unit='%', state_class='measurement',
icon='mdi:filter'),
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
name='Filter status', icon='mdi:filter-check'),
icon='mdi:filter-check',
device_class='enum', options=('normal', 'wash', 'replace'),
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
)),
),
)
@@ -297,7 +326,7 @@ WATER_FILTER = Capability(
#
# No translation_key: aiLevel's values are plain digit strings, and
# select.py's _display() already renders an untranslated numeric string
# as-is -- there's nothing a strings.json entry adds that's worth maintaining
# as-is -- there's nothing a catalog entry adds that's worth maintaining
# against an unknown, growing number of future levels.
@@ -344,14 +373,14 @@ AI_ENERGY_LEVEL = Capability(
# cold-tier href, the same reload already required to fix which
# platform got picked in that case.
SwitchDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf',
icon='mdi:leaf',
entity_category='config',
value_fn=lambda v: v != '0',
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) == 1),
write_fn=_ai_energy_level_switch_write),
SelectDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf',
icon='mdi:leaf',
entity_category='config',
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: (
@@ -367,7 +396,6 @@ FIRMWARE_UPDATE = Capability(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
@@ -380,21 +408,21 @@ SELF_CHECK = Capability(
poll_tier='cold',
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
name='Self-check status', icon='mdi:stethoscope',
icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
name='Self-check result', icon='mdi:clipboard-check-outline',
icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
# List of error codes from the last self-check; joined for display.
# Not every fridge reports the field, hence the exists_fn.
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
name='Self-check error', icon='mdi:alert-circle-outline',
icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.error' in rep),
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
payload='Start', icon='mdi:play-circle-outline',
ButtonDesc(key='selfcheck_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
@@ -61,7 +61,6 @@ COOKTOP_POWER = Capability(
BinarySensorDesc(
key='power_state',
field='x.com.samsung.da.power',
name='Power state',
device_class='power',
icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on',
@@ -83,7 +82,6 @@ COOKTOP_MODE = Capability(
BinarySensorDesc(
key='any_burner_active',
field='x.com.samsung.da.options',
name='Any burner active',
device_class='running',
icon='mdi:fire',
value_fn=_any_burner_active,
@@ -92,7 +90,8 @@ COOKTOP_MODE = Capability(
SensorDesc(
key=f'burner_{slot}_state',
field='x.com.samsung.da.options',
name=f'Burner {slot} state',
translation_key='burner_state',
translation_placeholders={'number': str(slot)},
icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}'
@@ -109,14 +108,12 @@ COOKTOP_MODE = Capability(
SensorDesc(
key='main_timer_state',
field='x.com.samsung.da.options',
name='Timer state',
icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'),
),
SensorDesc(
key='main_timer_current',
field='x.com.samsung.da.options',
name='Timer current value',
icon='mdi:timer-sand',
enabled_default=False,
value_fn=lambda options: _int_or_none(
@@ -134,7 +131,6 @@ COOKTOP_CONNECTED = Capability(
BinarySensorDesc(
key='cloud_connected',
field='x.com.samsung.da.connected',
name='Cloud connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
@@ -150,35 +146,30 @@ PAIRED_HOOD_STATUS = Capability(
BinarySensorDesc(
key='paired_hood_connected',
field='connectionState',
name='Paired hood connected',
device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected',
),
BinarySensorDesc(
key='paired_hood_power',
field='power',
name='Paired hood power',
device_class='running',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='paired_hood_fan_speed',
field='fanSpeed',
name='Paired hood fan speed',
icon='mdi:fan',
value_fn=_int_or_none,
),
BinarySensorDesc(
key='paired_hood_light',
field='lampState',
name='Paired hood light',
device_class='light',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='paired_hood_model',
field='micomModelId',
name='Paired hood model',
icon='mdi:information-outline',
entity_category='diagnostic',
enabled_default=False,
@@ -186,7 +177,6 @@ PAIRED_HOOD_STATUS = Capability(
SensorDesc(
key='paired_hood_firmware',
field='firmwareVersion',
name='Paired hood firmware',
icon='mdi:chip',
entity_category='diagnostic',
enabled_default=False,
@@ -18,13 +18,13 @@ DISHWASHER_SETTINGS = Capability(
href='/dishwasher/vs/0',
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
name='Sanitize', icon='mdi:bacteria',
icon='mdi:bacteria',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
name='Smart Dry', icon='mdi:heat-wave',
icon='mdi:heat-wave',
options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
@@ -45,9 +45,9 @@ CYCLE_OPTIONS = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy',
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open',
bool_option_switch('auto_release_dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
),
)
@@ -61,10 +61,10 @@ DIAGNOSIS = Capability(
poll_tier='cold',
entities=(
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
name='Diagnosis status', icon='mdi:stethoscope',
icon='mdi:stethoscope',
entity_category='diagnostic'),
ButtonDesc(key='diagnosis_start', field='', name='Start diagnosis',
payload='Start', icon='mdi:play-circle-outline',
ButtonDesc(key='diagnosis_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
@@ -76,7 +76,7 @@ OPERATION_ORIGIN = Capability(
poll_tier='cold',
entities=(
SensorDesc(key='operation_origin', field='origin',
name='Last operation source', icon='mdi:remote',
icon='mdi:remote',
entity_category='diagnostic'),
),
)
@@ -24,13 +24,13 @@ DRYER_SETTINGS = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:water-percent'),
icon='mdi:water-percent'),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
name='Dry time', icon='mdi:timer'),
icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
name='Dryer type', icon='mdi:tumble-dryer'),
icon='mdi:tumble-dryer'),
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
name='Wrinkle prevent', icon='mdi:iron',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
),
@@ -48,7 +48,8 @@ DRYER_SETTINGS = Capability(
DRYER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'),
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0'),
),
)
@@ -57,6 +58,6 @@ DRYER_DIAGNOSIS = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
name='Diagnosis', entity_category='diagnostic'),
entity_category='diagnostic'),
),
)
@@ -25,7 +25,7 @@ from ..entities import (
from .common import normalize_temp_unit
# Display names for the beverage zone, flex zone, ice type, and
# ice-making-status enums below live in strings.json / translations/en.json,
# ice-making-status enums below live in translations/en.json,
# keyed by the lowercased raw device value — select.py and SensorDesc.options
# normalize to lowercase for HA's translation lookup and map back to this
# original casing before writing to the device.
@@ -55,7 +55,8 @@ TEMP_CURRENT_GENERIC = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='temperature', field='temperature',
name=None, icon='mdi:thermometer',
translation_key='instance_temperature',
use_instance_name=True, icon='mdi:thermometer',
device_class='temperature', unit_fn=_temp_unit,
state_class='measurement'),
),
@@ -68,7 +69,8 @@ TEMP_SETPOINT_GENERIC = Capability(
poll_tier='warm',
entities=(
NumberDesc(key='setpoint', field='temperature',
name=None, device_class='temperature', unit_fn=_temp_unit,
translation_key='instance_setpoint',
use_instance_name=True, device_class='temperature', unit_fn=_temp_unit,
native_min=-20.0, native_max=50.0,
range_field='range', entity_category='config',
write_fn=lambda p, rep, href=None: (
@@ -87,12 +89,12 @@ ICEMAKER_NIGHTTIME = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='ice_night_mode', field='ice.night.status',
name='Nighttime ice quiet mode', icon='mdi:weather-night',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['icemaker', 'nighttime', 'vs', '0'],
{'ice.night.status': 'On' if p else 'Off'})),
{'ice.night.status': 'On' if p == 'On' else 'Off'})),
),
)
@@ -101,8 +103,9 @@ ICEMAKER_NIGHTTIME = Capability(
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
#
# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") -- read via name_field, reaching the translated name as the
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
# ice makers are independent on/off toggles that can both be enabled at once
# (issue #27), so they stay separate entities rather than a single ice-type
# select, but users still want them labeled with the device's own names.
@@ -123,17 +126,18 @@ ICEMAKER_GENERIC = Capability(
entities=(
SensorDesc(key='making_status',
field='x.com.samsung.da.iceMaker.iceMakingStatus',
name=None, icon='mdi:cube-outline',
use_instance_name=True, icon='mdi:cube-outline',
device_class='enum',
options=('icestatus_stop', 'icestatus_run'),
translation_key='ice_making_status',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
name=None, icon='mdi:cube-outline',
translation_key='instance_enabled',
use_instance_name=True, icon='mdi:cube-outline',
value_fn=lambda v: v == 'On',
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
name=None, icon='mdi:cube-outline',
use_instance_name=True, icon='mdi:cube-outline',
translation_key='ice_type',
entity_category='config',
options_field='x.com.samsung.da.iceType.supported',
@@ -151,7 +155,7 @@ DOOR_ALERT = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='door_alert', field='alert.door',
name='Door alarm', icon='mdi:bell-alert',
icon='mdi:bell-alert',
translation_key='door_alert',
entity_category='config',
options_field='supportedAlert.door',
@@ -168,7 +172,7 @@ DOOR_ALERT = Capability(
def _status_lock_write(field):
return lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{field: 'On' if p else 'Off'}
{field: 'On' if p == 'On' else 'Off'}
)
@@ -177,12 +181,12 @@ STATUS_LOCK = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol',
name='Auto door opener', icon='mdi:door-open',
icon='mdi:door-open',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')),
SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound',
name='Sound', icon='mdi:volume-high',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.device.sound')),
@@ -207,12 +211,12 @@ DEFROST_DELAY = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost',
name='Defrost delay', icon='mdi:snowflake-off',
icon='mdi:snowflake-off',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['defrost', 'delay', 'vs', '0'],
{'x.com.samsung.da.delayDefrost': 'On' if p else 'Off'})),
{'x.com.samsung.da.delayDefrost': 'On' if p == 'On' else 'Off'})),
),
)
@@ -229,7 +233,7 @@ DEFROST_BLOCK_STATUS = Capability(
poll_tier='warm',
entities=(
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
name='Defrost active', icon='mdi:snowflake-melt',
icon='mdi:snowflake-melt',
entity_category='diagnostic',
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
),
@@ -252,12 +256,12 @@ REFRIGERATION = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
name='Rapid fridge', icon='mdi:fridge-industrial',
icon='mdi:fridge-industrial',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
name='Rapid freezing', icon='mdi:snowflake',
icon='mdi:snowflake',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
@@ -279,7 +283,7 @@ AUTOFILL = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
name='Autofill pitcher', icon='mdi:cup-water',
icon='mdi:cup-water',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_autofill_write),
@@ -295,12 +299,12 @@ WELCOME_LIGHTING = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='welcome_lighting', field='status',
name='Welcome lighting', icon='mdi:motion-sensor',
icon='mdi:motion-sensor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['proximity', 'vs', '0'],
{'status': 'On' if p else 'Off'})),
{'status': 'On' if p == 'On' else 'Off'})),
),
)
@@ -386,15 +390,15 @@ CABINET_LIGHT_ENHANCED = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='day_brightness', field='level.brightness.daytime',
name='Cabinet brightness', icon='mdi:brightness-5',
translation_key='brightness_level',
icon='mdi:brightness-5',
translation_key='day_brightness',
entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS,
write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.daytime': p})),
SelectDesc(key='brightness_level', field='level.brightness.nighttime',
name='Night brightness', icon='mdi:brightness-4',
icon='mdi:brightness-4',
translation_key='brightness_level',
entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS,
@@ -402,12 +406,12 @@ CABINET_LIGHT_ENHANCED = Capability(
['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.nighttime': p})),
TimeDesc(key='night_start', field='',
name='Night light start', icon='mdi:clock-start',
icon='mdi:clock-start',
entity_category='config',
rep_fn=_night_start_value,
write_fn=_night_start_write),
TimeDesc(key='night_end', field='',
name='Night light end', icon='mdi:clock-end',
icon='mdi:clock-end',
entity_category='config',
rep_fn=_night_end_value,
write_fn=_night_end_write),
@@ -431,16 +435,16 @@ CABINET_LIGHT = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl',
name='Cabinet light', icon='mdi:fridge-outline',
icon='mdi:fridge-outline',
value_fn=lambda v: v == 'On',
write_fn=_cabinet_light_write),
SwitchDesc(key='cabinet_light_dim', field='light.dimming.status',
name='Brighten gradually', icon='mdi:brightness-auto',
icon='mdi:brightness-auto',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'total', 'vs', '0'],
{'light.dimming.status': 'On' if p else 'Off'})),
{'light.dimming.status': 'On' if p == 'On' else 'Off'})),
),
)
@@ -459,7 +463,7 @@ SABBATH = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
name='Sabbath mode', icon='mdi:hands-pray',
icon='mdi:hands-pray',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_sabbath_write),
@@ -479,7 +483,7 @@ BEVERAGE_ZONE = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
name='Beverage zone mode', icon='mdi:glass-wine',
icon='mdi:glass-wine',
translation_key='beverage_zone_mode',
entity_category='config',
options_field='roomSupportedModes',
@@ -507,7 +511,7 @@ PANTRY_ZONE = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode',
name='Pantry zone mode', icon='mdi:glass-wine',
icon='mdi:glass-wine',
translation_key='pantry_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
@@ -554,7 +558,7 @@ FLEX_ZONE = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='flex_zone_mode',
name='Flex zone mode', icon='mdi:thermostat',
icon='mdi:thermostat',
translation_key='flex_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
@@ -575,7 +579,8 @@ DOOR_GENERIC = Capability(
poll_tier='hot',
entities=(
BinarySensorDesc(key='open', field='openState',
name=None, device_class='door',
translation_key='instance_open',
use_instance_name=True, device_class='door',
value_fn=lambda v: v == 'Open'),
),
)
@@ -604,7 +609,7 @@ DOORS_FALLBACK = Capability(
poll_tier='hot',
entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door',
device_class='door',
value_fn=lambda items: any(
i.get('x.com.samsung.da.openState') == 'Open'
for i in (items or []))),
@@ -636,13 +641,13 @@ TEMPERATURES_FALLBACK = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
name='Freezer temperature', icon='mdi:thermometer',
icon='mdi:thermometer',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: _temp_item_unit(
rep.get('x.com.samsung.da.items'), 'Freezer'),
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
name='Fridge temperature', icon='mdi:thermometer',
icon='mdi:thermometer',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: _temp_item_unit(
rep.get('x.com.samsung.da.items'), 'Fridge'),
@@ -665,11 +670,11 @@ ICEMAKER_STATUS_FALLBACK = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker',
name='Ice maker', icon='mdi:cube-outline',
icon='mdi:cube-outline',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['icemaker', 'status', 'vs', '0'],
{'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})),
{'x.com.samsung.da.iceMaker': 'On' if p == 'On' else 'Off'})),
),
)
@@ -695,17 +700,17 @@ REFRIGERATION_FALLBACK = Capability(
poll_tier='warm',
entities=(
BinarySensorDesc(key='defrost_active', field='defrost',
name='Defrost active', icon='mdi:snowflake-melt',
icon='mdi:snowflake-melt',
entity_category='diagnostic',
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
BinarySensorDesc(key='rapid_fridge', field='rapidCool',
name='Rapid fridge', icon='mdi:fridge-industrial',
icon='mdi:fridge-industrial',
entity_category='config',
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
name='Rapid freezing', icon='mdi:snowflake',
icon='mdi:snowflake',
entity_category='config',
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
@@ -131,4 +131,11 @@ IGNORED: list[Capability] = [
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
# Microwave (issue #66): JSON-encoded {language, menu, servingSize,
# option} display string for the active cook recipe/program. Every
# field is empty on the only dump seen so far (device idle, no active
# cook program) -- no confirmed shape to build an entity around.
# Revisit if a dump with an active cook program surfaces.
Capability(href='/recipe/cook/vs/0'),
]
@@ -21,6 +21,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
"""
from datetime import time as dt_time
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
@@ -60,30 +61,30 @@ DOOR_LED = Capability(
href='/doorled/light/vs/0',
entities=(
SelectDesc(key='led_brightness', field='setBrightness',
name='Door LED brightness', icon='mdi:brightness-6',
icon='mdi:brightness-6',
entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
name='Door LED night light', icon='mdi:weather-night',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
name='Door LED night brightness', icon='mdi:brightness-4',
icon='mdi:brightness-4',
entity_category='config',
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
name='Door LED night start', icon='mdi:clock-start',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
name='Door LED night end', icon='mdi:clock-end',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
@@ -96,7 +97,7 @@ SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
entities=(
SelectDesc(key='sound_mode', field='mode',
name='Sound mode', icon='mdi:volume-high',
icon='mdi:volume-high',
entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write),
),
@@ -106,7 +107,7 @@ SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
entities=(
NumberDesc(key='sound_volume', field='level',
name='Sound volume', icon='mdi:volume-medium',
icon='mdi:volume-medium',
entity_category='config',
native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None,
@@ -127,13 +128,13 @@ BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
name='Buzzer sound', icon='mdi:volume-high',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound',
name='Finish sound', icon='mdi:bell-ring',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound',
@@ -146,9 +147,13 @@ BUZZER_SOUND = Capability(
# Cycle selection over /course/vs/0.
#
# The selected course and every other user-tunable option ride in the
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens;
# a write is a read-modify-write of that whole array (cycle_write). The set of
# *selectable* courses is not hardcoded -- it's read live from
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens.
# Confirmed on real hardware (issue #54): a write only needs to carry the one
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
# the device matches by prefix, evicts the stale token, and merges the result
# into the array itself. No read-modify-write of the whole array needed (see
# option_write). The set of *selectable* courses is not hardcoded -- it's read
# live from
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
# never show a course a given model doesn't have or hide one it does. Course
# codes are uppercase hex; display names live in translations under
@@ -157,6 +162,11 @@ BUZZER_SOUND = Capability(
# washer.py's course comment has the byte-level evidence for why the options[]
# MostUsed_* entry is *not* a trustworthy second source.
#
# Some boards populate /wm/editcourse/vs/0 without ever filling in
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
# the same list from /course/vs/0's own supportedOptions in that case; see
# _course_codes_from_supported_options for the byte-level evidence.
#
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
# expose the same /course/vs/0 options contract.
# ---------------------------------------------------------------------------
@@ -176,7 +186,10 @@ def parse_edit_course_list(raw):
def cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
return parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
if codes:
return codes
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
def option_value(options, prefix):
@@ -187,29 +200,131 @@ def option_value(options, prefix):
return None
def replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
def _course_codes_from_supported_options(course_rep):
"""Fallback for an empty/missing editCourseList: derive the selectable
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
supportedOptions is a 1-hex-nibble header followed by one fixed-width
record per selectable course, self-indexed rather than positional --
the first byte of every record is that course's own hex code, just in
the firmware's own internal order, not editCourseList's. Confirmed
against six independent real-world washer/dryer/dishwasher dumps: every
one divides evenly into `header + N * K bytes` with fully unique first
bytes across all N records, at the record's true byte width. (What the
rest of each record encodes is still unconfirmed -- this only uses the
course-code byte.)
Two guards, deliberately conservative rather than guessing further: the
derived codes must (a) all be distinct -- a real course table, not
noise -- and (b) include whatever course is currently selected
(x.com.samsung.da.options' Course_<code> token), which must always be a
member of its own device's valid list. If no split satisfies both, this
returns [] rather than guess.
Among splits that satisfy both, the *smallest* passing K wins, rather
than requiring a single unambiguous one -- more than one K reliably
does pass on real data (e.g. the shipped dishwasher fixture: true
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
7 -- position 0 always lands on the same real course code regardless
of K, which is enough on its own to satisfy the current-course guard
for several unrelated splits). Smallest-K-wins is a heuristic, not a
proof: it matches the confirmed answer on every one of six independent
real-world dumps this was checked against, but a coincidentally
unique, current-course-inclusive *smaller* K is not mathematically
impossible on some future device, and would be picked silently. Not
guarded against further here, since course tables are typically large
enough (double digits) that colliding by chance on both checks is
unlikely, and no device seen so far actually needs it.
"""
raw = course_rep.get('x.com.samsung.da.supportedOptions')
hexstr = raw[0] if isinstance(raw, list) and raw else raw
if not isinstance(hexstr, str) or len(hexstr) < 3:
return []
body = hexstr[1:]
if len(body) % 2:
return []
total_bytes = len(body) // 2
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
for k in range(1, total_bytes + 1):
if total_bytes % k:
continue
n = total_bytes // k
if n < 2:
continue
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
if len(set(firsts)) != n:
continue
if current is not None and current not in firsts:
continue
return firsts
return []
def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above cycle_options for why this doesn't read/rewrite the whole array."""
return [f'{prefix}_{new_value}']
def cycle_write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
if not rep.get('x.com.samsung.da.options'):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, 'Course', p),
'x.com.samsung.da.options': option_write('Course', p),
}
def cycle_select(*, translation_key, icon):
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get('x.com.samsung.da.st.courseTable')
def cycle_select(*, translation_key, icon, table_href=None):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The caller supplies the family's translation key (washer_cycle /
dishwasher_cycle / dryer_cycle) and icon; the option list, current value,
and write path are all shared.
The option list, current value, and write path are all shared across
washer/dryer/dishwasher; only the translation is family- (and, for
washer/dryer, board-) specific.
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
suffixes translation_key with the device's own course-table id, read
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
'washer_cycle_table_02'). An absent or unrecognized table id gets the
name-only ``cycle`` translation key while the raw course code remains
visible and writable.
This matters because course codes are NOT guaranteed consistent across
board generations sharing the same /course/vs/0 contract: every code in
washer_cycle_table_02 was confirmed against Table_02-reporting devices
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
Table_00 instead, so the same hex code could mean a different course
there for all we've verified. So a table-specific key is used only when
the shipped catalog actually has one; any other table (Table_00 today,
whatever ships next) falls back to the name-only ``cycle`` key, which
shows the raw course code rather than a label borrowed from another
board generation. Translating a new table is therefore a
translations-only change -- add the ``<family>_cycle_<table>`` entry and
this resolver picks it up.
Left at its default for dishwasher, which has no equivalent table-id
resource in any dump seen and no evidence its course codes vary by
table the way washer/dryer's do -- there's nothing to build a
table-specific key from.
"""
key = translation_key
if table_href is not None:
def key(resources):
table = _table_id(resources, table_href)
if not isinstance(table, str) or not table:
return 'cycle'
candidate = f'{translation_key}_{table.lower()}'
return candidate if has_entity_translation('select', candidate) else 'cycle'
return SelectDesc(
key='cycle', name='Cycle', icon=icon, translation_key=translation_key,
key='cycle', icon=icon, translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
@@ -231,11 +346,10 @@ def bool_option_write(prefix):
def write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
if not rep.get('x.com.samsung.da.options'):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
'x.com.samsung.da.options': option_write(prefix, p),
}
return write
@@ -249,8 +363,8 @@ def bool_option_exists(prefix):
rep.get('x.com.samsung.da.options'), prefix) is not None
def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
def bool_option_switch(key, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report
@@ -262,7 +376,7 @@ def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
building one, if needed, is the caller's job.
"""
return SwitchDesc(
key=key, name=name, icon=icon, entity_category=entity_category,
key=key, icon=icon, entity_category=entity_category,
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
rep_fn=bool_option_value(prefix),
write_fn=bool_option_write(prefix),
@@ -285,7 +399,6 @@ JOB_BEGINNING_STATUS = Capability(
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
name='Job beginning status',
entity_category='diagnostic'),
),
)
@@ -0,0 +1,138 @@
"""Capabilities for the microwave family (Samsung MW5300A-class, issue #66).
/doors/vs/0, /connected/vs/0, and /operational/state/vs/0 report the exact
same field shapes as the oven family's resources of the same name (all
generically-keyed: door_open, cloud_connected, machine_state, cycle_active,
progress_percentage, operation_time_minutes, finish_time, cook_time, stop),
so those capabilities are reused directly from oven.py rather than
re-declared here -- see by_type/microwave.py.
/mode/vs/0's cook-mode select reads its options live from supportedModes
(MicroWave/MicroWaveGrill/Grill/Autocook on this dump -- no oven-style
Bake/Broil vocabulary applies, and another model's supported set may
differ) and its options-array toggles (only a Sound_On/Off slot on this
dump -- no UpperLamp/fastpreheat/NaturalSteam like the oven family) are
microwave-specific, so it gets its own capability here. /oven/vs/0 additionally
reports a powerLevel field (a wattage with the unit embedded in the string,
e.g. "700W") the oven family's cavity capability doesn't carry, so it also
gets its own.
Cycle start/pause is deliberately not modeled: unlike the oven family
(whose module docstring documents confirmed-unreliable local cycle-start
writes from live testing), no live microwave has exercised writes at all --
this module is built from a single diagnostics dump. The shared
STOP_BUTTON's state='Ready' RMW is safe and reused via
oven.OVEN_OPERATIONAL_STATE. The /temperatures/vs/0 reading is exposed
read-only for the same reason: desired='0' on the only dump seen, well
below any plausible cook temperature, with no confirmed write contract.
/recipe/cook/vs/0 (a JSON-encoded {language, menu, servingSize, option}
display string) is not modeled here -- every field is empty on this dump
(device idle, no active cook program); see ignored.py.
"""
import re
from ..capability import Capability
from ..entities import SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
_POWER_LEVEL_RE = re.compile(r'^(-?\d+)W$')
def _power_level_watts(v):
"""'0'/'700W' -> 0/700 -- the unit is embedded in the string on every
dump seen so far; strip it so the sensor reports a plain wattage."""
if not isinstance(v, str):
return None
m = _POWER_LEVEL_RE.match(v)
return int(m.group(1)) if m else None
def _cavity_temp_unit(rep):
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in an options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
"""Return a new options list with the `<prefix>_*` slot replaced."""
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
for o in options]
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
}
def _mode_write(p, rep, href=None):
# rep is this capability's own /mode/vs/0 rep, so the live
# supportedModes list is right here -- no static vocabulary to keep in
# sync with devices whose mode set differs (grill-less models, etc.).
if p not in (rep.get('x.com.samsung.da.supportedModes') or ()):
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
MICROWAVE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='cook_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options_field='x.com.samsung.da.supportedModes',
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
icon='mdi:volume-high', entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
),
)
MICROWAVE_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
icon='mdi:flash', device_class='power',
state_class='measurement', unit='W',
value_fn=_power_level_watts),
),
)
MICROWAVE_TEMPERATURE = Capability(
href='/temperatures/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cavity_temp', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', entity_category='diagnostic',
unit_fn=_cavity_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
),
)
@@ -2,7 +2,8 @@
Shared by dryer/dishwasher/oven/washer families.
"""
from datetime import datetime, timezone, timedelta
import math
from datetime import datetime, timedelta, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
@@ -28,17 +29,41 @@ def _int(v):
return None
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)
def _remaining_seconds(raw):
"""'HH:MM:SS' (or 'MM:SS') -> total seconds, or None."""
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(':')]
except (ValueError, TypeError):
return None
if len(parts) == 3:
h, m, s = parts
elif len(parts) == 2:
h, m, s = 0, *parts
else:
return None
return h * 3600 + m * 60 + s
def _delay_hours(v):
"""delayStartTime is a duration until the cycle starts, not a
wall-clock time -- "01:00" means "1 hour from when you press start",
not "1 AM"."""
if not v:
return 0.0
try:
h, m, s = v.split(':')
return int(h) + int(m) / 60 + int(s) / 3600
except Exception:
return None
total_seconds = _remaining_seconds(v)
if total_seconds is None:
return 0.0 if not v else None
return total_seconds / 3600.0
def _format_delay(hours):
@@ -53,27 +78,39 @@ def _delay_field(rep):
wall-clock time -- see _delay_hours). Write back whichever key the
device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior)."""
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime')
return (
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
)
def _finish_time(remaining_str):
if not remaining_str:
def _finish_time(rep):
if not _is_active(rep):
return None
try:
h, m, s = remaining_str.split(':')
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
except Exception:
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
if not total_s:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
def _completion_minutes(rep):
"""Parse remaining time into minutes directly from device payload."""
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get('x.com.samsung.da.progress') == 'Finish':
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW.
STOP_BUTTON = ButtonDesc(key='stop', field='', name='Stop', payload='Ready',
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
@@ -84,46 +121,50 @@ OPERATIONAL_STATE = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', device_class='enum',
device_class='enum',
options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
# so we also gate on progress to avoid a stuck 'Running' indication.
# Named 'Running' rather than 'Cycle active' -- this href (and the
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
# start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc(key='cycle_active', device_class='running',
name='Running',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)),
SensorDesc(key='progress', name='Progress', icon='mdi:progress-wrench',
SensorDesc(key='progress', icon='mdi:progress-wrench',
rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
name='Progress percent', unit='%', state_class='measurement',
unit='%', state_class='measurement',
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
name='Estimated finish',
rep_fn=lambda rep: (
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
or rep.get('x.com.samsung.da.progress') == 'Finish'
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
)),
NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline',
rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
device_class='duration', state_class='measurement',
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h',
native_min=0, native_max=24, step=1,
rep_fn=lambda rep: _delay_hours(
@@ -132,16 +173,17 @@ OPERATIONAL_STATE = Capability(
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', name='Start', payload='Run',
ButtonDesc(key='start', field='', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', name='Pause', payload='Pause',
ButtonDesc(key='pause', field='', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
STOP_BUTTON,
),
)
@@ -122,10 +122,17 @@ def _option_value(options, prefix):
return None
def _replace_in_options(options, prefix, new_value):
"""Return a new options list with the `<prefix>_*` slot replaced."""
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
for o in options]
def _option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write, mirroring
laundry.option_write. NOT independently confirmed on an oven -- issue
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
This extrapolates that same vendor field/contract to the oven's
/mode/vs/0, on the assumption the firmware handles the array the same
way there. If that assumption is wrong for some oven, a device that
replaces the field outright instead of merging would drop every other
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
real device report surfaces that."""
return [f'{prefix}_{new_value}']
# ---------------------------------------------------------------------------
@@ -175,47 +182,41 @@ def _oven_mode_write(p, rep, href=None):
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
'x.com.samsung.da.options': _option_write('UpperLamp', p),
}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
'x.com.samsung.da.options': _option_write('Sound', p),
}
def _fastpreheat_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'fastpreheat', p),
'x.com.samsung.da.options': _option_write('fastpreheat', p),
}
def _naturalsteam_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
if not rep.get('x.com.samsung.da.options'):
return None
if not any(o.startswith('NaturalSteam_') for o in opts):
opts = opts + [f'NaturalSteam_{p}']
else:
opts = _replace_in_options(opts, 'NaturalSteam', p)
return ['mode', 'vs', '0'], {'x.com.samsung.da.options': opts}
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('NaturalSteam', p),
}
# ---------------------------------------------------------------------------
@@ -227,25 +228,25 @@ OVEN_OPERATIONAL_STATE = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', icon='mdi:stove',
icon='mdi:stove',
device_class='enum', options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Running', device_class='running',
device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
name='Operation time (minutes)', unit='min',
unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
name='Estimated finish', device_class='timestamp',
device_class='timestamp',
value_fn=_finish_time),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
name='Cook time', unit='min', native_min=0, native_max=1439,
unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
STOP_BUTTON,
@@ -257,7 +258,7 @@ OVEN_CAVITY = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
name='Cavity state'),
),
),
)
@@ -290,7 +291,7 @@ OVEN_SETPOINT = Capability(
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit,
device_class='temperature', unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
@@ -300,7 +301,7 @@ OVEN_SETPOINT = Capability(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
name='Temperature', device_class='temperature',
device_class='temperature',
state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
@@ -312,7 +313,7 @@ OVEN_DOOR = Capability(
poll_tier='hot',
entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door',
device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
@@ -324,7 +325,7 @@ OVEN_CONNECTED = Capability(
poll_tier='warm',
entities=(
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
name='Cloud connected', device_class='connectivity',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
@@ -343,25 +344,25 @@ OVEN_MODE = Capability(
entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
name='Cooking mode', icon='mdi:tune',
icon='mdi:tune',
options=_OVEN_MODES,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
name='Lamp', icon='mdi:track-light',
icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_lamp_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
name='Sound', icon='mdi:volume-high',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
name='Fast preheat', icon='mdi:fire',
icon='mdi:fire',
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_fastpreheat_write),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
name='Natural steam', icon='mdi:kettle-steam',
icon='mdi:kettle-steam',
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_naturalsteam_write),
),
@@ -81,17 +81,23 @@ def _burner_entities(i):
n = i + 1
return (
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
name=f'Burner {n} power level', icon='mdi:knob',
icon='mdi:knob',
translation_key='range_burner_power_level',
translation_placeholders={'number': str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, 'powerLevel'),
write_fn=_burner_power_level_write(i)),
SensorDesc(key=f'burner_{i}_state', field='burnerList',
name=f'Burner {n} state', icon='mdi:stove',
icon='mdi:stove',
translation_key='burner_state',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
name=f'Burner {n} hot surface', device_class='heat',
device_class='heat',
translation_key='burner_hot_surface',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
)
@@ -102,7 +108,7 @@ COOKTOP_STATUS = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='cooktop_state', field='operationState',
name='Cooktop state', icon='mdi:pot-steam'),
icon='mdi:pot-steam'),
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
),
)
@@ -123,7 +129,6 @@ COOKTOP_SAFETY = Capability(
poll_tier='warm',
entities=(
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
name='Hot surface auto-shutoff enabled',
entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'),
),
@@ -52,7 +52,6 @@ HOOD_ALARMS = Capability(
SensorDesc(
key='alarm_code',
field='x.com.samsung.da.items',
name='Alarm code',
icon='mdi:alert',
entity_category='diagnostic',
value_fn=_active_alarm_codes,
@@ -93,13 +92,11 @@ HOOD_FAN = Capability(
FanDesc(
key='fan',
field='x.com.samsung.da.hood.fanSpeed',
name=None,
write_fn=_hood_fan_write,
),
BinarySensorDesc(
key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation',
name='Automatic operation',
icon='mdi:fan-auto',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
@@ -125,7 +122,6 @@ HOOD_LAMP = Capability(
SwitchDesc(
key='lamp',
field='x.com.samsung.lamp.power',
name='Lamp',
icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on',
write_fn=lambda payload, rep, href=None: (
@@ -136,7 +132,6 @@ HOOD_LAMP = Capability(
SelectDesc(
key='lamp_brightness',
field='x.com.samsung.lamp.current',
name='Lamp brightness',
icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range',
@@ -153,7 +148,6 @@ HOOD_FILTER = Capability(
SensorDesc(
key='hood_filter_usage',
field='x.com.samsung.da.filterUsage',
name='Filter usage',
unit='%',
state_class='measurement',
icon='mdi:air-filter',
@@ -163,14 +157,18 @@ HOOD_FILTER = Capability(
SensorDesc(
key='hood_filter_status',
field='x.com.samsung.da.filterStatus',
name='Filter status',
icon='mdi:air-filter',
entity_category='diagnostic',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
),
),
SensorDesc(
key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity',
name='Filter capacity',
unit='h',
icon='mdi:timer-outline',
entity_category='diagnostic',
@@ -188,26 +186,22 @@ AIR_QUALITY = Capability(
SensorDesc(
key='clean_level',
field='x.com.samsung.da.items',
name='Clean level',
icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
),
SensorDesc(
key='dust',
field='x.com.samsung.da.items',
name='Dust',
value_fn=lambda items: sensor_item_value(items, 'Dust'),
),
SensorDesc(
key='fine_dust',
field='x.com.samsung.da.items',
name='Fine dust',
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
),
SensorDesc(
key='super_fine_dust',
field='x.com.samsung.da.items',
name='Super fine dust',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
),
),
@@ -221,7 +215,6 @@ AIR_LEVEL_CHECK = Capability(
BinarySensorDesc(
key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState',
name='Periodic air sensing',
icon='mdi:radar',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
@@ -229,14 +222,12 @@ AIR_LEVEL_CHECK = Capability(
SensorDesc(
key='air_sensing_state',
field='x.com.samsung.da.sensingState',
name='Air sensing state',
icon='mdi:radar',
entity_category='diagnostic',
),
SensorDesc(
key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime',
name='Last air sensing time',
device_class='timestamp',
entity_category='diagnostic',
value_fn=_timestamp,
@@ -244,14 +235,12 @@ AIR_LEVEL_CHECK = Capability(
SensorDesc(
key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel',
name='Last air sensing level',
icon='mdi:air-filter',
entity_category='diagnostic',
),
SensorDesc(
key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState',
name='Automatic ventilation state',
icon='mdi:fan-auto',
entity_category='diagnostic',
),
@@ -266,7 +255,6 @@ AUTO_VENTILATION = Capability(
SensorDesc(
key='auto_ventilation_action',
field='action',
name='Auto ventilation action',
icon='mdi:fan-auto',
),
),
@@ -19,12 +19,12 @@ from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import (
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
replace_in_options,
option_write,
)
# ---------------------------------------------------------------------------
# Course_XX hex codes. 23 of the codes named in strings.json/translations
# under entity.select.washer_cycle.state.<id, lowercased> were captured
# Course_XX hex codes. 23 of the codes named in translations/en.json
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
# positionally against a Slovak-UI user's screenshots of their app's course
@@ -75,19 +75,19 @@ WASHER_SETTINGS = Capability(
href='/washer/vs/0',
entities=(
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
name='Wash temperature', icon='mdi:thermometer-water',
icon='mdi:thermometer-water',
entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
name='Spin speed', icon='mdi:sync',
icon='mdi:sync',
entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
name='Rinse cycles', icon='mdi:water-sync',
icon='mdi:water-sync',
entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
@@ -97,7 +97,7 @@ WASHER_SETTINGS = Capability(
# #22. Self-gates off on plain washers, which never report
# supportedDryLevel.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:tumble-dryer',
icon='mdi:tumble-dryer',
entity_category='config',
translation_key='washer_dry_level',
options_field='x.com.samsung.da.supportedDryLevel',
@@ -157,8 +157,8 @@ def _drum_clean_last_cleaned(rep):
# valid raw codes for its field, same hex-pair shape as EditCourseList.
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
#
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_
# water_hardness/washer_softener_concentration in strings.json) is an
# Label mapping (entity.select.{detergent,softener}_quantity /
# detergent_water_hardness / softener_concentration in translations/en.json) is an
# assumed, not cross-device-verified, reading of the single issue #9 dump +
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
# on-screen equivalent -- the app's Quantité picker only offers
@@ -215,8 +215,7 @@ def _dosing_level(prefix):
def _level_write(prefix):
def write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
if not rep.get('x.com.samsung.da.options'):
return None
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
@@ -226,7 +225,7 @@ def _level_write(prefix):
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, prefix, native),
'x.com.samsung.da.options': option_write(prefix, native),
}
return write
@@ -260,7 +259,7 @@ def _dosing_low(prefix):
# auto-release-dry toggles -- only this per-course gating is washer-only, so
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
def _bool_option_switch(key, name, icon, prefix, availability_field):
def _bool_option_switch(key, icon, prefix, availability_field):
def validate(p, rep, resources):
"""Reject turning on when the selected course's byte in
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
@@ -282,75 +281,74 @@ def _bool_option_switch(key, name, icon, prefix, availability_field):
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != 'F0':
return f"{name} isn't available on the selected cycle."
return f"{key}_unavailable_for_cycle"
return None
return bool_option_switch(
key, name, icon, prefix,
key, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
WASHER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'),
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
icon='mdi:washing-machine-alert', unit='cycles',
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'),
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
icon='mdi:washing-machine-alert',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
icon='mdi:calendar-clock', device_class='timestamp',
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
icon='mdi:calendar-clock',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
translation_key='washer_dosing_quantity',
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
translation_key='detergent_quantity',
entity_category='config',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
icon='mdi:water-opacity',
translation_key='washer_detergent_water_hardness',
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
translation_key='detergent_water_hardness',
entity_category='config',
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
translation_key='washer_dosing_quantity',
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
translation_key='softener_quantity',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration',
icon='mdi:flask-plus-outline',
translation_key='washer_softener_concentration',
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
translation_key='softener_concentration',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', name='Detergent low',
icon='mdi:alert-circle-outline', device_class='problem',
BinarySensorDesc(key='detergent_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', name='Softener low',
icon='mdi:alert-circle-outline', device_class='problem',
BinarySensorDesc(key='softener_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine',
_bool_option_switch('pre_wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
_bool_option_switch('intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
),
)
@@ -32,7 +32,7 @@ class BoundEntity:
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to a state key."""
_derive_name, which applies the same transform to an href-derived key."""
return s.replace('_', ' ').title()
@@ -8,14 +8,14 @@ where a description declares one.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
# (payload, rep, resources) -> a human-readable rejection message, or None to
# allow the write. resources is the coordinator's full href->rep snapshot, for
# the same cross-resource lookups exists_fn needs (e.g. reading a sibling
# href's live option list).
# (payload, rep, resources) -> a translation key, or None to allow the
# write. resources is the coordinator's full href->rep snapshot, for the same
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
# option list).
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
@@ -27,8 +27,18 @@ def _identity(v: Any) -> Any:
class SamsungEntityDescription:
key: str
field: str = ''
name: Optional[str] = None
translation_key: Optional[str] = None
# Defaults to `key`: entity names and states live in translations/, never
# here, so a descriptor only sets this to share one catalog entry across
# several descriptors, or to point at a differently-named one.
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot and returns the key to use -- for a descriptor shared across
# board generations whose state-code meaning isn't guaranteed consistent
# between them; see laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Optional[Mapping[str, str]] = None
# Dynamic resources such as fridge compartments and ice makers use a
# device-provided or href-derived instance label inside a translated name.
use_instance_name: bool = False
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
+42 -11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from typing import Optional
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
@@ -10,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SelectDesc
from .catalog import translated_states
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
@@ -31,16 +33,37 @@ async def async_setup_entry(
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
def _display(value, desc: SelectDesc):
def _translation_state(value: str, known: frozenset[str]) -> str | None:
"""Return the catalog state `value` normalizes to, else None.
Samsung reports options in whatever casing the resource uses
('Rinse_Hold', 'SpTtypeBeerDrinks', '1b'); Home Assistant looks state
translations up by a lowercase key. Only values the catalog actually
knows are normalized -- an unrecognized (or future) vendor value keeps
its own readable form rather than becoming an untranslatable slug.
"""
direct = value.lower().replace(' ', '_')
if direct in known:
return direct
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
return snake if snake in known else None
def _display(value, translation_key: Optional[str]):
"""Turn a raw device option/state value into what's shown in the UI.
An entity with a translation_key looks its state up in strings.json,
and hassfest requires those keys to be lowercase -- so those values
`translation_key` is the entity's already-resolved key (SelectDesc.
translation_key can itself be a callable -- see entities.py -- so
callers pass the resolved value, e.g. self.translation_key, not
the raw descriptor field).
An entity with a translation_key looks its state up in the shipped
translation catalog, whose state keys are lowercase -- so those values
must be lowercased exactly to match, and the device still expects
that same raw casing back on write (callers map the displayed value
back to raw via _raw_options()).
Everything else has no strings.json lookup, so there's no reason to
Everything else has no catalog lookup, so there's no reason to
destroy the device's own casing. Only two cosmetic fixups apply: a
fully lowercase device-native token (e.g. "voice") is title-cased,
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
@@ -50,8 +73,16 @@ def _display(value, desc: SelectDesc):
"""
if not isinstance(value, str):
return value
if desc.translation_key:
return value.lower()
if translation_key:
known = translated_states('select', translation_key)
if not known:
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
# aren't (an unrecognized course table, say). Either way the
# opaque device value is the best thing to show.
return value
if translated := _translation_state(value, known):
return translated
if value.islower():
return value.replace('_', ' ').title()
return _CAMEL_BOUNDARY_RE.sub(' ', value)
@@ -63,7 +94,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, desc) for o in desc.options]
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
def _raw_options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
@@ -83,17 +114,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
if desc.options_field or callable(desc.options):
return [_display(o, desc) for o in self._raw_options()]
return [_display(o, self.translation_key) for o in self._raw_options()]
return self._attr_options
@property
def current_option(self):
raw = (self.coordinator.data or {}).get(self._state_key)
return _display(raw, self._bound.desc)
return _display(raw, self.translation_key)
async def async_select_option(self, option: str) -> None:
desc: SelectDesc = self._bound.desc
raw = next(
(o for o in self._raw_options() if _display(o, desc) == option), option
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
option,
)
await self.coordinator.async_send_command(self._bound, raw)
-1
View File
@@ -61,7 +61,6 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True
_attr_name = 'Connection mode'
_attr_translation_key = 'connection_mode'
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
-266
View File
@@ -1,266 +0,0 @@
{
"entity": {
"select": {
"door_alert": {
"state": {
"1": "Alarm 1",
"2": "Alarm 2",
"3": "Alarm 3",
"4": "Alarm 4"
}
},
"brightness_level": {
"state": {
"33": "Low",
"66": "Medium",
"100": "High"
}
},
"range_hood_lamp_brightness": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
},
"ice_type": {
"state": {
"off": "Off",
"whiskey_iceball_3": "3 Balls/Day",
"whiskey_iceball_6": "6 Balls/Day",
"whiskey_iceball_9": "9 Balls/Day"
}
},
"beverage_zone_mode": {
"state": {
"sp_ttype_beer_drinks": "Beverage",
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"pantry_zone_mode": {
"state": {
"fdr_wine": "Wine",
"fdr_deli": "Deli",
"fdr_drinks": "Drinks"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage",
"cv_fdr_wine": "Wine",
"cv_fdr_deli": "Deli",
"cv_fdr_beverage": "Beverage",
"cv_fdr_meat": "Meat",
"cv_fdr_soft_freezer": "Soft Freezer"
}
},
"dishwasher_cycle": {
"state": {
"0e": "AI Wash",
"07": "Pre blast",
"90": "Self clean",
"86": "Normal",
"83": "Express 60",
"84": "Heavy",
"8d": "Pots and pans",
"80": "Delicate",
"8e": "Plastic",
"8f": "Baby Care"
}
},
"washer_cycle": {
"state": {
"1c": "Eco 40-60",
"1d": "Super Speed",
"21": "Colours",
"1b": "Cotton",
"1e": "15' Quick Wash",
"29": "Drum Clean+",
"24": "Towels",
"33": "Bedding",
"28": "Drain/Spin",
"26": "Delicates",
"27": "Rinse+Spin",
"22": "Wool",
"20": "Hygiene Steam",
"23": "Outdoor",
"25": "Synthetics",
"32": "Shirts",
"2f": "Activewear",
"2e": "Baby Care",
"30": "Cloudy Day",
"66": "Denim",
"2d": "Silent Wash",
"8f": "Intense Cold",
"96": "Less Microfiber",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"1f": "Intense Cold"
}
},
"dryer_cycle": {
"state": {
"16": "Cotton",
"18": "Synthetics",
"19": "Delicates",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed Load",
"20": "Iron Dry",
"23": "Quick Dry 35",
"24": "Cool Air",
"25": "Warm Air",
"27": "Time Dry"
}
},
"washer_dosing_quantity": {
"state": {
"00": "None",
"01": "Low",
"02": "Medium",
"03": "High"
}
},
"washer_detergent_water_hardness": {
"state": {
"01": "Soft",
"02": "Medium",
"03": "Hard"
}
},
"washer_softener_concentration": {
"state": {
"01": "1x",
"02": "2x",
"03": "3x"
}
},
"washer_dry_level": {
"state": {
"none": "Off",
"cupboard": "Cupboard",
"30": "30 min",
"60": "1 hr",
"90": "1 hr 30",
"120": "2 hr",
"180": "3 hr",
"240": "4 hr"
}
}
},
"sensor": {
"ice_making_status": {
"state": {
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"connection_mode": {
"state": {
"observe": "Push (observe)",
"poll": "Polling"
}
},
"machine_state": {
"state": {
"idle": "Idle",
"active": "Active",
"pause": "Paused"
}
},
"air_filter_status": {
"state": {
"normal": "Normal",
"wash": "Wash",
"replace": "Replace"
}
}
},
"climate": {
"airconditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"turbo": "Turbo"
}
},
"swing_mode": {
"state": {
"off": "Fixed",
"both": "All directions",
"vertical": "Up and down"
}
},
"preset_mode": {
"state": {
"none": "Off",
"sleep": "Sleep",
"quiet": "Quiet",
"smart": "Smart",
"speed": "Speed"
}
}
}
}
}
},
"config": {
"step": {
"user": {
"title": "Add Samsung Appliance",
"description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates. {reuse_note}",
"data": {
"host": "IP Address",
"ca_cert_pem": "CA Certificate (PEM)",
"ca_key_pem": "CA Private Key (PEM)"
},
"data_description": {
"host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).",
"ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.",
"ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
}
},
"confirm_unknown_type": {
"title": "Appliance type not recognized",
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
}
},
"error": {
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
"unknown": "Unexpected error. Check the Home Assistant logs for details."
},
"abort": {
"already_configured": "This device is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Local Things Options",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
}
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,872 @@
{
"entity": {
"binary_sensor": {
"any_burner_active": {
"name": "Een brander actief"
},
"automatic_operation": {
"name": "Automatische werking"
},
"burner_hot_surface": {
"name": "Brander {number} heet oppervlak"
},
"cloud_connected": {
"name": "Verbonden met cloud"
},
"cooktop_safety_shutoff_enabled": {
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
},
"current_limit_enabled": {
"name": "Stroombegrenzing ingeschakeld"
},
"cycle_active": {
"name": "Actief"
},
"defrost_active": {
"name": "Ontdooien actief"
},
"detergent_low": {
"name": "Wasmiddel bijna op"
},
"device_active": {
"name": "Apparaat actief"
},
"door_open": {
"name": "Deur"
},
"firmware_update": {
"name": "Firmware-update beschikbaar"
},
"instance_open": {
"name": "{instance_name} open"
},
"paired_hood_connected": {
"name": "Gekoppelde afzuigkap verbonden"
},
"paired_hood_light": {
"name": "Verlichting gekoppelde afzuigkap"
},
"paired_hood_power": {
"name": "Gekoppelde afzuigkap actief"
},
"periodic_air_sensing": {
"name": "Periodieke luchtmeting"
},
"power_state": {
"name": "Voedingsstatus"
},
"rapid_freezing": {
"name": "Snelvriezen"
},
"rapid_fridge": {
"name": "Snelkoelen"
},
"remote_control": {
"name": "Smart Control"
},
"softener_low": {
"name": "Wasverzachter bijna op"
}
},
"button": {
"diagnosis_start": {
"name": "Diagnose starten"
},
"pause": {
"name": "Pauzeren"
},
"selfcheck_start": {
"name": "Zelfcontrole starten"
},
"start": {
"name": "Starten"
},
"stop": {
"name": "Stoppen"
}
},
"climate": {
"airconditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo"
}
},
"preset_mode": {
"state": {
"quiet": "Stil",
"smart": "Slim",
"speed": "Snel"
}
}
}
}
},
"number": {
"cook_time": {
"name": "Bereidingstijd"
},
"delay_start_hours": {
"name": "Uitgestelde start"
},
"instance_setpoint": {
"name": "Instelpunt {instance_name}"
},
"oven_setpoint": {
"name": "Instelpunt"
},
"sound_volume": {
"name": "Geluidsvolume"
}
},
"select": {
"ai_energy_level": {
"name": "Niveau AI Energy Mode"
},
"beverage_zone_mode": {
"name": "Modus drankenzone",
"state": {
"sp_ttype_beer_drinks": "Dranken",
"sp_ttype_wine_dessert": "Wijn en dessert"
}
},
"brightness_level": {
"name": "Nachthelderheid",
"state": {
"33": "Laag",
"66": "Gemiddeld",
"100": "Hoog"
}
},
"day_brightness": {
"name": "Helderheid kast",
"state": {
"33": "Laag",
"66": "Gemiddeld",
"100": "Hoog"
}
},
"buzzer_sound": {
"name": "Zoemergeluid",
"state": {
"off": "Uit",
"on": "Aan"
}
},
"cook_mode": {
"name": "Kookmodus",
"state": {
"no_operation": "Niet actief",
"micro_wave": "Magnetron",
"micro_wave_grill": "Magnetron + grill",
"grill": "Grill",
"autocook": "Automatisch koken",
"autocook_custom": "Automatisch koken (aangepast)"
}
},
"cycle": {
"name": "Programma"
},
"detergent_quantity": {
"name": "Hoeveelheid wasmiddel",
"state": {
"00": "Geen",
"01": "Laag",
"02": "Gemiddeld",
"03": "Hoog"
}
},
"detergent_water_hardness": {
"name": "Waterhardheid voor wasmiddel",
"state": {
"01": "Zacht",
"02": "Gemiddeld",
"03": "Hard"
}
},
"dishwasher_cycle": {
"name": "Programma",
"state": {
"80": "Delicaat",
"83": "Express 60",
"84": "Intensief",
"86": "Normaal",
"90": "Zelfreiniging",
"0e": "AI Wash",
"07": "Voorspoelen",
"8d": "Potten en pannen",
"8e": "Kunststof",
"8f": "Babyverzorging"
}
},
"door_alert": {
"name": "Deuralarm",
"state": {
"1": "Alarm 1",
"2": "Alarm 2",
"3": "Alarm 3",
"4": "Alarm 4"
}
},
"dryer_cycle_table_03": {
"name": "Programma",
"state": {
"16": "Katoen",
"18": "Synthetisch",
"19": "Fijne was",
"20": "Strijkdroog",
"23": "Snel drogen 35'",
"24": "Koude lucht",
"25": "Warme lucht",
"27": "Tijdprogramma",
"1a": "Wol",
"1b": "Beddengoed",
"1c": "Overhemden",
"1d": "Handdoeken",
"1e": "Outdoor",
"1f": "Gemengde was"
}
},
"finish_sound": {
"name": "Eindgeluid",
"state": {
"off": "Uit",
"on": "Aan"
}
},
"flex_zone_mode": {
"name": "Modus FlexZone",
"state": {
"cv_ttype_rf9000a_freeze": "Vriezen",
"cv_ttype_rf9000a_softfreeze": "Zachtvriezen",
"cv_ttype_rf9000a_meat_fish": "Vlees/vis",
"cv_ttype_rf9000a_fruit_veggies": "Fruit en groenten",
"cv_ttype_rf9000a_beverage": "Dranken",
"cv_fdr_wine": "Wijn",
"cv_fdr_deli": "Delicatessen",
"cv_fdr_beverage": "Dranken",
"cv_fdr_meat": "Vlees",
"cv_fdr_soft_freezer": "Zachtvriezer"
}
},
"heated_dry": {
"name": "Smart Dry",
"state": {
"off": "Uit",
"low": "Laag",
"high": "Hoog",
"extra_high": "Extra hoog"
}
},
"ice_type": {
"name": "Type {instance_name}",
"state": {
"off": "Uit",
"whiskey_iceball_3": "3 ijsballen per dag",
"whiskey_iceball_6": "6 ijsballen per dag",
"whiskey_iceball_9": "9 ijsballen per dag"
}
},
"led_brightness": {
"name": "Helderheid deur-LED",
"state": {
"low": "Laag",
"high": "Hoog"
}
},
"led_night_brightness": {
"name": "Nachtelijke helderheid deur-LED",
"state": {
"low": "Laag",
"high": "Hoog"
}
},
"oven_mode": {
"name": "Bereidingsmodus",
"state": {
"no_operation": "Niet actief",
"bake": "Bakken",
"broil": "Grillen",
"convection": "Hetelucht",
"convection_bake": "Bakken met hetelucht",
"convection_broil": "Grillen met hetelucht",
"frozen_pizza_plus": "Frozen Pizza+",
"slow_cook": "Langzaam garen",
"plate_warm": "Borden warmhouden",
"air_fry": "Airfryen"
}
},
"pantry_zone_mode": {
"name": "Modus voorraadzone",
"state": {
"fdr_wine": "Wijn",
"fdr_deli": "Delicatessen",
"fdr_drinks": "Dranken"
}
},
"range_burner_power_level": {
"name": "Vermogensniveau brander {number}",
"state": {
"0": "Uit",
"boost": "Boost",
"simmer": "Sudderen"
}
},
"range_hood_lamp_brightness": {
"name": "Helderheid lamp",
"state": {
"1": "Niveau 1",
"2": "Niveau 2"
}
},
"rinse_cycles": {
"name": "Spoelcycli"
},
"softener_concentration": {
"name": "Concentratie wasverzachter",
"state": {
"01": "1x",
"02": "2x",
"03": "3x"
}
},
"softener_quantity": {
"name": "Hoeveelheid wasverzachter",
"state": {
"00": "Geen",
"01": "Laag",
"02": "Gemiddeld",
"03": "Hoog"
}
},
"sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"tone": "Toon",
"mute": "Dempen"
}
},
"spin_speed": {
"name": "Centrifugesnelheid",
"state": {
"rinse_hold": "Spoelstop",
"no_spin": "Niet centrifugeren",
"low": "Laag",
"medium": "Gemiddeld",
"high": "Hoog"
}
},
"wash_temperature": {
"name": "Wastemperatuur",
"state": {
"none": "Geen",
"cold": "Koud",
"cool": "Koel",
"warm": "Warm",
"hot": "Heet",
"extra_hot": "Extra heet"
}
},
"washer_cycle_table_02": {
"name": "Programma",
"state": {
"01": "Normaal",
"04": "Snelle was",
"17": "Gedownload",
"1b": "Katoen",
"1c": "Eco 40-60",
"1d": "Super Speed",
"1e": "15' Snelle was",
"1f": "Intensief koud",
"20": "Hygiënisch stomen",
"21": "Bonte was",
"22": "Wol",
"23": "Outdoor",
"24": "Handdoeken",
"25": "Synthetisch",
"26": "Fijne was",
"27": "Spoelen+centrifugeren",
"28": "Afpompen/centrifugeren",
"29": "Drum Clean+",
"2d": "Stille was",
"2e": "Babyverzorging",
"2f": "Sportkleding",
"30": "Bewolkte dag",
"32": "Overhemden",
"33": "Beddengoed",
"36": "Wassen+drogen",
"37": "Air Wash",
"38": "Katoen drogen",
"39": "Synthetisch drogen",
"53": "Intensief",
"55": "Sportkleding",
"57": "Fijn",
"5e": "Spoelen+centrifugeren",
"65": "Bonte was",
"66": "Spijkergoed",
"7c": "Witte was",
"7d": "Beddengoed/Waterdicht",
"7e": "Zelfreinigend",
"86": "Diep wassen",
"8f": "Intensief koud",
"96": "Minder microvezels",
"2b": "AI Wash",
"2a": "Spijkerbroek"
}
},
"washer_dry_level": {
"name": "Droogniveau",
"state": {
"30": "30 min",
"60": "1 uur",
"90": "1 uur 30 min",
"120": "2 uur",
"180": "3 uur",
"240": "4 uur",
"none": "Uit",
"cupboard": "Kastdroog"
}
}
},
"sensor": {
"air_filter_usage": {
"name": "Filterverbruik"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
"alarm_code": {
"name": "Alarmcode"
},
"auto_ventilation_action": {
"name": "Actie automatische ventilatie"
},
"automatic_ventilation_state": {
"name": "Status automatische ventilatie"
},
"burner_state": {
"name": "Status brander {number}"
},
"cavity_state": {
"name": "Status ovenruimte"
},
"cavity_temp": {
"name": "Temperatuur ovenruimte"
},
"clean_level": {
"name": "Reinigingsniveau"
},
"connection_mode": {
"name": "Verbindingsmodus",
"state": {
"observe": "Push (observeren)",
"poll": "Polling"
}
},
"cooktop_state": {
"name": "Status kookplaat"
},
"current_limit_level": {
"name": "Niveau stroombegrenzing"
},
"current_temp_c": {
"name": "Temperatuur"
},
"diagnosis": {
"name": "Diagnose"
},
"diagnosis_status": {
"name": "Diagnosestatus"
},
"drum_clean_cycles_remaining": {
"name": "Trommelreiniging over"
},
"drum_clean_last_cleaned": {
"name": "Trommel laatst gereinigd"
},
"dry_level": {
"name": "Droogniveau"
},
"dry_time": {
"name": "Droogtijd"
},
"dryer_type": {
"name": "Type droger"
},
"dust": {
"name": "Stof"
},
"energy_kwh": {
"name": "Energie"
},
"energy_last_month_kwh": {
"name": "Energie (vorige maand)"
},
"energy_saved_kwh": {
"name": "Bespaarde energie"
},
"energy_this_month_kwh": {
"name": "Energie (deze maand)"
},
"fan_direction": {
"name": "Ventilatorrichting"
},
"fan_speed_level": {
"name": "Ventilatorniveau"
},
"filter_progress": {
"name": "Filterverbruik"
},
"filter_usage": {
"name": "Filtergebruik"
},
"fine_dust": {
"name": "Fijnstof"
},
"finish_time": {
"name": "Geschatte eindtijd"
},
"completion_minutes": {
"name": "Voltooiingstijd"
},
"freezer_temperature": {
"name": "Vriezertemperatuur"
},
"fridge_temperature": {
"name": "Koelkasttemperatuur"
},
"hood_filter_capacity": {
"name": "Filtercapaciteit"
},
"hood_filter_usage": {
"name": "Filterverbruik"
},
"instance_temperature": {
"name": "Temperatuur {instance_name}"
},
"job_beginning_status": {
"name": "Status programmastart"
},
"last_air_sensing_level": {
"name": "Laatste luchtmeetniveau"
},
"last_air_sensing_time": {
"name": "Tijdstip laatste luchtmeting"
},
"main_timer_current": {
"name": "Huidige timerwaarde"
},
"main_timer_state": {
"name": "Timerstatus"
},
"odor": {
"name": "Geur"
},
"operating_mode": {
"name": "Werkingsmodus"
},
"operation_origin": {
"name": "Bron laatste bediening"
},
"operation_time_minutes": {
"name": "Werkingsduur"
},
"oven_state": {
"name": "Status ovenruimte"
},
"paired_hood_fan_speed": {
"name": "Ventilatorsnelheid gekoppelde afzuigkap"
},
"paired_hood_firmware": {
"name": "Firmware gekoppelde afzuigkap"
},
"paired_hood_model": {
"name": "Model gekoppelde afzuigkap"
},
"power_energy_kwh": {
"name": "Energieverbruik"
},
"power_level": {
"name": "Vermogensniveau"
},
"power_watts": {
"name": "Vermogen"
},
"progress": {
"name": "Voortgang"
},
"progress_percentage": {
"name": "Voortgangspercentage"
},
"selfcheck_error": {
"name": "Fout zelfcontrole"
},
"selfcheck_result": {
"name": "Resultaat zelfcontrole"
},
"selfcheck_status": {
"name": "Status zelfcontrole"
},
"super_fine_dust": {
"name": "Ultrafijnstof"
},
"water_liters": {
"name": "Waterverbruik"
},
"machine_state": {
"name": "Machinestatus",
"state": {
"idle": "Inactief",
"active": "Actief",
"pause": "Gepauzeerd"
}
},
"filter_status": {
"name": "Filterstatus",
"state": {
"normal": "Normaal",
"wash": "Reinigen",
"replace": "Vervangen"
}
},
"ice_making_status": {
"name": "{instance_name} ijsmaakstatus",
"state": {
"icestatus_stop": "Inactief",
"icestatus_run": "IJs maken"
}
}
},
"switch": {
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_purify": {
"name": "Luchtzuivering"
},
"auto_clean": {
"name": "Automatisch reinigen"
},
"auto_door_opener": {
"name": "Automatische deuropener"
},
"auto_release_dry": {
"name": "Auto Release Dry"
},
"autofill": {
"name": "Automatisch gevulde waterkan"
},
"bubble_soak": {
"name": "Bubble Soak"
},
"cabinet_light_dim": {
"name": "Geleidelijk feller"
},
"cabinet_light_switch": {
"name": "Kastverlichting"
},
"child_lock": {
"name": "Kinderslot"
},
"defrost_delay": {
"name": "Ontdooien uitstellen"
},
"display_light": {
"name": "Displayverlichting"
},
"fast_preheat": {
"name": "Snel voorverwarmen"
},
"fridge_sound": {
"name": "Geluid"
},
"ice_maker_enabled": {
"name": "IJsmaker"
},
"ice_night_mode": {
"name": "Stille nachtmodus ijsmaker"
},
"instance_enabled": {
"name": "{instance_name} ingeschakeld"
},
"intensive": {
"name": "Intensive"
},
"lamp": {
"name": "Lamp"
},
"led_night_light": {
"name": "Nachtverlichting deur-LED"
},
"mute_once": {
"name": "Eenmalig dempen"
},
"natural_steam": {
"name": "Natuurlijke stoom"
},
"power_switch": {
"name": "Voeding"
},
"pre_wash": {
"name": "Voorwas"
},
"rapid_freezing": {
"name": "Snelvriezen"
},
"rapid_fridge": {
"name": "Snelkoelen"
},
"sabbath_mode": {
"name": "Sabbatmodus"
},
"sanitize": {
"name": "Hygiëne"
},
"sound": {
"name": "Geluid"
},
"storm_wash": {
"name": "Storm Wash+"
},
"welcome_lighting": {
"name": "Welkomstverlichting"
},
"wrinkle_prevent": {
"name": "Kreukpreventie"
}
},
"time": {
"led_night_end": {
"name": "Einde nachtverlichting deur-LED"
},
"led_night_start": {
"name": "Start nachtverlichting deur-LED"
},
"night_end": {
"name": "Einde nachtverlichting"
},
"night_start": {
"name": "Start nachtverlichting"
}
}
},
"config": {
"step": {
"user": {
"title": "Samsung-apparaat toevoegen",
"description": "Voer het IP-adres van je Samsung-apparaat en de AC14K_M-CA-inloggegevens in waarmee apparaatcertificaten worden aangemaakt.",
"data": {
"host": "IP-adres",
"ca_cert_pem": "CA-certificaat (PEM)",
"ca_key_pem": "Privésleutel van CA (PEM)"
},
"data_description": {
"host": "Het lokale IP-adres van je Samsung-apparaat (bijvoorbeeld 192.168.1.50).",
"ca_cert_pem": "De PEM-gecodeerde AC14K_M-CA-certificaatketen. Plak de volledige inhoud van je fullchain-PEM, inclusief alle blokken BEGIN CERTIFICATE en END CERTIFICATE.",
"ca_key_pem": "De PEM-gecodeerde AC14K_M-privésleutel. Plak de volledige inhoud, inclusief de kop- en voettekst BEGIN PRIVATE KEY en END PRIVATE KEY."
}
},
"user_reuse": {
"title": "Samsung-apparaat toevoegen",
"description": "Voer het IP-adres van je Samsung-apparaat in. De CA-inloggegevens van een bestaand LocalThings-apparaat worden hergebruikt.",
"data": {
"host": "IP-adres"
},
"data_description": {
"host": "Het lokale IP-adres van je Samsung-apparaat (bijvoorbeeld 192.168.1.50)."
}
},
"confirm_unknown_type": {
"title": "Apparaattype niet herkend",
"description": "Dit apparaat meldt oneUiVersion \"{one_ui_version}\", wat niet als apparaattype wordt herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
},
"confirm_unknown_type_no_version": {
"title": "Apparaattype niet herkend",
"description": "Dit apparaat meldt geen oneUiVersion en het apparaattype kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
}
},
"error": {
"cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.",
"invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.",
"unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie."
},
"abort": {
"already_configured": "Apparaat is al geconfigureerd"
}
},
"options": {
"step": {
"init": {
"title": "LocalThings-opties",
"menu_options": {
"settings": "Schrijfinstellingen voor afstandsbediening",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
},
"settings": {
"title": "Schrijfinstellingen voor afstandsbediening",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld"
}
},
"debug_write": {
"title": "Foutopsporing: naar een resource schrijven",
"description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.",
"data": {
"href": "Href van resource"
}
},
"debug_edit": {
"title": "Schrijfbewerking voor foutopsporing: {href}",
"description": "Huidige waarde van `{href}`:\n```\n{current_value}\n```\nVoer hieronder ALLEEN de velden in die je wilt wijzigen. Wat je invoert, wordt ongewijzigd naar het apparaat verzonden (als gedeeltelijke update). Het wordt NIET samengevoegd met de hierboven getoonde velden, dus plak niet de volledige waarde terug. Let op de typen: een numerieke tekenreeks zoals \"1\" moet tussen aanhalingstekens blijven staan. Een 1 zonder aanhalingstekens wordt als integer verzonden, wat sommige apparaten weigeren.",
"data": {
"payload": "Te schrijven payload"
}
},
"debug_result": {
"title": "Resultaat van schrijfbewerking voor foutopsporing",
"description": "Het apparaat heeft CoAP-code {code} geretourneerd. Een code in de 2.xx-klasse betekent dat de schrijfbewerking is geaccepteerd; 4.xx/5.xx betekent dat deze is geweigerd. De resource heeft nu de volgende waarde:\n```\n{new_value}\n```\nAls de waarde ongewijzigd is, heeft het apparaat de schrijfbewerking waarschijnlijk genegeerd. Kies wat je hierna wilt doen:",
"menu_options": {
"debug_write": "Naar een andere resource schrijven",
"finish": "Voltooien"
}
}
},
"error": {
"empty_payload": "Voer ten minste één veld in om te schrijven.",
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie."
},
"abort": {
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
}
},
"issues": {
"device_gap": {
"title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}",
"description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template."
}
},
"exceptions": {
"remote_control_disabled": {
"message": "Afstandsbediening is uitgeschakeld op dit apparaat. Raadpleeg de handleiding van je apparaat om afstandsbediening in te schakelen voordat Home Assistant het kan bedienen."
},
"debug_payload_empty": {
"message": "De payload voor de schrijfbewerking voor foutopsporing moet een niet-leeg object zijn."
},
"resource_href_required": {
"message": "Een resource-href is vereist."
},
"bubble_soak_unavailable_for_cycle": {
"message": "Bubble Soak is niet beschikbaar voor het geselecteerde programma."
},
"pre_wash_unavailable_for_cycle": {
"message": "Voorwas is niet beschikbaar voor het geselecteerde programma."
},
"intensive_unavailable_for_cycle": {
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
}
}
}
-1
View File
@@ -1,7 +1,6 @@
{
"state_keys": [
"alarm_code",
"blooming_level",
"clean_level",
"device_active",
"diagnosis_status",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code",
"auto_release_dry",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"alarm_code",
"cavity_state",
"cavity_temp",
"child_lock",
"cloud_connected",
"cook_mode",
"cook_time",
"cycle_active",
"door_open",
"energy_kwh",
"finish_time",
"firmware_update",
"machine_state",
"operation_time_minutes",
"power_level",
"power_switch",
"progress_percentage",
"sound"
]
}
+1
View File
@@ -5,6 +5,7 @@
"bubble_soak",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code",
"bubble_soak",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+2
View File
@@ -2,6 +2,8 @@
"state_keys": [
"alarm_code",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
+217
View File
@@ -0,0 +1,217 @@
{
"meta": {
"model": "TP2X_DA-KS-MICROWAVE-01011 (MW5300A-/KO0)",
"device_type": "microwave",
"source": "issue #66 diagnostics (scrubbed)",
"note": "Microwave reports no oneUiVersion; resolved via the '-MICROWAVE-' modelNum token fallback in for_device_by_model, mirroring the '-OVEN-'/'-RANGE-' fallbacks added for issues #55/#44."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
],
"rt": [
"x.com.samsung.da.doors"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "12500",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_DA-KS-MICROWAVE-01011|40444841|50040100021511000200000000000000",
"x.com.samsung.da.description": "MW5300A-/KO0",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "240717",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "DE92-04448A_21010500",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "622",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Autocook",
"NoOperation",
"MicroWave",
"MicroWaveGrill",
"Grill",
"AutocookCustom"
],
"x.com.samsung.da.modes": [
"NoOperation"
],
"x.com.samsung.da.options": [
"DeviceType_MW5300A-/KO0",
"ScreenTimeOut_10min",
"Sound_On"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.operationTime": "00:00:00",
"x.com.samsung.da.remainingTime": "00:00:00",
"x.com.samsung.da.progressPercentage": "0",
"rt": [
"x.com.samsung.da.operation"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "OV_E_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-24T13:38:49"
}
]
}
},
{
"href": "/oven/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.recipe": "00000000000000",
"x.com.samsung.da.powerLevel": "0W",
"rt": [
"x.com.samsung.da.oven"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/recipe/cook/vs/0",
"rep": {
"x.com.samsung.da.recipeDisplay": "{\"language\":\"None\",\"menu\":\"\",\"servingSize\":\"\",\"option\":\"\"}"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "1",
"x.com.samsung.da.increment": "5",
"x.com.samsung.da.unit": "Celsius"
}
],
"rt": [
"x.com.samsung.da.temperatures"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
}
]
}
+190 -1
View File
@@ -38,6 +38,7 @@ async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None:
DOMAIN, context={'source': 'user'}
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'user_reuse'
assert CONF_CA_CERT_PEM not in result['data_schema'].schema
assert CONF_CA_KEY_PEM not in result['data_schema'].schema
@@ -219,6 +220,39 @@ async def test_unknown_type_shows_confirmation_step(
assert result['data'][CONF_HOST] == MOCK_HOST
async def test_unknown_type_without_version_uses_localized_step(
hass: HomeAssistant,
) -> None:
"""No English placeholder sentinel leaks into a translated description."""
probe_result = {
'port': MOCK_PORT,
'serial': MOCK_SERIAL,
'leaf_cert_pem': 'leaf cert',
'leaf_key_pem': 'leaf key',
'one_ui_version': '',
'device_type_recognized': False,
}
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
return_value=probe_result,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'confirm_unknown_type_no_version'
assert not result.get('description_placeholders')
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
"""Second add of same serial: flow aborts.
@@ -279,6 +313,20 @@ def test_probe_marks_washer_as_recognized(monkeypatch):
assert recognized is True
async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
"""The options flow's entry point is now a menu (issue #54's debug
panel lives alongside the remote-control settings), not the settings
form directly."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result['type'] == FlowResultType.MENU
assert result['step_id'] == 'init'
assert set(result['menu_options']) == {'settings', 'debug_write'}
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
"""The bypass defaults to False, so devices that never touch this
option see no change in the remote-control write block."""
@@ -286,9 +334,12 @@ async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'settings'}
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'init'
assert result['step_id'] == 'settings'
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is False
@@ -299,6 +350,9 @@ async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'settings'}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={CONF_BYPASS_REMOTE_CONTROL: True}
)
@@ -317,5 +371,140 @@ async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'settings'}
)
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is True
async def test_options_flow_debug_write_shows_hrefs_from_coordinator(
hass: HomeAssistant, mock_coordinator_session,
) -> None:
"""The debug panel's href dropdown is populated from the live
coordinator's cached resources, not a static list."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'debug_write'
async def test_options_flow_debug_write_aborts_when_device_not_loaded(
hass: HomeAssistant,
) -> None:
"""No coordinator in hass.data (device never finished loading) means
the debug panel has nothing to write to or read from."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
)
assert result['type'] == FlowResultType.ABORT
assert result['reason'] == 'not_loaded'
async def test_options_flow_debug_edit_writes_and_shows_result(
hass: HomeAssistant, mock_coordinator_session,
) -> None:
"""Picking an href, then submitting a payload, drives
coordinator.async_raw_write and lands on the result menu with the
device's response."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'href': '/washer/vs/0'},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'debug_edit'
with patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
return_value=(0x44, {'a': 1}),
):
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'payload': {'a': 1}},
)
assert result['type'] == FlowResultType.MENU
assert result['step_id'] == 'debug_result'
assert result['description_placeholders']['code'] == '2.04 (0x44)'
async def test_options_flow_debug_edit_rejects_empty_payload(
hass: HomeAssistant, mock_coordinator_session,
) -> None:
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'href': '/washer/vs/0'},
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'payload': {}},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'debug_edit'
assert result['errors'] == {'payload': 'empty_payload'}
async def test_options_flow_finish_preserves_existing_options(
hass: HomeAssistant, mock_coordinator_session,
) -> None:
"""Finishing from the debug-result menu must not clobber a previously
saved remote-control bypass setting."""
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'href': '/washer/vs/0'},
)
with patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
return_value=(0x44, {'a': 1}),
):
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'payload': {'a': 1}},
)
assert result['type'] == FlowResultType.MENU
assert result['step_id'] == 'debug_result'
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'finish'}
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
+6 -2
View File
@@ -914,9 +914,12 @@ async def test_send_command_blocked_when_remote_control_disabled(
with patch.object(fake, 'subscribe'):
fake.post = _post
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
with pytest.raises(ServiceValidationError) as exc_info:
await coordinator.async_send_command(bound, 5)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == 'remote_control_disabled'
assert posted is False
@@ -977,8 +980,9 @@ async def test_send_command_remote_control_check_precedes_validate_fn(
desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
with pytest.raises(ServiceValidationError) as exc_info:
await coordinator.async_send_command(bound, 'On')
assert exc_info.value.translation_key == 'remote_control_disabled'
async def test_send_command_bypasses_remote_control_when_option_enabled(
+22 -14
View File
@@ -42,7 +42,7 @@ def test_expected_entities_present():
'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
'odor', 'clean_level', 'filter_progress', 'device_active',
'diagnosis_status', 'fan_speed_level', 'fan_direction',
'display_light', 'operating_mode', 'blooming_level',
'display_light', 'operating_mode',
):
assert key in state, key
@@ -59,6 +59,8 @@ def test_air_quality_sensor_values():
def test_filter_progress_reads_named_consumable_item():
"""FilterProgress is confirmed (issue #56) to count up as the filter
wears -- 100 means fully used and needs replacing, not "brand new"."""
assert _state()['filter_progress'] == 100
@@ -71,9 +73,10 @@ def test_diagnosis_reuses_dishwasher_capability():
def test_light_switch_write_contract():
"""The display-light switch RMW-replaces only the 'Light_*' entry in the
packed /mode/vs/0 options list (via laundry.replace_in_options), leaving
the other flags and the list order untouched."""
"""The display-light switch writes only the changed 'Light_*' token
(via laundry.option_write) -- confirmed on real hardware (issue #54)
that the device merges by prefix itself, so no read-modify-write of the
whole packed /mode/vs/0 options list is needed."""
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
rep = {'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282',
@@ -81,24 +84,29 @@ def test_light_switch_write_contract():
assert desc.rep_fn(rep) is True
assert desc.write_fn('Off', rep) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_Off', 'OptionCode_60282',
]},
{'x.com.samsung.da.options': ['Light_Off']},
)
def test_mode_tokens_are_read_only_diagnostics():
"""Comode_/Blooming_ tokens surface as raw diagnostic sensors rather than
a select/control -- their valid value ranges aren't confirmed yet (see
the air_purifier.py module docstring and the issue #56 follow-up)."""
def test_operating_mode_is_a_read_only_diagnostic():
"""Comode_* surfaces as a raw diagnostic sensor rather than a select/
control -- issue #56's five running-state dumps confirmed it reads 'Off'
regardless of the device's actual fan setting, ruling out the original
guess that it was the fan-speed selector; its real purpose is still
unconfirmed (see the air_purifier.py module docstring)."""
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
blooming = next(e for e in air_purifier.MODE.entities if e.key == 'blooming_level')
rep = {'x.com.samsung.da.options': ['Comode_Off', 'Blooming_6']}
rep = {'x.com.samsung.da.options': ['Comode_Off']}
assert operating_mode.rep_fn(rep) == 'Off'
assert blooming.rep_fn(rep) == '6'
assert not hasattr(operating_mode, 'write_fn')
def test_blooming_not_modeled():
"""Confirmed (issue #56) to have no corresponding SmartThings app
setting -- dropped entirely rather than kept as an unexplained
diagnostic."""
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
def test_airflow_vs_fallback_only_binds_without_generic():
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the
OCF-standard /airflow/0 is also present (both are on every dump seen)."""
+32 -1
View File
@@ -26,6 +26,37 @@ def test_common_caps_discover_on_dishwasher(dishwasher_resources):
assert 'power_switch' in keys
class TestMergeOptionsField:
"""merge_options_field() is the read side of issue #54's finding: a
write only needs to carry the changed token, so the coordinator uses
this to keep its optimistic cache entry complete (every sibling option
still present) without waiting on a real poll."""
def test_replaces_matching_prefix(self):
cached = ['DeviceType_0167', 'Course_16', 'GMT_04']
assert common.merge_options_field(cached, ['Course_1D']) == [
'DeviceType_0167', 'Course_1D', 'GMT_04',
]
def test_appends_when_prefix_absent(self):
cached = ['DeviceType_0167']
assert common.merge_options_field(cached, ['NaturalSteam_On']) == [
'DeviceType_0167', 'NaturalSteam_On',
]
def test_merges_multiple_tokens_independently(self):
cached = ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_0', 'GMT_04']
merged = common.merge_options_field(cached, ['SoftenerLevelCtrl_2'])
assert merged == ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_2', 'GMT_04']
def test_handles_missing_cache(self):
assert common.merge_options_field(None, ['Course_1D']) == ['Course_1D']
def test_ignores_malformed_tokens(self):
cached = ['Course_16']
assert common.merge_options_field(cached, ['nounderscore']) == ['Course_16']
# ---------------------------------------------------------------------------
# OCF-native / vendor '-vs' fallback pairs (power, kids-lock, remote control).
# ---------------------------------------------------------------------------
@@ -228,7 +259,7 @@ class TestAiEnergyLevelSelect:
def test_no_translation_key(self):
"""aiLevel's values are plain digits that render fine untranslated
(select.py's _display()) -- no strings.json entry to maintain
(select.py's _display()) -- no catalog entry to maintain
against an unknown number of future levels."""
desc = self._desc()
assert desc.translation_key is None
+137
View File
@@ -0,0 +1,137 @@
"""Tests for LocalThingsCoordinator.async_raw_write -- the debug-only
arbitrary-href write primitive backing the options-flow debug panel
(issue #54). It deliberately bypasses the remote-control block and every
write_fn/validate_fn, so this only exercises the primitive itself: it
POSTs exactly the caller's body, reads the href back, and validates its
inputs.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import cbor2
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
ENTRY_DATA = {
CONF_HOST: '10.0.0.199',
CONF_PORT: 49154,
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
}
class _FakeRawWriteSession:
"""Stand-in for DtlsCoapSession: records every POST verbatim and
answers the follow-up GET with a canned representation -- no real
DTLS/network involved."""
def __init__(self, post_code: int = 0x44, get_rep: dict | None = None):
self.post_calls: list[tuple[list[str], bytes]] = []
self.get_calls: list[list[str]] = []
self._post_code = post_code
self._get_rep = {} if get_rep is None else get_rep
def post(self, path_segs, payload, timeout=None):
self.post_calls.append((list(path_segs), payload))
return self._post_code, b''
def get(self, path_segs, timeout=None):
self.get_calls.append(list(path_segs))
return 0x45, cbor2.dumps(self._get_rep)
def pace(self):
pass
@pytest.fixture
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_RAWWRITE-TEST',
)
entry.add_to_hass(hass)
coord = LocalThingsCoordinator(hass, entry)
# async_raw_write kicks a full refresh after the write; a real refresh
# would try to poll a session that doesn't exist for this unit test, so
# replace it with a no-op the same way the reference test setup does.
coord.async_request_refresh = AsyncMock()
return coord
async def test_raw_write_splits_href_and_posts_exact_body(coordinator) -> None:
fake = _FakeRawWriteSession(post_code=0x44, get_rep={'x.field': 'after'})
coordinator._session = fake
body = {'x.com.samsung.da.field': 'value', 'n': 1}
code, new_rep = await coordinator.async_raw_write('/course/vs/0', body)
assert len(fake.post_calls) == 1
posted_path, posted_bytes = fake.post_calls[0]
assert posted_path == ['course', 'vs', '0']
assert cbor2.loads(posted_bytes) == body
assert code == 0x44
assert new_rep == {'x.field': 'after'}
coordinator.async_request_refresh.assert_awaited_once()
async def test_raw_write_reads_href_back_for_ground_truth(coordinator) -> None:
fake = _FakeRawWriteSession(post_code=0x45, get_rep={'value': 'confirmed'})
coordinator._session = fake
code, new_rep = await coordinator.async_raw_write('/washer/vs/0', {'a': 1})
assert fake.get_calls == [['washer', 'vs', '0']]
assert code == 0x45
assert new_rep == {'value': 'confirmed'}
async def test_raw_write_returns_coap_code_from_post_not_get(coordinator) -> None:
"""The returned code reflects the write's own response, even though a
follow-up GET (which could carry a different code in principle) runs
right after it."""
fake = _FakeRawWriteSession(post_code=0x80, get_rep={'value': 1})
coordinator._session = fake
code, _ = await coordinator.async_raw_write('/test/vs/0', {'a': 1})
assert code == 0x80
async def test_raw_write_rejects_empty_dict(coordinator) -> None:
with pytest.raises(ServiceValidationError):
await coordinator.async_raw_write('/washer/vs/0', {})
async def test_raw_write_rejects_non_dict_payload(coordinator) -> None:
with pytest.raises(ServiceValidationError):
await coordinator.async_raw_write('/washer/vs/0', ['not', 'a', 'dict']) # type: ignore[arg-type]
async def test_raw_write_rejects_empty_href(coordinator) -> None:
with pytest.raises(ServiceValidationError):
await coordinator.async_raw_write('', {'a': 1})
async def test_raw_write_rejects_root_href(coordinator) -> None:
with pytest.raises(ServiceValidationError):
await coordinator.async_raw_write('/', {'a': 1})
async def test_raw_write_validation_errors_do_not_touch_the_session(coordinator) -> None:
"""A rejected call must fail before ever reaching the network -- no
session needs to be connected at all for validation to run."""
assert coordinator._session is None
with pytest.raises(ServiceValidationError):
await coordinator.async_raw_write('/washer/vs/0', {})
assert coordinator._session is None
coordinator.async_request_refresh.assert_not_awaited()
+94
View File
@@ -0,0 +1,94 @@
"""Tests for LocalThingsCoordinator.async_send_command's optimistic-apply
step, specifically for x.com.samsung.da.options[] writes (issue #54).
The write itself only needs to carry the single changed token -- confirmed
on real hardware, the device merges by prefix and evicts the stale token
itself. But observe.ObserveManager.apply() does a shallow {**cached, **rep}
field merge, so handing it that same minimal single-token body would
overwrite the *whole* cached options[] field, wiping every sibling option
(other courses/levels/toggles packed into the same array) until the next
real poll lands. async_send_command must pre-merge the token into the
cached array itself before applying it optimistically, while still POSTing
only the minimal body over the wire.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import cbor2
import pytest
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities import laundry
from custom_components.localthings.registry.discovery import BoundEntity
ENTRY_DATA = {
CONF_HOST: '10.0.0.199',
CONF_PORT: 49154,
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
}
class _FakeSendSession:
def __init__(self):
self.post_calls: list[tuple[list[str], bytes]] = []
def post(self, path_segs, payload, timeout=None):
self.post_calls.append((list(path_segs), payload))
return 0x44, b''
def pace(self):
pass
@pytest.fixture
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SENDCMD-TEST',
)
entry.add_to_hass(hass)
coord = LocalThingsCoordinator(hass, entry)
coord.async_request_refresh = AsyncMock()
coord._session = _FakeSendSession()
return coord
async def test_options_write_posts_only_the_changed_token(coordinator) -> None:
href = '/course/vs/0'
coordinator._observe.apply(href, {
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
}, source='poll')
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
bound = BoundEntity(href=href, capability=None, desc=desc)
await coordinator.async_send_command(bound, '1D')
posted_path, posted_bytes = coordinator._session.post_calls[0]
assert posted_path == ['course', 'vs', '0']
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.options': ['Course_1D']}
async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) -> None:
"""The regression this guards: applying the minimal wire body straight
to the cache would wipe DeviceType_0167/GMT_04 until the next poll."""
href = '/course/vs/0'
coordinator._observe.apply(href, {
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
}, source='poll')
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
bound = BoundEntity(href=href, capability=None, desc=desc)
await coordinator.async_send_command(bound, '1D')
cached = coordinator._cache.get(href)
assert cached['x.com.samsung.da.options'] == [
'DeviceType_0167', 'Course_1D', 'GMT_04',
]
+1 -3
View File
@@ -28,9 +28,7 @@ class TestCycleOptions:
rep = {'x.com.samsung.da.options': ['DeviceType_0001', 'Course_0E', 'GMT_04']}
path, body = desc.write_fn('90', rep)
assert path == ['course', 'vs', '0']
assert body == {
'x.com.samsung.da.options': ['DeviceType_0001', 'Course_90', 'GMT_04'],
}
assert body == {'x.com.samsung.da.options': ['Course_90']}
class TestDishwasherOptions:
+9 -3
View File
@@ -59,11 +59,17 @@ def test_power_watts_gated_for_dead_sentinel():
def test_course_bound_to_shared_course_vs_0():
"""Dryer course uses the shared /course/vs/0 cycle select with dryer_cycle
translations, consistent with washer/dishwasher."""
"""Dryer course uses the shared /course/vs/0 cycle select, with the
translation key built from the device's own course table (see
laundry.cycle_select) -- confirmed dryers report Table_03, matching
the shipped dryer_cycle_table_03 translations, consistent with
washer/dishwasher."""
assert dryer.DRYER_COURSE.href == '/course/vs/0'
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle')
assert desc.translation_key == 'dryer_cycle'
assert callable(desc.translation_key)
table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}}
assert desc.translation_key(table_03) == 'dryer_cycle_table_03'
assert desc.translation_key({}) == 'cycle'
assert desc.options is laundry.cycle_options
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
assert desc.rep_fn(rep) == '16'
+23 -17
View File
@@ -1,7 +1,4 @@
"""Tests for LocalThingsEntity's display-name derivation
(custom_components/localthings/entity.py) -- the explicit-name,
device-given-instance-name, and href-derived fallbacks.
"""
"""Tests for translated entity naming and dynamic instance placeholders."""
from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
@@ -23,23 +20,32 @@ def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_
return LocalThingsEntity(_FakeCoordinator(), bound)
def test_explicit_name_wins_over_everything():
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
entity = _make_entity(desc, instance_name='Cubed Ice')
assert entity._attr_name == 'Explicit Name'
def test_descriptor_key_is_the_default_translation_key():
"""A descriptor names itself through the catalog, keyed by its own key.
def test_instance_name_prefixes_the_derived_suffix():
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
the href-derived instance label ("Icemaker One") as the name prefix,
keeping the same entity-specific suffix."""
Nothing sets _attr_name: that would take precedence over HA's
translation catalog and make the entity untranslatable.
"""
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, instance_name='Cubed Ice')
assert entity.translation_key == 'enabled'
assert not hasattr(entity, '_attr_name')
def test_device_instance_name_becomes_translation_placeholder():
desc = BinarySensorDesc(
key='enabled', translation_key='instance_enabled', use_instance_name=True
)
entity = _make_entity(desc, key_override='icemaker_one_enabled',
instance_name='Cubed Ice')
assert entity._attr_name == 'Cubed Ice Enabled'
assert entity.translation_key == 'instance_enabled'
assert entity.translation_placeholders == {'instance_name': 'Cubed Ice'}
assert not hasattr(entity, '_attr_name')
def test_no_instance_name_falls_back_to_derived_state_key():
desc = BinarySensorDesc(key='enabled')
def test_href_instance_name_becomes_translation_placeholder():
desc = BinarySensorDesc(
key='enabled', translation_key='instance_enabled', use_instance_name=True
)
entity = _make_entity(desc, key_override='icemaker_one_enabled')
assert entity._attr_name == 'Icemaker One Enabled'
assert entity.translation_placeholders == {'instance_name': 'Icemaker One'}
+36
View File
@@ -258,3 +258,39 @@ class TestRefrigeratorAiEnergyLevelFixtureCoverage:
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] == '1'
class TestSwitchOffIsNotInverted:
"""Regression: several fridge SwitchDesc write_fns used ``'On' if p else 'Off'``.
The switch platform passes the literal string ``'Off'`` on turn-off (and
``'On'`` on turn-on). ``'Off'`` is a non-empty, truthy string, so
``'On' if p else 'Off'`` always evaluated to ``'On'`` — turning the switch
OFF silently re-sent ``On`` and the switch could never be turned off. The
guard must compare ``p == 'On'`` (as the other capability files already do).
"""
# (switch descriptor, payload key it writes)
CASES = [
(fridge.ICEMAKER_NIGHTTIME.entities[0], 'ice.night.status'),
(fridge.STATUS_LOCK.entities[0], 'x.com.samsung.da.ado.devicecontrol'),
(fridge.STATUS_LOCK.entities[1], 'x.com.samsung.da.device.sound'),
(fridge.DEFROST_DELAY.entities[0], 'x.com.samsung.da.delayDefrost'),
(fridge.WELCOME_LIGHTING.entities[0], 'status'),
(fridge.CABINET_LIGHT.entities[1], 'light.dimming.status'),
(fridge.ICEMAKER_STATUS_FALLBACK.entities[0], 'x.com.samsung.da.iceMaker'),
]
def test_turning_off_sends_off(self):
for desc, key in self.CASES:
_segs, payload = desc.write_fn('Off', {})
assert payload[key] == 'Off', (
f"{desc.key}: OFF must send 'Off', got {payload[key]!r} (inverted)"
)
def test_turning_on_sends_on(self):
for desc, key in self.CASES:
_segs, payload = desc.write_fn('On', {})
assert payload[key] == 'On', (
f"{desc.key}: ON must send 'On', got {payload[key]!r}"
)
+17
View File
@@ -294,6 +294,23 @@ def test_registry_reproduces_golden_state_keys_for_oven():
)
def test_registry_reproduces_golden_state_keys_for_microwave():
"""Microwave (model TP2X_DA-KS-MICROWAVE-01011, issue #66) -- reports no
oneUiVersion; resolved via the '-MICROWAVE-' modelNum token fallback in
for_device_by_model, mirroring the '-RANGE-'/'-OVEN-' fallbacks. Reuses
the oven family's door/connected/operational-state capabilities and adds
microwave-specific mode/cavity/temperature capabilities."""
from tests.conftest import _load_device
resources = _load_device('microwave')
golden = json.loads((GOLDEN / 'microwave.json').read_text())
state_keys = _new_state_keys('microwave', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {
+146 -5
View File
@@ -4,10 +4,11 @@ from custom_components.localthings.registry.capabilities import laundry
class TestCourseHelpers:
def test_parses_edit_course_list(self):
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A'
assert laundry.parse_edit_course_list(raw) == [
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
'2B', '2A'
]
def test_parse_edit_course_list_handles_missing_or_malformed(self):
@@ -35,6 +36,103 @@ class TestCourseHelpers:
assert laundry.option_value(opts, 'Missing') is None
class TestCourseCodesFromSupportedOptions:
"""cycle_options()'s fallback for boards that populate
/wm/editcourse/vs/0 without ever filling in editCourseList itself
(issue #1) -- derives the course list from /course/vs/0's own
supportedOptions instead."""
# Real dump from issue #1 (DA_WM_TP1_21_COMMON, WW5000C): a 1-hex-nibble
# header followed by 14 self-indexed 7-byte-per-course records. '1C' (the
# first record) is confirmed as "Eco 40-60" both by this device's own
# currently-selected course matching the SmartThings app screenshot's
# checked item, and by six other independent devices' already-shipped
# translations agreeing on the same code -> name mapping.
_REAL_SUPPORTED_OPTIONS = (
'31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F'
'088000913FA67F7485209204A5208780009000A00006841E930FA30F'
'7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F'
'34841E923FA67FA0811E933FA33F'
)
def test_derives_codes_when_edit_course_list_is_empty(self):
resources = {
'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': ''},
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_1C'],
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == [
'1C', '1B', '25', '20', '08', '74', '87', '06',
'7F', '65', '8F', '96', '34', 'A0',
]
def test_edit_course_list_still_takes_priority(self):
"""A live editCourseList wins even with supportedOptions present --
no reason to prefer a derived list over the authoritative one."""
resources = {
'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_651C'},
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_1C'],
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == ['65', '1C']
def test_rejects_a_table_missing_the_current_course(self):
"""The device's own currently-selected course must be a member of
its derived list -- a mismatch means the guess is wrong, not that
the device selected something outside its own supported set."""
resources = {
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_FF'],
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
},
}
assert laundry.cycle_options(resources) == []
def test_smallest_passing_split_wins_over_its_own_multiples(self):
"""K=2 and K=4 both trivially re-pass the same two checks here --
each is just a sparser sampling of the true, smaller K=1 table (its
first bytes are a subset of K=1's, so uniqueness and "contains the
current course" carry over for free) -- but K=1 is the real, most
specific table and must be the one returned."""
resources = {
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_AA'],
'x.com.samsung.da.supportedOptions': ['0AABBCCDD'],
},
}
assert laundry.cycle_options(resources) == ['AA', 'BB', 'CC', 'DD']
def test_empty_without_supported_options_or_course_href(self):
assert laundry.cycle_options({}) == []
assert laundry.cycle_options({'/course/vs/0': {}}) == []
def test_smallest_wins_even_when_a_larger_pass_is_not_a_multiple(self):
"""Real dishwasher dump (K=7, 10 courses): K=10, 14, and 35 also
pass both checks here, and none of them are multiples of 7 --
position 0 lands on the same real course code ('0e') regardless of
K, which alone satisfies the current-course guard for several
unrelated splits. Smallest-K-wins is a heuristic that matches every
real dump checked so far, not a proven guarantee -- see
_course_codes_from_supported_options's docstring."""
resources = {
'/course/vs/0': {
'x.com.samsung.da.options': ['Course_0E'],
'x.com.samsung.da.supportedOptions': [
'30E5434B102D102835034B002D002845034B002D002805034B000D000'
'865034B002D002075000B000D000905000B000D0008D5034B002D0028'
'E5034B000D0008F5034B000D000'
],
},
}
assert laundry.cycle_options(resources) == [
'0E', '83', '84', '80', '86', '07', '90', '8D', '8E', '8F',
]
class TestCycleSelect:
def test_builds_labelled_cycle_select(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')
@@ -56,20 +154,63 @@ class TestCycleSelect:
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_16'}}
assert desc.exists_fn({}, live) is True
def test_cycle_write_rmw_on_options(self):
def test_cycle_write_is_single_token(self):
"""Confirmed on real hardware (issue #54): the device merges a
single-token options[] write by prefix itself, so the write only
needs to carry the changed token, not the whole rewritten array."""
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
path, body = desc.write_fn('1D', rep)
assert path == ['course', 'vs', '0']
assert body == {
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
}
assert body == {'x.com.samsung.da.options': ['Course_1D']}
def test_cycle_write_noop_without_options(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
assert desc.write_fn('1D', {}) is None
class TestCycleSelectTableGating:
"""translation_key becomes a resolver, not a plain string, once
table_href is given -- washer/dryer's real call sites (issue: course
codes aren't guaranteed consistent across board generations sharing
the same /course/vs/0 contract; FlexWash's older board reports a
different course table than every device the shipped translations
were confirmed against). The resolved key is built from a reported
table id. Devices without one use the generic translated Cycle entity
name while preserving raw option codes as a safe fallback."""
def _desc(self):
return laundry.cycle_select(
translation_key='washer_cycle', icon='x',
table_href='/st/washercourse/vs/0',
)
def test_static_string_when_no_table_href_given(self):
"""dishwasher's call site -- no equivalent table-id resource in any
dump seen, no evidence of the same cross-board risk -- keeps the
plain static key unconditionally."""
desc = laundry.cycle_select(translation_key='dishwasher_cycle', icon='x')
assert desc.translation_key == 'dishwasher_cycle'
def test_resolved_key_is_built_from_the_reported_table(self):
desc = self._desc()
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert callable(desc.translation_key)
assert desc.translation_key(resources) == 'washer_cycle_table_02'
def test_untranslated_table_uses_generic_cycle_key(self):
"""An unknown table does not claim another board's state labels."""
desc = self._desc()
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(resources) == 'cycle'
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
"""An absent table id still gets a translated generic entity name."""
desc = self._desc()
assert desc.translation_key({}) == 'cycle'
assert desc.translation_key({'/st/washercourse/vs/0': {}}) == 'cycle'
class TestBuzzerSound:
def test_href(self):
assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0'
+136
View File
@@ -0,0 +1,136 @@
"""Unit tests for microwave-family capabilities (issue #66)."""
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import microwave
from custom_components.localthings.registry.discovery import discover
# ---------------------------------------------------------------------------
# Device-type detection + full-dump coverage
# ---------------------------------------------------------------------------
def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
"""The issue #66 dump previously came back device_type='unknown' with
/connected/vs/0 and /recipe/cook/vs/0 unbound -- resolving via the
'-MICROWAVE-' modelNum token fallback must leave every href in the
microwave registry bound or ignored."""
from tests.conftest import _load_device
resources = _load_device('microwave')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
assert reg is not None
assert reg.name == 'microwave'
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
# ---------------------------------------------------------------------------
# MICROWAVE_MODE -- SelectDesc reads its options live from supportedModes
# ---------------------------------------------------------------------------
_SUPPORTED_MODES_REP = {'x.com.samsung.da.supportedModes': [
'NoOperation', 'MicroWave', 'MicroWaveGrill', 'Grill', 'Autocook', 'AutocookCustom',
]}
def test_microwave_mode_options_field():
desc = microwave.MICROWAVE_MODE.entities[0]
assert desc.options_field == 'x.com.samsung.da.supportedModes'
def test_microwave_mode_write_round_trips():
desc = microwave.MICROWAVE_MODE.entities[0]
path, body = desc.write_fn('MicroWave', _SUPPORTED_MODES_REP)
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == ['MicroWave']
def test_microwave_mode_rejects_unknown():
desc = microwave.MICROWAVE_MODE.entities[0]
assert desc.write_fn('SpaghettiMode', _SUPPORTED_MODES_REP) is None
def test_microwave_mode_rejects_when_not_in_live_supported_list():
"""A mode absent from *this* device's live supportedModes is rejected
even if another microwave model supports it -- the write must not fall
back to a static vocabulary."""
desc = microwave.MICROWAVE_MODE.entities[0]
narrower_rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave']}
assert desc.write_fn('Grill', narrower_rep) is None
# ---------------------------------------------------------------------------
# MICROWAVE_MODE -- sound options-array RMW
# ---------------------------------------------------------------------------
def _mode_rep(*extra_opts):
return {'x.com.samsung.da.options': [
'DeviceType_MW5300A-/KO0', 'ScreenTimeOut_10min', 'Sound_On', *extra_opts,
]}
def test_sound_write_preserves_other_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
path, body = desc.write_fn('Off', _mode_rep())
opts = body['x.com.samsung.da.options']
assert 'Sound_Off' in opts
assert 'ScreenTimeOut_10min' in opts # other slot unchanged
def test_sound_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
assert desc.write_fn('On', {}) is None
def test_sound_write_rejects_invalid_payload():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
assert desc.write_fn('Loud', _mode_rep()) is None
# ---------------------------------------------------------------------------
# MICROWAVE_CAVITY -- plain sensors, no write path
# ---------------------------------------------------------------------------
def test_cavity_state_reads_state_field():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'cavity_state')
assert desc.value_fn('Ready') == 'Ready'
def test_power_level_parses_watts():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn('700W') == 700
assert desc.value_fn('0W') == 0
def test_power_level_rejects_unparseable():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn('') is None
assert desc.value_fn(None) is None
# ---------------------------------------------------------------------------
# MICROWAVE_TEMPERATURE -- read-only current-temperature sensor
# ---------------------------------------------------------------------------
def test_cavity_temp_reads_current_field():
desc = microwave.MICROWAVE_TEMPERATURE.entities[0]
items = [{'x.com.samsung.da.current': '1', 'x.com.samsung.da.desired': '0'}]
assert desc.value_fn(items) == 1
def test_cavity_temp_missing_items_is_none():
desc = microwave.MICROWAVE_TEMPERATURE.entities[0]
assert desc.value_fn([]) is None
def test_cavity_temp_unit_defaults_celsius():
from custom_components.localthings.registry.capabilities.microwave import _cavity_temp_unit
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]}
assert _cavity_temp_unit(rep) == '°C'
def test_cavity_temp_unit_falls_back_when_absent():
from custom_components.localthings.registry.capabilities.microwave import _cavity_temp_unit
assert _cavity_temp_unit({'x.com.samsung.da.items': []}) == '°C'
+39 -1
View File
@@ -1,3 +1,5 @@
"""Unit tests for operational state capabilities."""
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
@@ -24,6 +26,42 @@ class TestProgressPercentage:
assert desc.rep_fn(rep) == 42
class TestCompletionMinutes:
"""Unit tests for completion_minutes entity."""
def test_completion_minutes_parsing(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
# 1 hour 25 mins 30 secs -> 85 mins + 1 sec ceiling = 86 mins
rep = {'x.com.samsung.da.remainingTime': '01:25:30'}
assert desc.rep_fn(rep) == 86
# Exact minutes: 1 hour 30 mins 00 secs -> 90 mins
rep_exact = {'x.com.samsung.da.remainingTime': '01:30:00'}
assert desc.rep_fn(rep_exact) == 90
def test_completion_minutes_fallback_key(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {'remainingTime': '00:45:00'}
assert desc.rep_fn(rep) == 45
def test_completion_minutes_stale_finish_gated(self):
"""Firmware freezes remainingTime at '00:01:00' when progress reaches 'Finish'.
Should return 0 to prevent stuck values."""
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {
'x.com.samsung.da.progress': 'Finish',
'x.com.samsung.da.remainingTime': '00:01:00',
}
assert desc.rep_fn(rep) == 0
def test_completion_minutes_missing_or_invalid(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {}
assert desc.rep_fn(rep) is None
class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
@@ -53,4 +91,4 @@ class TestDelayFieldFallback:
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
path, body = desc.write_fn(1.5, rep)
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}
+14 -13
View File
@@ -137,7 +137,10 @@ def test_oven_mode_rejects_unknown():
# ---------------------------------------------------------------------------
# OVEN_MODE options-array RMW (lamp, sound, fast_preheat, natural_steam)
# OVEN_MODE options-array writes (lamp, sound, fast_preheat, natural_steam).
# Confirmed on real hardware (issue #54): a write only needs to carry the
# changed token -- the device matches by prefix, evicts the stale token, and
# merges the result into the array itself. No read-modify-write needed.
# ---------------------------------------------------------------------------
def _mode_rep(*extra_opts):
@@ -146,12 +149,11 @@ def _mode_rep(*extra_opts):
]}
def test_lamp_write_replaces_slot():
def test_lamp_write_is_single_token():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'lamp')
path, body = desc.write_fn('On', _mode_rep())
opts = body['x.com.samsung.da.options']
assert 'UpperLamp_On' in opts
assert 'UpperLamp_Off' not in opts
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['UpperLamp_On']}
def test_lamp_write_requires_existing_options():
@@ -159,20 +161,19 @@ def test_lamp_write_requires_existing_options():
assert desc.write_fn('On', {}) is None
def test_sound_write_preserves_other_options():
def test_sound_write_is_single_token():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'sound')
path, body = desc.write_fn('Off', _mode_rep())
opts = body['x.com.samsung.da.options']
assert 'Sound_Off' in opts
assert 'UpperLamp_Off' in opts # other slot unchanged
assert body == {'x.com.samsung.da.options': ['Sound_Off']}
def test_natural_steam_appended_if_absent():
"""NaturalSteam slot is absent until first write — write_fn must append it."""
def test_natural_steam_write_is_single_token():
"""NaturalSteam's slot may be absent from the live rep until first
write -- the single-token write covers both the insert and replace case
identically, since the device merges by prefix either way."""
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'natural_steam')
path, body = desc.write_fn('On', _mode_rep()) # no NaturalSteam_* in rep
opts = body['x.com.samsung.da.options']
assert any(o.startswith('NaturalSteam_') for o in opts)
assert body == {'x.com.samsung.da.options': ['NaturalSteam_On']}
# ---------------------------------------------------------------------------
+12 -4
View File
@@ -1,9 +1,8 @@
"""Tests for select-option display casing (custom_components/localthings/select.py)."""
from custom_components.localthings.registry.entities import SelectDesc
from custom_components.localthings.select import _display
_UNTRANSLATED = SelectDesc(key='x', options=())
_TRANSLATED = SelectDesc(key='y', options=(), translation_key='door_alert')
_UNTRANSLATED = None
_TRANSLATED = 'ice_type'
def test_display_titlecases_a_fully_lowercase_device_native_token():
@@ -29,11 +28,20 @@ def test_display_passes_through_an_already_human_friendly_value():
def test_display_lowercases_for_translation_key_lookup():
"""An entity with a translation_key must match strings.json's
"""An entity with a translation_key must match the catalog's
lowercase keys exactly -- unlike the untranslated cases above, this
is not a cosmetic transform."""
assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3'
def test_unknown_translated_vendor_value_keeps_readable_fallback():
"""A firmware-added value must remain readable instead of being mangled."""
assert _display('FutureVendorMode', _TRANSLATED) == 'Future Vendor Mode'
def test_known_camel_case_state_uses_snake_case_translation_key():
assert _display('ExtraHigh', 'heated_dry') == 'extra_high'
def test_display_passes_through_non_string_values():
assert _display(None, _UNTRANSLATED) is None
+44 -1
View File
@@ -49,7 +49,7 @@ def test_callable_options_receives_full_resource_snapshot():
'/other/vs/0': {'codes': ['1C', '1D']},
}
entity = _make_select(desc, '/x/vs/0', resources)
assert entity.options == ['1c', '1d']
assert entity.options == ['1C', '1D']
assert calls == [resources]
@@ -57,3 +57,46 @@ def test_callable_options_empty_result():
desc = SelectDesc(key='cycle', options=lambda resources: [])
entity = _make_select(desc, '/x/vs/0', {})
assert entity.options == []
def test_callable_translation_key_reresolves_live_not_once_at_construction():
"""A callable translation_key (laundry.cycle_select's table-id-gated
resolver) must be re-evaluated against current coordinator data on
every access, not baked in once at __init__ -- discovery can run while
a sibling resource (e.g. /st/washercourse/vs/0) is still an empty stub
(see entity.py's _is_included docstring), and a one-time resolution
would permanently show untranslated codes even after a later poll
populates the real value."""
desc = SelectDesc(key='cycle', translation_key=lambda resources: resources.get('key'))
resources = {'key': None}
entity = _make_select(desc, '/x/vs/0', resources)
assert entity.translation_key is None
resources['key'] = 'washer_cycle_table_02'
assert entity.translation_key == 'washer_cycle_table_02'
async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
"""Readable fallback labels must still write the exact Samsung token."""
class _WritableCoordinator(_FakeCoordinator):
data = {'mode': 'FutureVendorMode'}
def __init__(self, last_resources):
super().__init__(last_resources)
self.writes = []
async def async_send_command(self, bound, value):
self.writes.append(value)
desc = SelectDesc(
key='mode', translation_key='door_alert',
options=('Known', 'FutureVendorMode'), write_fn=lambda *args: None,
)
capability = Capability(href='/x/vs/0', entities=(desc,))
bound = BoundEntity(href='/x/vs/0', capability=capability, desc=desc)
coordinator = _WritableCoordinator({})
entity = LocalThingsSelect(coordinator, bound)
assert entity.options[-1] == 'Future Vendor Mode'
await entity.async_select_option('Future Vendor Mode')
assert coordinator.writes == ['FutureVendorMode']
+170
View File
@@ -0,0 +1,170 @@
"""Translation catalog architecture tests.
Home Assistant loads a custom integration's ``translations/<lang>.json``
directly -- there is no ``strings.json`` step and no ``[%key:...%]``
resolution, both of which belong to Core's build tooling. So
``translations/en.json`` is the source of truth here, and these tests hold
the two invariants that follow from that: every translation key the Python
side names has to exist in it, and every other language has to mirror its
shape.
"""
from __future__ import annotations
import importlib
import json
from pathlib import Path
from string import Formatter
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.entities import PLATFORM_OF
INTEGRATION = (
Path(__file__).parents[1] / "custom_components" / "localthings"
)
TRANSLATIONS = INTEGRATION / "translations"
def _load(language: str) -> dict:
return json.loads(
(TRANSLATIONS / f"{language}.json").read_text(encoding="utf-8")
)
def _languages() -> list[str]:
return sorted(path.stem for path in TRANSLATIONS.glob("*.json"))
def _topology(value):
if isinstance(value, dict):
return {key: _topology(child) for key, child in value.items()}
if isinstance(value, list):
return [_topology(child) for child in value]
return None
def _placeholders(value: str) -> set[str]:
return {
field_name
for _, field_name, _, _ in Formatter().parse(value)
if field_name is not None
}
def _walk_strings(value, path=()):
if isinstance(value, dict):
for key, child in value.items():
yield from _walk_strings(child, (*path, key))
elif isinstance(value, list):
for index, child in enumerate(value):
yield from _walk_strings(child, (*path, str(index)))
elif isinstance(value, str):
yield path, value
def _all_descriptions():
capabilities_dir = INTEGRATION / "registry" / "capabilities"
seen: set[int] = set()
for module_path in capabilities_dir.glob("*.py"):
if module_path.stem == "__init__":
continue
module = importlib.import_module(
f"custom_components.localthings.registry.capabilities.{module_path.stem}"
)
def visit(value):
if isinstance(value, Capability):
if id(value) in seen:
return
seen.add(id(value))
yield from value.entities
elif isinstance(value, (tuple, list, set)):
for child in value:
yield from visit(child)
for value in vars(module).values():
yield from visit(value)
def test_every_language_mirrors_the_english_catalog():
"""English is the complete catalog; the rest must match it key for key.
A missing key silently falls back to English at runtime, so checking the
shape is the only way to notice a half-finished translation.
"""
english = _load("en")
english_strings = dict(_walk_strings(english))
for language in _languages():
if language == "en":
continue
translated = _load(language)
assert _topology(english) == _topology(translated), language
translated_strings = dict(_walk_strings(translated))
for path, value in english_strings.items():
# Placeholders are substituted by name, so a translation that
# drops or invents one renders a literal '{...}' in the UI.
assert _placeholders(value) == _placeholders(
translated_strings[path]
), (language, path)
def test_no_catalog_carries_unresolved_core_references():
"""``[%key:...%]`` never resolves for a custom integration.
Core's build tooling expands these; nothing does for us, so a reference
left in a catalog would reach the UI verbatim.
"""
for language in _languages():
unresolved = [
(path, value)
for path, value in _walk_strings(_load(language))
if "[%key:" in value
]
assert unresolved == [], language
# The hood fan is its device's primary feature: fan.py sets _attr_name = None
# so it presents as the device itself, and never reads a catalog name.
UNNAMED_DESCRIPTORS = {("fan", "fan")}
def test_every_descriptor_has_an_entity_catalog_entry():
"""A descriptor's name comes from the catalog or nowhere.
``translation_key`` defaults to ``desc.key`` (entity.py), and there is no
Python-side name to fall back on, so a descriptor with no catalog entry
is an entity with no name.
"""
entity_strings = _load("en")["entity"]
missing = []
for desc in _all_descriptions():
platform = PLATFORM_OF[type(desc)]
if (platform, desc.key) in UNNAMED_DESCRIPTORS:
continue
translation_key = desc.translation_key
if callable(translation_key):
# Runtime table resolvers pick their key out of the catalog
# itself (see laundry.cycle_select), so there's nothing static
# to check here; the generic 'cycle' fallback is asserted below.
continue
if translation_key is None:
translation_key = desc.key
if translation_key not in entity_strings.get(platform, {}):
missing.append((platform, desc.key, translation_key))
assert missing == []
# cycle_select falls back to 'cycle' for any course table without its
# own entry, and resolves to '<family>_cycle_<table>' where there is one.
select_strings = entity_strings["select"]
for key in ("cycle", "washer_cycle_table_02", "dryer_cycle_table_03"):
assert key in select_strings
def test_all_entity_state_translation_keys_are_lowercase():
entity_strings = _load("en")["entity"]
for platform in entity_strings.values():
for translation in platform.values():
for state_key in translation.get("state", {}):
assert state_key == state_key.lower()
+39 -25
View File
@@ -61,12 +61,22 @@ class TestWasherCourse:
assert washer.WASHER_COURSE.href == '/course/vs/0'
def test_translation_key(self):
"""Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. Only a
verified table gets table-specific state translations."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.translation_key == 'washer_cycle'
assert callable(desc.translation_key)
table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert desc.translation_key(table_02) == 'washer_cycle_table_02'
table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(table_00) == 'cycle'
assert desc.translation_key({}) == 'cycle'
def test_reads_raw_course_code_from_options_array(self):
"""rep_fn returns the raw device code; display names come from
strings.json via translation_key, not from Python (see select.py's
translations/en.json via translation_key, not Python (see select.py's
_display())."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
@@ -91,13 +101,14 @@ class TestWasherCourse:
assert desc.exists_fn({}, live) is True
def test_cycle_write(self):
"""Confirmed on real hardware (issue #54): the write only needs to
carry the changed token -- the device matches by prefix, evicts the
stale token, and merges the result into the array itself."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
path, body = desc.write_fn('1D', rep)
assert path == ['course', 'vs', '0']
assert body == {
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
}
assert body == {'x.com.samsung.da.options': ['Course_1D']}
class TestDrumClean:
@@ -195,14 +206,11 @@ class TestDetergentSoftenerDosing:
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers,
same shape as fridge.py's shared 'brightness_level' key); hardness
and concentration each have their own since their labels differ."""
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity'
assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity'
assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness'
assert self._desc('softener_concentration').translation_key == 'washer_softener_concentration'
"""Each entity gets its own translated name and state vocabulary."""
assert self._desc('detergent_quantity').translation_key == 'detergent_quantity'
assert self._desc('softener_quantity').translation_key == 'softener_quantity'
assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness'
assert self._desc('softener_concentration').translation_key == 'softener_concentration'
def test_quantity_and_hardness_options_decode_supported_list(self):
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
@@ -219,20 +227,22 @@ class TestDetergentSoftenerDosing:
def test_quantity_write(self):
"""The UI selects a padded supported code ('01'); the write posts the
un-padded device code ('1'), mirroring how the device reports it."""
un-padded device code ('1'), mirroring how the device reports it.
Confirmed on real hardware (issue #54): the write only needs to carry
the changed token -- the device matches by prefix, evicts the stale
token, and merges the result into the array itself. No need to read
the current array back and rewrite it whole."""
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('detergent_quantity').write_fn('01', rep)
assert path == ['course', 'vs', '0']
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
# untouched siblings survive the read-modify-write
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
assert body == {'x.com.samsung.da.options': ['DetergentLevelCtrl_1']}
def test_hardness_write(self):
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('softener_concentration').write_fn('03', rep)
assert path == ['course', 'vs', '0']
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
assert body == {'x.com.samsung.da.options': ['SoftenerLevel2Ctrl_3']}
def test_low_reservoir_off_when_alarm_off(self):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
@@ -294,18 +304,20 @@ class TestWashOptionToggles:
"""write_fn receives the same 'On'/'Off' string switch.py sends
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
implementation always wrote 'On', since any non-empty string
(including 'Off') is truthy."""
(including 'Off') is truthy.
The write body carries only the changed token (issue #54: confirmed
the device merges by prefix itself), not the whole options array."""
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']}
path, body = self._desc(key).write_fn('On', rep)
assert path == ['course', 'vs', '0']
assert f'{prefix}_On' in body['x.com.samsung.da.options']
assert 'GMT_02' in body['x.com.samsung.da.options']
assert body == {'x.com.samsung.da.options': [f'{prefix}_On']}
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
path, body = self._desc(key).write_fn('Off', rep)
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
assert body == {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
def test_write_rejects_non_on_off_payload(self):
@@ -348,8 +360,10 @@ class TestWashOptionToggleValidation:
def test_rejected_on_an_unsupported_course(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
assert msg == "Bubble soak isn't available on the selected cycle."
translation_key = self._desc('bubble_soak').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES
)
assert translation_key == 'bubble_soak_unavailable_for_cycle'
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}