Compare commits

...
Author SHA1 Message Date
Marc Billow 03958dd994 Merge pull request #184 from mbillow/claude/issue-triage-429jtb
Issue triage: absence/motion AC sensors (#173), microwave reminders (#181), Python 3.13 pin
2026-07-28 22:37:55 -05:00
Marc Billow a7aea3764b feat(airconditioner): make absence/motion-detect enable switches writable
status on both /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0 is a bare On/Off boolean, the same
shape already shipped writable elsewhere in this file (MUTE_ONCE,
AUTO_CLEAN, AIR_PURIFY) without a live-confirmed write either -- worst
case a wrong token no-ops. The paired mode selects (switchPowerSaveMode,
motion-detect modes) stay read-only: their behavioral effect on live HVAC
isn't inferable from the dump, same reasoning as ANOMALY_LOAD's mode field.
2026-07-29 03:34:18 +00:00
Marc Billow a6eb1c62d4 feat(microwave): add filter reminder / end signal reminder switches (#181)
Both FilterRemind_*/RemindBeep_* option-array tokens are already
present -- and both On and Off already confirmed -- on the existing
ME7500D fixtures (issue #152), so this is a straight sibling of the
Sound/Lamp switches rather than new discovery work. Gated with
exists_fn like Lamp since the MW7300B combi dump has neither token.

Doesn't address the rest of issue #181 (power-level slider,
non-reported cooking modes, 3-level light, child lock, send-to-
microwave) -- those need write-contract confirmation this dump
doesn't carry.
2026-07-29 02:55:47 +00:00
Marc Billow 90d79f117a build: pin minimum Python to 3.13 in pyproject.toml
README.md and requirements-dev.txt already document that the test
harness needs Python 3.13+ (pytest-homeassistant-custom-component
doesn't resolve below it), but only in prose. Add requires-python so
pip fails fast with a clear message on an older interpreter instead of
a wall of "Requires-Python >=3.13" version-list noise.
2026-07-29 02:52:38 +00:00
Marc Billow 2b24152276 feat(registry): cover absence-power-saving and motion-detect-wind on AC (#173)
Lennox-branded heat pump on the Samsung RAC board family (modelNum
TP1X_LNX-AC-RAC-01001_0000) already routes correctly via the existing
'-RAC-' token, but its dump has two resources no prior AC fixture
carried: /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0. Bind both as read-only
sensors, matching the CURRENT_LIMIT/ANOMALY_LOAD precedent -- nothing
in the dump confirms write safety on live HVAC hardware.
2026-07-29 02:52:33 +00:00
Marc Billow 6f21699c37 Merge pull request #182 from mbillow/claude/device-detection-simplify-58rvo5
Simplify device-type detection: token table in, oneUiVersion out
2026-07-28 21:28:49 -05:00
Marc Billow 40c7033b1b docs(readme): update device-type routing and test setup
Three things this branch left stale.

"Adding a new appliance type" step 4 told contributors to key the registry
on the lowercased suffix of oneUiVersion and pointed at _type_key() for the
transform. Neither exists any more, and oneUiVersion no longer routes at
all. Describe the board-token table instead, including the two rules that
keep it a table: whole-token matching covers every delimiter spelling, and
an entry must name the specific device type rather than the board family
that contains it.

The repo-layout line for identity.py said "Reads device identity for type
detection". It has never fed type detection -- it reads /oic/p and /oic/d
for the HA device registry, and now also carries OCF's device-type
declaration into diagnostics.

The test setup installed pytest-homeassistant-custom-component and
homeassistant unpinned on top of requirements-dev.txt, which already pulls
both in at matching versions, and used whatever `python3` resolves to. On
3.12 or older nothing resolves and the install fails outright with a wall of
version-conflict output that doesn't name the real cause. Say 3.13+, drop
the redundant install, and note CI runs 3.14.
2026-07-29 02:27:00 +00:00
Marc Billow c9620b47b3 fix(diagnostics): redact the OCF device name; log description on unknown type
Two things a review of this branch turned up.

/oic/d's `n` is free text the owner sets from the SmartThings app, so it may
carry a person's name. Nothing in the /device/0 dump has ever exposed it --
it only became reachable when diagnostics started reporting /oic/d earlier
in this branch, which would have started carrying it into public issue
reports. Redact it. `rt`, the device-type signal the block exists for, is
untouched, and no /device/0 resource uses a bare 'n' key, so nothing else
changes.

The unknown-device-type warning logged only modelNum. That line is what a
user pastes into an issue, and modelNum alone can't identify a washer from a
dryer -- both report the shared DA_WM_ laundry board, and detection reads
the consumer-model code out of `description` for exactly that reason. Log
both fields.
2026-07-29 02:20:37 +00:00
Marc Billow cafd7d5afa refactor(registry): drop oneUiVersion from device-type detection
oneUiVersion looks like the signal you'd want -- the device naming its own
type, '7.0 Dishwasher' -- and it was the first thing detection consulted. It
never earned the position:

- Only 7 of 49 fixtures report it at all.
- All 7 resolve to the same registry from their modelNum board token alone.
- No device-support issue has ever been fixed by adding a mapping for it.
  Every one went through modelNum. The alias keys it needed in
  _REGISTRY_BY_KEY ('airpurifier', 'air_conditioner', 'hood') were
  speculative when the registries were first written and never used since.

So it bought a key-normalizing helper (_type_key), a lookup with a suffix
fallback (for_device), three alias keys, and a second config-flow step whose
only reason to exist was phrasing a sentence about oneUiVersion -- for a
signal that has never once been decisive.

Remove it from detection. It stays in diagnostics, where it's genuinely
useful: it names the firmware generation ('7.0 Air conditioner' is Tizen
Lite), which matters when triaging an issue.

Detection order was also duplicated in four places -- the coordinator, the
config flow's probe, the golden-regression harness, and the skill -- which
is how the harness and the shipped order drift apart. Collapse it into
by_type.resolve(resources), and call that everywhere.

The two "appliance type not recognized" config steps become one. They
differed only in whether they blamed a missing oneUiVersion, which is not a
distinction a user can act on, and never was.

Verified by the full suite (795 passing), including every golden regression
-- so entity output is byte-identical for all 49 device fixtures.

TestOneUiVersionIsNotConsulted locks in the premise rather than just the
outcome: for every dump that reports a oneUiVersion, the model strings alone
must still reach a registry. If a future device breaks that, the test says
so instead of the device silently losing half its entities.

Also note in requirements-dev.txt that Python 3.13 resolves the pinned
harness floor -- 3.12 and older resolve nothing and fail the whole install.
2026-07-29 02:14:13 +00:00
Marc Billow a863df1e59 refactor(registry): match board families by token table, not substring ladder
for_device_by_model() had grown to 21 sequential `if key is None` branches
and 102 comment lines against 59 lines of code -- 33 of the repo's 243
commits have touched this file. Most of that bulk came from one wrong
primitive: substring matching on a delimited string.

Samsung spells the same board family with either delimiter, so '_RAC_' and
'-RAC-' each needed their own rule, and 'ARTIK051_DONGLE_REF' (issues #77,
#83) matched no '_TOKEN_' spelling at all because REF lands at the end of
the pipe-prefix with no trailing underscore -- which is what
_model_num_segments() existed to work around. Which field a rule searched
(modelNum, or modelNum + description) was historical accident. Collisions
like WAC vs WA were resolved by one `if` physically preceding another,
invisible in the code and explained at length in prose.

Tokenize on any non-alphanumeric run, upper-case, and look the tokens up in
a flat table. Every delimiter spelling collapses to one entry, both fields
go through the same matcher in a documented order (modelNum, then
description, then the fuzzy consumer prefix), and specificity is a property
of the table rather than of line ordering.

Two behaviours are preserved deliberately:

- modelNum is matched before description, which is what keeps the legacy
  gas cooktop correct: it reports 'ARTIK051_GB_CT_001' (CT) alongside
  'ARTIK051_GLOBAL_COOKTOP' (COOKTOP, which otherwise means induction).
  It is the only known device whose two fields disagree.
- _consumer_model_key still splits on '_' only. Widening it to '-' would
  read the dishwasher's 'ADW-WW-RTL-24-AILITE' board segment as a bare 'WW'
  washer.

Verified identical: all 49 device fixtures resolve to the same registry
before and after, and every existing for_device_by_model test case passes
unchanged. The table also picks up two families that previously depended on
oneUiVersion alone (TP1X_DA-AC-AIR air purifiers, ADW dishwashers), so they
now survive firmware that omits it.

TestBoardTokenAmbiguity guards the one property the flat lookup needs --
that no real model string contains two tokens naming different device types
-- across the whole fixture corpus, so a newly added dump exercises it
automatically.

The skill gains a section on routing: what each detection stage is for, the
rules for adding a token (name the specific type, never the board family;
never add a delimiter spelling; two-letter tokens are a last resort), when
to reach for the consumer prefix or a resource signature instead, and the
measured stake -- an unrouted device loses roughly half its entities.
2026-07-29 01:57:10 +00:00
Marc Billow 288ba02cc5 feat(diagnostics): capture /oic/p and /oic/d identity
Device-type detection currently parses board part numbers out of
/information/vs/0's modelNum. OCF has a standard field for exactly this
question -- /oic/d's `rt` -- and read_identity() already fetches the
resource, but kept only `n` and threw the rest away. No captured dump has
ever included it either: /device/0 batch responses don't carry /oic/d, and
diagnostics didn't report it, so there's no evidence on whether real
hardware populates it usefully.

Keep `rt` as DeviceIdentity.device_types, keep both raw payloads whole
(we don't yet know which of their fields identify a type), and surface
them in diagnostics so incoming issue reports answer the question.

Nothing routes on it yet.

/oic/d and /oic/p identify the unit with bare two-letter keys -- 'di' and
'pi' -- as sensitive as the serial number redact.py already covers but far
too short to match on: 'di' alone is a substring of 'condition', 'display'
and 'dispenser'. Add a whole-key match alongside the substring rules.
2026-07-29 01:56:52 +00:00
Marc Billow 668aec401a Merge pull request #174 from firstof9/fix/microwave-issue-172 2026-07-28 17:57:45 -05:00
firstof9@gmail.com 1f331f3aa6 fix(registry): route microwaves without /information/vs/0 to microwave registry (#172)
Issue #172: Samsung Microwave units (ME8000T-/AA0) omit /information/vs/0 and have empty oneUiVersion, falling back to unknown device type. Route via /oven/vs/0 and MicroWave modes in supportedModes.
2026-07-28 13:29:57 -07:00
Marc Billow 498da49817 Merge pull request #171 from mbillow/claude/issue-triage-gating-gf6bg4
Fix unsound gating from #170, cover remaining fan-speed icons
2026-07-28 14:10:22 -05:00
Marc Billow bbf3f3f833 Revert unsound air-quality gating from #170, cover remaining fan-speed icons
An Opus review of merged PR #170 found the cleanLevel-scalar existence
gate on AIR_QUALITY doesn't hold up as a general rule: three fixtures
in this repo (air_purifier_device.json, air_purifier_vtww_device.json,
range_hood_device.json) carry genuinely populated Dust/FineDust/
SuperFineDust readings with no such scalar, so requiring it risks
silently dropping real air-quality readings on AC hardware this repo
hasn't seen yet. Reverted _has_sensor_type to item-type presence only
(as before #170) and moved the #166 fix to enabled_default=False on
all five entities instead -- same conservative, non-existence-gated
treatment already used for tropical_night_mode and the fridge/cooktop
precedents it was modeled on. Golden fixtures and tests updated to
match; the five sensors are bound-but-disabled on windfree/#17-style
boards again rather than unbound.

Also added icons for the AC fan_mode values #170 missed -- the raw
numeric labels ("1".."5") that TP1X_DA-AC-RAC-01001 and the window-AC
board report instead of turbo/max -- and swapped the whole fan-speed
icon family to mdi:fan-speed-1/2/3 for a more purpose-built look than
the generic speedometer, applied consistently to both the AC climate
card and the air purifier fan. Fixed motiondirect/motionindirect to
match core's smartthings integration's arrow pairing (previously
inverted).

Known limitation, not fixed here: enabled_default only affects newly
registered entities. Anyone who already has tropical_night_mode or the
five air-quality sensors enabled from #164 (a narrow window before
this fix, but real) won't see them auto-disable -- they'd need to
disable them by hand in Settings > Devices > Entities. A real fix
needs a one-time entity-registry migration, which this integration has
no existing infrastructure or test coverage for; scoping that felt
like its own follow-up rather than something to bolt on here.
2026-07-28 19:07:59 +00:00
Marc Billow 91b129282b Bump version to 0.15.0 2026-07-28 18:56:07 +00:00
Marc Billow 34fe991858 Merge pull request #170 from mbillow/claude/issue-triage-gating-gf6bg4
Fix AC entity gating from #164, add missing preset/mode icons (#166, #169)
2026-07-28 13:50:53 -05:00
Marc Billow 6d185e4e3d Match WindFree icon to the official smartthings integration's choice
HA core's bundled smartthings integration (the cloud counterpart to
this same Samsung AC feature set) uses mdi:weather-dust for its
wind_free preset rather than a generic windy icon -- a better fit for
a feature about avoiding direct airflow, not blowing harder. Match it
for both the AC climate preset and the air purifier fan preset.
2026-07-28 18:47:10 +00:00
Marc Billow 5f47fc0477 Add per-state icons for the remaining entities that render with none
HA only consults icon-translation state icons when the entity has no
static icon of its own (Entity.icon, if set, always wins -- see
homeassistant.helpers.entity's state_attributes construction). Audited
every entity with a labelled state/state_attributes catalog in
translations/en.json against its descriptor's icon= setting: every
select (cycles, courses, brightness levels, ...) and most sensors
already carry a fixed icon in code, so per-state icons there would be
silently shadowed. The three that don't -- air_purifier_fan's
preset_mode, machine_state, and connection_mode -- get one per value
here.
2026-07-28 18:31:31 +00:00
Marc Billow cfa82e8853 Add icons for AC preset/fan modes not covered by HA's built-ins (#169)
HA's core climate component already ships default icons for common
preset_mode/fan_mode values (eco, away, sleep, auto, low/medium/high,
...), but this integration's own values -- WindFree (nano/nanosleep),
Quiet, Smart, Speed, Long wind, the motion-aware direct/indirect
presets, Dry comfort, 2-Step, and the turbo/max fan speeds some boards
report -- fall outside that vocabulary and rendered with the generic
circle-dot fallback (the icon the #169 screenshot is missing). Adds
icons.json with an icon per value, mirroring the state-label catalog
these same values already have in translations/en.json.
2026-07-28 18:20:10 +00:00
Marc Billow 739881de16 Gate AC tropical night mode and air-quality sensors on real capability signals (#166)
Issue #166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) reported tropical
night mode, clean level, dust, fine dust, odor, and super fine dust
entities showing up even though the reporter's units have no such
physical features. All six were added in #164.

The Sleep_<N> options token backing tropical_night_mode is present in
every AC dump on record regardless of confirmed reality, so there's no
usable signal at boot time -- it's now registered but disabled by
default (matching the precedent already set by fridge.rack_count /
cooktop.paired_hood_model), letting units that do have it opt in.

/sensors/vs/0's item-type list has the same problem (all five types
always listed, permanently zero on this board), but there turned out
to be a real tell: a top-level x.com.samsung.da.cleanLevel scalar is
present only alongside genuinely populated readings on every dump on
record (tp1x_da_ac_rac_01011, the tp1x_da_ac_air air purifier fixture)
and absent on every all-zero ARTIK051_PRAC_20K dump, including both
#166 units and the original windfree/#17 fixtures this capability was
first verified against -- which, per their /information/vs/0, turn out
to be the same board revision as #166's units, so that "verification"
never actually proved a real sensor either. AIR_QUALITY's exists_fn now
requires that scalar, and the windfree/airconditioner golden fixtures
are updated to match (those five entities no longer bind there).
2026-07-28 18:12:02 +00:00
Marc Billow 6080d37f7d Merge pull request #167 from mbillow/claude/issue-triage-138-latest-y90g78
Issue triage batch: oven/AC/microwave/air-dresser fixes, 4 new device types
2026-07-28 10:21:28 -05:00
36 changed files with 1919 additions and 508 deletions
+104 -10
View File
@@ -5,6 +5,8 @@ description: >-
/device/0 diagnostics dump. Use when a device-support issue lands, a device
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
routing an unrecognized board family to a registry (modelNum board tokens,
resource signatures),
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
@@ -47,9 +49,7 @@ discovery = importlib.import_module('custom_components.localthings.registry.disc
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
resources = json.load(open('dump.json'))['data']['resources']
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
# or: by_type.for_device(one_ui_version) when /otninformation has swVersionInfo.oneUiVersion
reg = by_type.resolve(resources) # the same entry point the coordinator uses
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
@@ -61,7 +61,95 @@ print('state_keys:', sorted(state))
`exists_fn` and produces the final entity values. Use the same routine to
regenerate a golden.
## 3. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
## 3. Route the device to a registry — add a row, never a branch
If detection returns `None`, the device falls back to common capabilities and
loses roughly **half** its entities (measured across the fixture corpus: 843 of
1510 bound entities survive). So routing is the first thing to fix, and
`registry/by_type/__init__.py` is deliberately kept boring.
`resolve(resources)` is the only entry point — the coordinator, the config
flow's probe and the golden-regression harness all call it, so the order can't
drift between what ships and what the tests assert. Two stages:
1. **`for_device_by_model(model_num, description)`** — the primary path. Both
fields come from `/information/vs/0`. Board-family tokens are matched
against `modelNum` first, then `description`, then the fuzzy two-letter
consumer-model prefix.
2. **`for_device_by_resources(resources)`** — for boards that report no
`/information/vs/0` at all. Needs a *distinctive* signature.
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
device naming its own type, `'7.0 Dishwasher'` — and it used to be stage one.
But only a minority of hardware reports it, every device that does is already
typed by its modelNum board token (`TestOneUiVersionIsNotConsulted` checks that
against the whole corpus), and no device-support issue was ever fixed by adding
a mapping for it. Don't reintroduce it as a detection stage; it stays in
diagnostics as a firmware-generation marker (`'7.0 Air conditioner'` means
Tizen Lite), which is useful when triaging.
### Adding a board family
Almost always a one-line addition to `_BOARD_TOKEN_TO_KEY`:
```python
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
```
Matching is on **whole tokens** of the model string, split on any run of
non-alphanumerics and upper-cased. That is what keeps this a table, and it
carries rules:
- **Never add a delimiter spelling.** `'_RAC_'` and `'-RAC-'` are the same
entry, `RAC`. If you find yourself adding a second row for punctuation, the
tokenizer already handled it.
- **Name the specific type, never the board family.** `DA-AC-` prefixes
RAC/WAC/DHM/AIR alike — a bare `'AC'` row would swallow the dehumidifier
and the air purifier. Same for `DA`, `KS`, `WM`, `TP1X`, `ARTIK051`.
`TestBoardTokenTable` asserts these stay out.
- **Never add a token that can co-occur with another.** `_board_family_key`
returns the first hit, which is only safe while no real model string
contains two tokens naming different types.
`TestBoardTokenAmbiguity` checks that invariant against every fixture, so a
new dump exercises it automatically — if it fails, the answer is a narrower
token, not a reordering.
- **Two-letter tokens are a last resort.** `'CT'` (legacy gas cooktop) is the
only one, and it is loose enough to collide by accident.
Reach past the table only when the evidence isn't a board token:
- **Consumer-model prefix** (`_CONSUMER_PREFIX_TO_KEY`) — for washers, dryers
and dishwashers, whose `modelNum` is the shared `DA_WM_` laundry board and
whose real type is only in `description`'s trailing model code
(`..._WA8000T`). Deliberately split on `_` only: widening it to `-` would
read the dishwasher's `ADW-WW-RTL-24-AILITE` board segment as a `WW`
washer. Consulted last because a two-letter prefix is the weakest evidence
here — `WAC` (window AC) starts with `WA` (top-load washer).
- **Resource signature** (`for_device_by_resources`) — only when
`/information/vs/0` is absent entirely. Require **two** independent shapes
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
never one, or an unrelated family's `/mode/vs/0` will match.
### If the model string identifies nothing
Check the diagnostics `identity` block before inventing a rule: it carries
`/oic/p` and `/oic/d`, which sit outside the `/device/0` dump.
`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type
declaration (`oic.d.airconditioner`). Nothing routes on it yet because no
captured dump has ever included it; if real hardware turns out to populate it,
it beats parsing board part numbers and this whole section shrinks. Note in
the issue when a dump has it.
### Sharing a registry vs adding one
Route a new family to an **existing** registry when its resource surface
matches (most AC board families do — verify by checking the dump binds with
zero unbound hrefs). Add a **new** registry only when the resources genuinely
differ: `vacuum_station` earned one because it shares no hrefs with anything
modelled; `microwave` split from `oven` over a distinct mode vocabulary,
setpoint bounds, and a `powerLevel` field.
## 4. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
Samsung appliances run RT-OCF and often expose the **same state twice**:
- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields.
@@ -83,7 +171,7 @@ resource from the populated dump:
Course/cycle is **not** an OCF question — there's no standard course resource, so
`/course/vs/0` (and the `/st/*course/vs/0` re-encoding) are both vendor.
## 4. Entity taxonomy — the judgement call
## 5. Entity taxonomy — the judgement call
For each field worth exposing, decide the entity kind and category
(`entity_category` on the descriptor):
@@ -104,7 +192,7 @@ 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. Select options: read them from the device, don't hardcode
## 6. Select options: read them from the device, don't hardcode
A `SelectDesc`'s `options` should come from the device's own advertised list
whenever the resource carries one, not from a Python tuple typed in from a
@@ -134,7 +222,7 @@ permanent design choice: migrate it to `options_field`/a callable the moment
a dump with a real supported-values list surfaces, instead of just adding
the new values to the static tuple.
## 6. Names and enum labels live in translations, never in Python
## 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
@@ -173,7 +261,7 @@ 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`.
## 7. Coverage discipline: bound or ignored
## 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`)
@@ -187,7 +275,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.
## 8. 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
@@ -196,7 +284,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.
## 9. 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,
@@ -209,6 +297,12 @@ shared module rather than copying.
4. Run `pytest tests/ -q` — and re-run the golden tests for **other** device
types after any change to `common.py`/`laundry.py`, since they share those.
The new fixture is picked up automatically by the corpus-wide checks (the
`all_device_fixtures` conftest fixture), including
`TestBoardTokenAmbiguity` — so a model string that collides with an existing
board token fails the build rather than silently mistyping someone's
appliance.
## Key files
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
- `registry/capability.py`, `registry/entities.py` — the `Capability` and
+7 -4
View File
@@ -105,12 +105,13 @@ The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` i
### Tests
```sh
python3 -m venv .venv
python3.13 -m venv .venv # 3.13 or newer; see below
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/pip install pytest-homeassistant-custom-component homeassistant
.venv/bin/pytest tests/ -q
```
`requirements-dev.txt` already pulls in Home Assistant and pytest at matching versions, so there's nothing to install alongside it. Use Python 3.13 or newer: pip resolves the newest `pytest-homeassistant-custom-component` your interpreter supports, and on 3.12 or older nothing resolves and the install fails outright. CI runs 3.14.
A large suite covering registry composition, discovery, entity descriptors, and golden-file regression against captured device dumps. `requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
---
@@ -139,7 +140,7 @@ custom_components/localthings/
entities.py Per-platform entity descriptor dataclasses
discovery.py Binds a device's live resources to registered capabilities
adapter.py Flattens bound entities into HA-ready state
identity.py Reads device identity for type detection
identity.py Reads /oic/p + /oic/d (manufacturer, model, OCF device type)
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,
@@ -169,7 +170,9 @@ support for hardware the maintainers don't have.
1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion`, add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` so `for_device_by_model()` can route it. If it also omits `/information/vs/0` (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, then route devices to it by adding the board-family token from their `modelNum` to `_BOARD_TOKEN_TO_KEY` — a single row, e.g. `'VSKR': 'vacuum_station'`. Tokens are matched whole (the model string is upper-cased and split on any run of non-alphanumerics), so one entry covers every delimiter spelling Samsung uses: `TP1X_DA-AC-RAC-01001` and `TP2X_RAC_20K` both resolve on `RAC`. Name the specific type, never the board family that contains it — `DA-AC-` prefixes RAC/WAC/DHM/AIR alike, so a bare `AC` row would swallow the dehumidifier and the air purifier. If the board is shared across types (washers and dryers both report `DA_WM_`), add the consumer-model prefix from `description` to `_CONSUMER_PREFIX_TO_KEY` instead. If the device omits `/information/vs/0` entirely (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
`oneUiVersion` is deliberately not consulted — see `resolve()` in that file for why.
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
+2 -35
View File
@@ -214,9 +214,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import (
for_device, for_device_by_model, for_device_by_resources,
)
from .registry.by_type import resolve as resolve_registry
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try:
@@ -275,25 +273,12 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
)
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
one_ui_version = (
resources
.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', '')
)
info_resource = resources.get('/information/vs/0', {})
recognized_registry = (
for_device(one_ui_version) if one_ui_version else None
) or for_device_by_model(
info_resource.get('x.com.samsung.da.modelNum', ''),
info_resource.get('x.com.samsung.da.description', ''),
) or for_device_by_resources(resources)
recognized_registry = resolve_registry(resources)
return {
"port": port,
"serial": serial,
"leaf_cert_pem": fullchain_pem,
"leaf_key_pem": leaf_key_pem,
"one_ui_version": one_ui_version,
"device_type_recognized": recognized_registry is not None,
}
except CannotConnect:
@@ -407,27 +392,9 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Shown only when the probe already knows the device type is unrecognized."""
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"],
},
)
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({}),
)
+19 -16
View File
@@ -23,7 +23,7 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
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.by_type import resolve as resolve_registry
from .registry.capabilities.common import (
merge_items_field,
merge_options_field,
@@ -352,19 +352,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# ------------------------------------------------------------------
def _run_discovery(self, resources: dict[str, dict]) -> None:
one_ui = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
self.one_ui_version = one_ui
# Reported for diagnostics only -- it names the firmware generation
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
# an issue. It does not route: only a minority of hardware reports it
# at all, and every device that does is already typed by its modelNum.
self.one_ui_version = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
info = resources.get('/information/vs/0', {})
reg = for_device(one_ui) if one_ui else None
if reg is None:
reg = for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if reg is None:
reg = for_device_by_resources(resources)
reg = resolve_registry(resources)
unbound: list[str] = []
hot, warm = set(), set()
@@ -374,13 +370,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
elif tier == 'warm':
warm.add(href)
model_num = info.get('x.com.samsung.da.modelNum', '')
description = info.get('x.com.samsung.da.description', '')
if reg is not None:
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
self._log.debug("device type: %s (modelNum=%r)", reg.name, model_num)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
log=unbound.append, tier_log=_tier_log)
self.device_type_name = reg.name
else:
self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
# Both fields: detection reads each of them (board token, then
# consumer-model code), and this line is what a user pastes into
# an issue -- modelNum alone doesn't identify a washer or dryer.
self._log.warning(
"unknown device type modelNum=%r description=%r; using common caps",
model_num, description,
)
bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log)
self.device_type_name = None
self.bound = bound
@@ -393,7 +397,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
ident = self._identity
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
model_num = info.get('x.com.samsung.da.modelNum', '')
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
mfr = (ident.manufacturer if ident else '') or 'Samsung'
@@ -31,9 +31,19 @@ async def async_get_config_entry_diagnostics(
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
# /oic/p and /oic/d sit outside the /device/0 batch captured below, so
# they'd otherwise never reach an issue report. /oic/d's `rt` is OCF's
# standard device-type declaration -- see registry/identity.py.
identity = coordinator._identity
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"identity": {
"manufacturer": identity.manufacturer,
"model": identity.model,
"device_types": list(identity.device_types),
"resources": redact_resources(identity.raw),
} if identity is not None else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
+66
View File
@@ -0,0 +1,66 @@
{
"entity": {
"climate": {
"airconditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"1": "mdi:fan-speed-1",
"2": "mdi:fan-speed-1",
"3": "mdi:fan-speed-2",
"4": "mdi:fan-speed-3",
"5": "mdi:fan-speed-3",
"turbo": "mdi:fan-speed-3",
"max": "mdi:fan-speed-3"
}
},
"preset_mode": {
"state": {
"ai_comfort": "mdi:creation",
"quiet": "mdi:volume-off",
"smart": "mdi:brain",
"speed": "mdi:speedometer",
"nano": "mdi:weather-dust",
"nanosleep": "mdi:sleep",
"longwind": "mdi:weather-windy",
"motiondirect": "mdi:account-arrow-left",
"motionindirect": "mdi:account-arrow-right",
"drycomfort": "mdi:water-percent",
"2step": "mdi:stairs"
}
}
}
}
},
"fan": {
"air_purifier_fan": {
"state_attributes": {
"preset_mode": {
"state": {
"smart": "mdi:brain",
"max": "mdi:fan-speed-3",
"mid": "mdi:fan-speed-2",
"windfree": "mdi:weather-dust",
"sleep": "mdi:sleep"
}
}
}
}
},
"sensor": {
"machine_state": {
"state": {
"idle": "mdi:power-standby",
"active": "mdi:play",
"pause": "mdi:pause"
}
},
"connection_mode": {
"state": {
"observe": "mdi:broadcast",
"poll": "mdi:sync"
}
}
}
}
}
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.1"
],
"version": "0.14.0"
"version": "0.15.0"
}
@@ -1,4 +1,5 @@
"""Per-device-type registries."""
import re
from typing import Optional
from ._base import DeviceRegistry
@@ -9,17 +10,17 @@ from . import (
)
__all__ = [
'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model',
'for_device_by_resources',
'DeviceRegistry', 'resolve', 'for_device_by_model',
'for_device_by_resources', '_board_tokens',
]
# One entry per registry, no aliases: every key here is reachable from
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airpurifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'air_conditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dehumidifier': dehumidifier.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
@@ -27,7 +28,6 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'induction_cooktop': induction_cooktop.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
@@ -37,48 +37,6 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
}
def _type_key(one_ui_version: str) -> str:
"""Convert oneUiVersion string to registry key.
Args:
one_ui_version: String like '7.0 Dishwasher' or 'Oven'.
Returns:
Lowercase key with version prefix stripped and spaces/hyphens converted to underscores.
Examples:
'7.0 Dishwasher' -> 'dishwasher'
'7.0 French Door Refrigerator' -> 'french_door_refrigerator'
'Oven' -> 'oven'
"""
if ' ' in one_ui_version:
# Strip version prefix: everything before and including the first space
suffix = one_ui_version.split(' ', 1)[-1]
else:
suffix = one_ui_version
return suffix.lower().replace(' ', '_').replace('-', '_')
def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
"""Return the DeviceRegistry for the given oneUiVersion string, or None if unknown.
Args:
one_ui_version: Device's oneUiVersion string (e.g., '7.0 Dishwasher').
Returns:
DeviceRegistry if a matching registry exists, None otherwise.
"""
key = _type_key(one_ui_version)
if key in _REGISTRY_BY_KEY:
return _REGISTRY_BY_KEY[key]
# Suffix fallback: e.g. "french_door_refrigerator" ends with "_refrigerator"
for rkey, reg in _REGISTRY_BY_KEY.items():
if key.endswith(f'_{rkey}'):
return reg
return None
# Consumer-model prefix (first two letters of the '_'-delimited token in
# `description` right before any '/board-info' suffix) -> registry key.
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
@@ -94,21 +52,89 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'DW': 'dishwasher',
}
# Board-family token -> registry key, matched against whole tokens of
# `modelNum`/`description` (see `_board_tokens`).
#
# Tokenizing instead of substring-matching is what keeps this a table rather
# than a ladder of hand-written rules. Samsung spells the same board family
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
# same RAC family -- so a substring rule has to be written once per spelling
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
# every one of those as a single entry.
#
# Entries must name the *specific* device type, never the board family that
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
# would swallow the dehumidifier and the air purifier. Where two families
# genuinely share a resource surface they share a registry (all the
# air-conditioner spellings below), which is a statement about the hardware,
# not a shortcut.
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
'REF': 'refrigerator',
# Air conditioners. Every one of these is a distinct board family with
# the same resource surface: room (issues #37, #91), package, Korean
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
# (#52), and ARA-WW wall-mount (#115, #116, #117, #120).
'RAC': 'airconditioner',
'PRAC': 'airconditioner',
'KRAC': 'airconditioner',
'WAC': 'airconditioner',
'FAC': 'airconditioner',
'CAWW': 'airconditioner',
'ARA': 'airconditioner',
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
'WATERPURIFIER': 'water_purifier', # issue #90
'ADW': 'dishwasher',
'AHD': 'range_hood',
'RANGE': 'range', # issue #44 -- cooktop+oven combo
'OVEN': 'oven', # issue #55 -- wall oven, no burners
'MICROWAVE': 'microwave', # issues #66, #121
'COOKTOP': 'induction_cooktop', # issue #86 -- standalone, no oven
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
# token, and so the loosest entry in this table -- it is only ever
# reached by a device that matched nothing more specific, and its
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
# for the field ordering that makes that resolve correctly.
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
}
def _model_num_segments(model_num: str) -> list[str]:
"""Underscore-delimited segments of modelNum's pipe-prefix.
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
Most boards wrap a token in underscores on both sides ('..._REF_...'),
which a plain substring check catches fine. But the ARTIK051_DONGLE_REF
family (issues #77, #83) reports modelNum as
'<board>_DONGLE_REF|<rest...>' -- REF is the *last* segment before the
pipe, with no trailing underscore, so '_REF_' never matches and the
device silently fell back to 'unknown'. Splitting on '_' and checking
segment membership catches both shapes without caring which side (if
either) has a delimiter.
def _board_tokens(value: str, cut_at: str) -> list[str]:
"""Whole, upper-cased tokens of `value` up to the first `cut_at`.
`cut_at` drops the trailing junk each field carries -- everything after
modelNum's first '|' (a board revision and a capability bitmap, which can
contain anything) and after description's first '/' (a '/DC92-...' board
part number).
"""
prefix = (model_num or '').split('|', 1)[0]
return prefix.split('_')
head = (value or '').split(cut_at, 1)[0].upper()
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
def _board_family_key(value: str, cut_at: str) -> Optional[str]:
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
No known modelNum or description yields two *conflicting* board keys, so
which token is found first doesn't matter within one field -- the table is
a flat lookup, not a priority list. Adding an entry that could co-occur
with another (a family token, or one short enough to collide by accident)
would break that property; see this table's comment.
"""
for token in _board_tokens(value, cut_at):
key = _BOARD_TOKEN_TO_KEY.get(token)
if key is not None:
return key
return None
def _consumer_model_key(description: str) -> Optional[str]:
@@ -123,12 +149,18 @@ def _consumer_model_key(description: str) -> Optional[str]:
segments from the end and take the first one that resolves, rather
than assuming the last segment is always it.
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
prefixes matched against the *start* of a segment, so widening the split
to '-' as well would start reading board-family segments as consumer
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
'WW' segment and route to washer.
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
board-family token, issue #87) also starts with 'WA' (the top-load-washer
prefix, issue #106) at this granularity. for_device_by_model() calls the
board-family modelNum checks first and this function only as a fallback,
so that ambiguity resolves correctly without this function needing to
know about unrelated device families.
prefix, issue #106) at this granularity. for_device_by_model() consults
the board-family table first and this function only as a fallback, so
that ambiguity resolves correctly without this function needing to know
about unrelated device families.
"""
segments = (description or '').split('/', 1)[0].split('_')
for segment in reversed(segments):
@@ -139,176 +171,52 @@ def _consumer_model_key(description: str) -> Optional[str]:
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
"""Fallback device-type detection for hardware that never reports
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
no swVersionInfo key at all).
"""Device-type detection from /information/vs/0's model strings.
The primary path: the board named in `modelNum` determines the resource
surface, which is what a registry describes.
Three passes, narrowest evidence first:
1. Board-family tokens in `modelNum`. The most reliable signal -- it names
the board, which determines the resource surface.
2. The same tokens in `description`. Some units carry the board token only
there (a scrubbed or placeholder modelNum, e.g. description
'TP1X_REF_21K'). This runs second so that a device whose two fields
disagree is typed by its modelNum: the legacy gas cooktop reports
'ARTIK051_GB_CT_001' (CT -> gas cooktop) alongside
'ARTIK051_GLOBAL_COOKTOP' (COOKTOP -> induction cooktop), and the
board is right.
3. The consumer-model prefix in `description` (washer/dryer/dishwasher).
Last, because a bare two-letter prefix is the fuzziest evidence here
and would otherwise shadow the specific board tokens above.
Args:
model_num: x.com.samsung.da.modelNum from /information/vs/0.
description: x.com.samsung.da.description from /information/vs/0.
Returns:
DeviceRegistry if the consumer-model code or modelNum resolves to a
DeviceRegistry if the modelNum or consumer-model code resolves to a
known type, None otherwise.
"""
# Board-family modelNum tokens are checked first, and the fuzzier
# 2-letter consumer-model prefix from `description` only as a fallback
# (see the bottom of this function) -- some board tokens are themselves
# only 2-3 letters long ('WAC', issue #87) and would otherwise collide
# with an unrelated consumer prefix at that granularity ('WA', issue #106).
key = None
if key is None and 'REF' in _model_num_segments(model_num):
key = 'refrigerator'
# Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and
# a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token.
if key is None and '_PRAC_' in (model_num or ''):
key = 'airconditioner'
# Other RAC boards carry a bare 'RAC' (Room Air Conditioner) token in the
# modelNum, in one of two spellings: the underscore form '_RAC_' (e.g.
# TP2X_RAC_20K, issue #37) or the hyphenated form '-RAC-' (e.g.
# TP1X_DA-AC-RAC-01001, a cool-only global variant, issue #91). Both are
# distinct from '_PRAC_' above ('P' sits before 'RAC' with no delimiter)
# and from range ('-RANGE-') / oven ('-OVEN-') tokens. Most TP1X boards
# self-report oneUiVersion and resolve via for_device() upstream of this
# fallback; the hyphenated match is what rescues variants whose
# /otninformation/vs/0 ships no swVersionInfo block at all, so
# oneUiVersion is empty.
if key is None and ('_RAC_' in (model_num or '')
or '-RAC-' in (model_num or '').upper()):
key = 'airconditioner'
# System air conditioners (multi-indoor-unit commercial installs, e.g.
# A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and
# carry the '-CAWW-' board-family token instead of '_RAC_'/'_PRAC_'.
# Same TP1X/TP2X-class resource surface as the room-AC models above
# (confirmed by the issue #52 dump binding cleanly against the existing
# airconditioner registry once routed here), plus one new SAC-specific
# resource (see airconditioner.py's _AC_IGNORED).
if key is None and '-CAWW-' in (model_num or '').upper():
key = 'airconditioner'
# Dehumidifiers (e.g. TP1X_DA_AC_DHM_01001_0000, issue #88) share the
# DA_AC_ board family with the room-AC models above but carry the
# '_DHM_' (DeHuMidifier) token instead of '_RAC_'/'_PRAC_'. Distinct
# registry: target humidity, not temperature, is the primary control,
# and there's no climate composite.
if key is None and '_DHM_' in (model_num or ''):
key = 'dehumidifier'
# Window air conditioners (e.g. TP1X_DA_AC_WAC_01001_0000, issue #87)
# report no oneUiVersion and carry the '_WAC_' (Window Air Conditioner)
# token instead of '_RAC_'/'_PRAC_'. Same TP1X-class resource surface
# as the room-AC models above (mode/convenient/wind/temperature/power/
# filter/humidity all confirmed against the issue #87 dump binding
# cleanly against the existing airconditioner registry once routed
# here) -- no WAC-specific resources needed.
if key is None and '_WAC_' in (model_num or ''):
key = 'airconditioner'
# Wind-Free 2-in-1 systems (floor-standing + wall-mounted indoor units
# sharing one outdoor unit and one local IP, e.g. TP2X_FAC_BORA_21K,
# issues #150/#153) report no oneUiVersion and carry the '_FAC_' token
# instead of '_RAC_'/'_PRAC_'. Same TP1X/TP2X-class resource surface as
# the room-AC models above.
if key is None and '_FAC_' in (model_num or ''):
key = 'airconditioner'
# ARA-WW-class wall-mount RACs (e.g. ARA-WW-TP1-22-COMMON, issues #115/
# #116/#117/#120) report no oneUiVersion and no '_RAC_'/'-RAC-' token at
# all -- the board family is spelled 'ARA-WW-' instead. Same resource
# surface as the other TP1X-class room ACs above (mode/convenient/wind/
# temperature/power/filter/humidity all confirmed against these dumps
# binding cleanly against the existing airconditioner registry once
# routed here) -- no ARA-WW-specific resources needed, so this reuses
# the same registry rather than adding a new device type.
if key is None and 'ARA-WW-' in (model_num or '').upper():
key = 'airconditioner'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
# Room air conditioners on the ARTIK051 board (e.g. ARTIK051_KRAC_18K,
# issue #136) report no oneUiVersion and carry a '_KRAC_' token. The '_RAC_'
# check above cannot see it -- the 'K' sits between the underscore and 'RAC' --
# and the consumer-prefix fallback only covers washers/dryers/dishwashers, so
# these units fell back to 'unknown' and exposed nothing but power. Same
# ARTIK051 board family as the '_TVTL_' air purifier below.
if key is None and '_KRAC_' in (model_num or ''):
key = 'airconditioner'
if key is None and '_TVTL_' in (model_num or ''):
key = 'air_purifier'
# BESPOKE Cube Air (e.g. A-VTWW-TP2-21-COMMON, issue #151) reports no
# oneUiVersion and carries the hyphenated '-VTWW-' board-family token
# (distinct from the underscore-delimited '_TVTL_' ARTIK051 family
# above). Its fan lives on /wind/strength/vs/0 rather than /mode/vs/0 --
# see capabilities/air_purifier.py's WIND_STRENGTH_FAN.
if key is None and '-VTWW-' in (model_num or '').upper():
key = 'air_purifier'
model_identity = f'{model_num} {description}'.upper()
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
key = 'cooktop'
# Water purifiers (e.g. TP2X_WATERPURIFIER_20K, issue #90) report no
# oneUiVersion and carry the 'WATERPURIFIER' board-family token.
if key is None and 'WATERPURIFIER' in model_identity:
key = 'water_purifier'
if key is None and model_identity.startswith('AHD-'):
key = 'range_hood'
# Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) --
# like the RAC/PRAC air conditioners above, these report no oneUiVersion
# and don't match the washer/dryer/dishwasher consumer-prefix map either.
if key is None and '-RANGE-' in (model_num or '').upper():
key = 'range'
# Wall ovens (e.g. TP1X_DA-KS-OVEN-0107X, issue #55) -- same board-family
# naming as the range combo above, minus the burners; also reports no
# 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, both combi (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121)
# and plain (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same board
# family as the wall oven above (an '/oven/vs/0' cavity resource, same
# /operational/state/vs/0 + /doors/vs/0 shape), but a distinct mode
# vocabulary (Convection/AirFryer/Grill/MicroWave*) and setpoint bounds
# from the oven registry, plus a powerLevel field ovens don't report --
# its own device type rather than folded into 'oven' (issue #121 shipped
# it onto the oven registry initially; split out per user feedback).
# Reports no oneUiVersion and doesn't match the washer/dryer/dishwasher
# prefix map either.
if key is None and '-MICROWAVE-' in (model_num or '').upper():
key = 'microwave'
# Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue
# #86) -- same board family and '/cooktop/status/vs/0' resource shape
# as the range combo above, but no oven attached at all. Distinct from
# the '_COOKTOP'/'_GB_CT_' underscore-delimited check above: that one
# matches a different, older gas-cooktop family (cooktop.py's NA9300K
# class, burner state embedded in /mode/vs/0's options array) whose
# modelNum token is underscore-delimited, not hyphenated like this
# board family's.
if key is None and '-COOKTOP-' in (model_num or '').upper():
key = 'induction_cooktop'
# Stick-vacuum clean/auto-empty station (e.g. A-VSKR-TP1-22-VS9500AL,
# issue #131) -- reports no oneUiVersion and carries the '-VSKR-'
# board-family token. See capabilities/vacuum_station.py for why this
# is its own device type: the resource set (dustbag/dustbin/UV-sanitize
# station state) shares no hrefs with anything else already modeled.
if key is None and '-VSKR-' in (model_num or '').upper():
key = 'vacuum_station'
# AirDresser (e.g. DA_DF_A51_20_COMMON, issue #162) -- reports no
# oneUiVersion and carries the '_DF_' (Dresser Function) board-family
# token. Every resource it exposes (course select, wrinkle-prevent
# setting, diagnosis) is already handled by the shared laundry
# machinery; it needs its own device type only because none of the
# washer/dryer/dishwasher consumer-model prefixes below match it.
if key is None and '_DF_' in (model_num or ''):
key = 'air_dresser'
# Consumer-model prefix from `description` (washer/dryer/dishwasher) --
# last, since it's the fuzziest match (a bare 2-letter prefix) and would
# otherwise shadow the more specific board-family tokens above.
if key is None:
key = _consumer_model_key(description)
key = (
_board_family_key(model_num, '|')
or _board_family_key(description, '/')
or _consumer_model_key(description)
)
return _REGISTRY_BY_KEY.get(key) if key else None
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Detect a device family from a distinctive local-resource signature.
Some newer cooktops omit both ``oneUiVersion`` and
``/information/vs/0``. Their mode resource still identifies them: it
contains a DeviceType option and multiple per-burner OperationState
options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is
not misclassified.
For boards that ship no ``/information/vs/0`` at all, leaving
`for_device_by_model` nothing to read. Some newer cooktops are the
original case: their mode resource still identifies them, carrying a
DeviceType option and multiple per-burner OperationState options.
Require two independent shapes for every signature here, never one, so
an unrelated family's ``/mode/vs/0`` isn't misclassified.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
@@ -327,18 +235,45 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
# Oven/range-combo boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS -- the resource is simply absent from the
# dump, not just empty) can't be matched via for_device_by_model's
# modelNum tokens either. 'Bake' is oven/range cook-mode vocabulary that
# no other family's /mode/vs/0 uses (confirmed against the microwave,
# cooktop, and every laundry fixture), so its presence alongside the
# oven cavity resource is a safe signature. Distinguish range (has a
# cooktop half) from a plain wall oven by which cooktop-status resource,
# if any, is also present.
# Oven/range/microwave boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
# absent from the dump, not just empty) can't be matched via
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
# the oven cavity resource (/oven/vs/0) is a safe signature.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources and 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
if '/oven/vs/0' in resources:
if any(
m in supported_modes
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
):
return _REGISTRY_BY_KEY['microwave']
if 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
return None
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Device type for a parsed /device/0 dump, or None if unrecognized.
The single entry point for detection -- the coordinator, the config
flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert.
Model strings first (`for_device_by_model`), then a distinctive resource
signature (`for_device_by_resources`) for boards that report no
/information/vs/0 at all.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
reads like the obvious signal -- the device naming its own type, e.g.
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
device that does is already typed by its modelNum board token, and no
device-support issue has ever been fixed by adding a mapping for it. It
is still reported in diagnostics as a firmware-generation marker.
"""
info = resources.get('/information/vs/0', {})
return for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
) or for_device_by_resources(resources)
@@ -4,16 +4,16 @@ Spans three board generations sharing this one registry (see
capabilities/air_purifier.py's module docstring for the per-href
match_fn discriminators that keep them from colliding):
- ARTIK051_TVTL-class (issue #56). Reports no oneUiVersion; resolved via
for_device_by_model's '_TVTL_' modelNum token (see registry.py).
- TP1X_DA-AC-AIR-class (issue #130). Self-reports oneUiVersion "7.0 Air
purifier", resolved via for_device(). Adds real fan-mode control plus
- ARTIK051_TVTL-class (issue #56). Resolved via the 'TVTL' modelNum board
token (see by_type/__init__.py).
- TP1X_DA-AC-AIR-class (issue #130). Resolved via the 'AIR' board token.
Adds real fan-mode control plus
display/HEPA-filter/pet-filter/sound resources the older family never
reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE
for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on
the shared DA-AC- board family.
- A-VTWW-TP2-21-COMMON-class (issue #151). Reports no oneUiVersion and no
existing modelNum token; falls back to unknown until routed here. Its fan
- A-VTWW-TP2-21-COMMON-class (issue #151). Resolved via the 'VTWW' board
token, added for it. Its fan
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
/mode/vs/0 -- see that capability's comment.
@@ -27,6 +27,8 @@ REGISTRY = DeviceRegistry(
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
@@ -4,9 +4,8 @@ Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label
doesn't collide with the unrelated induction_cooktop family (issue #86,
by_type/induction_cooktop.py) -- two different OCF surfaces that happen to
share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup
key is unchanged: it's relied on by real devices reporting
oneUiVersion "Cooktop" (for_device), the legacy ARTIK051 modelNum rule
(for_device_by_model), and the resource-signature fallback
key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
(for_device_by_model) and the resource-signature fallback
(for_device_by_resources) alike.
"""
@@ -1,8 +1,8 @@
"""Capabilities specific to the AirDresser family (Samsung DA_DF-class,
issues #162/#157).
This board family reports no oneUiVersion and no /information/vs/0 token
any existing family routes on, so it gets its own device type -- but most
This board family carried no /information/vs/0 token any existing family
routed on, so it gets its own device type (the 'DF' board token) -- but most
of the resources it exposes are already handled by the shared laundry
machinery:
@@ -128,6 +128,24 @@ def _sensor_item_value(items, type_):
def _has_sensor_type(type_):
"""True when the /sensors/vs/0 items[] array lists an item of this type.
This proves the type is *listed*, not that the reading is real: issue
#166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) lists all five item types
with permanent zero values on both its units, and the reporter confirmed
none of these sensors are physically present. A top-level
x.com.samsung.da.cleanLevel scalar (separate from the CleanLevel item)
looked like a corroborating "this reading is real" signal at first --
present alongside genuinely populated readings on tp1x_da_ac_rac_01011
and the tp1x_da_ac_air air purifier fixture, absent on every all-zero
ARTIK051_PRAC_20K dump including both #166 units -- but it doesn't hold
up as a general rule: air_purifier_device.json (ARTIK051_TVTL_18K),
air_purifier_vtww_device.json, and range_hood_device.json all carry
genuinely populated, non-AC-family Dust/FineDust/SuperFineDust readings
with no such scalar. So this stays item-type presence only -- the
entities are disabled by default instead (see AIR_QUALITY below) rather
than existence-gated on a signal that would silently drop real readings
on hardware this repo hasn't seen yet."""
def fn(rep, resources):
return any(isinstance(i, dict) and i.get('x.com.samsung.da.type') == type_
for i in (rep.get('x.com.samsung.da.items') or []))
@@ -441,12 +459,26 @@ CLIMATE = Capability(
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
# Gated off the legacy board for the same reason as beep above -- its
# Sleep_ token is already the good_sleep Number below.
#
# exists_fn only proves the Sleep_ token slot is present, not that
# tropical night mode is a real feature of the unit: issue #166
# (ARxxTXFCAWKNEU) reports Sleep_0 in every dump -- the exact same
# always-there-at-zero shape as the issue #17 dump #164 was verified
# against -- yet the reporter confirmed their remote/app has no
# tropical night mode control at all. Samsung's OCF options[] blob
# carries this scaffolding token regardless of physical capability,
# so there's no reliable signal here to gate on (same 'don't guess'
# rule as elsewhere in this file, just with no signal to guess from).
# Registered but disabled by default, same precedent as
# fridge.rack_count / cooktop.paired_hood_* -- units that do have the
# feature can enable it themselves.
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
exists_fn=lambda rep, resources: (
not is_legacy_board(resources)
and _option_token(rep, 'Sleep') is not None),
write_fn=_tropical_night_write,
native_min=0, native_max=16, step=1,
enabled_default=False,
icon='mdi:weather-night', entity_category='config'),
# Settings that this board generation keeps as options[] tokens.
SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'),
@@ -645,6 +677,65 @@ ANOMALY_LOAD = Capability(
),
)
# Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001 --
# Lennox-branded heat pump on the RAC board family): `status` is a bare
# On/Off boolean with no vendor prefix, the same shape already shipped
# writable elsewhere in this file (MUTE_ONCE, AUTO_CLEAN, AIR_PURIFY,
# DISPLAY_LIGHT) despite none of those having a live-confirmed write either
# -- worst case a wrong token no-ops, same risk profile as that family, so
# it's a switch rather than a sensor. `switchPowerSaveMode` picks the save
# intensity out of its own supportedSwitchPowerSaveMode list, but *what*
# writing it actually does to a running compressor isn't knowable from the
# dump -- same 'don't guess' read-only treatment as CURRENT_LIMIT/
# ANOMALY_LOAD's mode fields. A third field, `motionState`, also carries a
# supportedMotionState list but its role (a live sensor readout vs. a
# sensitivity setting) isn't distinguishable from the dump, so it's left
# unmodeled entirely.
ABSENCE_POWER_SAVING = Capability(
href='/mds/absencepowersaving/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='absence_power_saving_active', field='status',
icon='mdi:human-greeting-proximity',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['mds', 'absencepowersaving', 'vs', '0'],
{'status': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='absence_power_saving_mode', field='switchPowerSaveMode',
device_class='enum',
options=('eco', 'normal', 'comfort'),
translation_key='absence_power_saving_mode',
icon='mdi:leaf', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
# Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING
# above on the same dump: `status` is the same bare On/Off shape, promoted to
# a switch for the same reason. `modes` (Direct/Indirect airflow out of
# `supportedModes`) stays read-only -- same reasoning as
# absence_power_saving_mode above.
MOTION_DETECT_WIND = Capability(
href='/option/motiondetectwind/stateful/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='motion_detect_wind_active', field='status',
icon='mdi:motion-sensor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'motiondetectwind', 'stateful', 'vs', '0'],
{'status': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='motion_detect_wind_mode', field='modes',
device_class='enum',
options=('direct', 'indirect'),
translation_key='motion_detect_wind_mode',
icon='mdi:weather-windy', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
# The climate entity already surfaces current_temperature as a card
# attribute, but that's not enough for history graphs/automations/
# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard-
@@ -702,6 +793,19 @@ HUMIDITY = Capability(
# are 1- or 2-element arrays with no corroborating scalar, so they stay string
# diagnostics (see _sensor_item_value for the 2-element ambiguity and why only
# v[0] is taken). No unit is advertised on the resource, so no device_class.
#
# exists_fn (_has_sensor_type) only proves the item *type* is listed, not
# that the unit actually carries that sensor: issue #166 (ARxxTXFCAWKNEU,
# board ARTIK051_PRAC_20K) reports all five item types on both its units,
# values permanently '0'/['0','0'] -- yet the reporter confirmed none apply
# to their model. A tighter existence gate was tried (requiring the
# corroborating cleanLevel scalar above) but doesn't hold up as a general
# rule -- see _has_sensor_type's docstring -- and risks silently dropping
# real readings on hardware that reports them without that scalar. So these
# stay bound whenever the type is listed, same as before #166, and disabled
# by default instead (same precedent as fridge.rack_count /
# cooktop.paired_hood_model / this file's own tropical_night_mode): units
# that do have the sensor can enable it themselves.
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='cold',
@@ -710,11 +814,13 @@ AIR_QUALITY = Capability(
icon='mdi:broom', entity_category='diagnostic',
state_class='measurement',
exists_fn=_has_sensor_type('CleanLevel'),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, 'CleanLevel'))),
*tuple(
SensorDesc(key=key, field='x.com.samsung.da.items',
icon=icon, entity_category='diagnostic',
exists_fn=_has_sensor_type(type_),
enabled_default=False,
value_fn=lambda items, t=type_: _sensor_item_value(items, t))
for key, icon, type_ in (
('odor', 'mdi:weather-windy', 'Odor'),
@@ -27,6 +27,10 @@ different from an oven, and defined fresh here:
(a brightness level, not literally 'On') -- the switch now treats any
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
(the two confirmed tokens) rather than the never-confirmed 'On'.
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
option-array tokens (issue #181), both with On and Off observed live
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
the MW7300B combi dump has neither.
Note: cooking-mode writes are unproven here, same caveat as oven.py's
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
@@ -145,6 +149,14 @@ def _lamp_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
def _filter_remind_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'FilterRemind') is not None
def _remind_beep_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'RemindBeep') is not None
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
@@ -159,6 +171,26 @@ def _lamp_write(p, rep, href=None):
}
def _filter_remind_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('FilterRemind', p),
}
def _remind_beep_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('RemindBeep', p),
}
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
@@ -213,5 +245,22 @@ MICROWAVE_MODE = Capability(
exists_fn=_lamp_exists,
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
write_fn=_lamp_write),
# issue #181: Filter Reminder / End Signal Reminder toggles,
# confirmed present (both On and Off observed across dumps -- see
# issue #152's ME7500D fixtures) but only on boards that carry the
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
# MW7300B combi dump has neither) rather than assumed universal.
SwitchDesc(key='filter_remind', field='x.com.samsung.da.options',
icon='mdi:air-filter',
entity_category='config',
exists_fn=_filter_remind_exists,
value_fn=lambda opts: option_value(opts, 'FilterRemind') == 'On',
write_fn=_filter_remind_write),
SwitchDesc(key='remind_beep', field='x.com.samsung.da.options',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=_remind_beep_exists,
value_fn=lambda opts: option_value(opts, 'RemindBeep') == 'On',
write_fn=_remind_beep_write),
),
)
@@ -2,9 +2,10 @@
front-load washers).
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
family). Washers never report `oneUiVersion` -- see
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
detection this device type requires.
family). Washers share the `DA_WM_` laundry board with dryers, so their
`modelNum` can't tell the two apart -- see `registry/by_type/__init__.py`'s
`_CONSUMER_PREFIX_TO_KEY` for the `description`-based detection this device
type requires.
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
fallback pairs, buzzer, energy meter, job-beginning-status, and the
@@ -1,7 +1,7 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional
import cbor2
@@ -13,6 +13,8 @@ class DeviceIdentity:
model: str
name: str
serial: Optional[str]
device_types: tuple[str, ...] = ()
raw: dict[str, dict] = field(default_factory=dict)
def _get(sess, path) -> dict:
@@ -26,6 +28,27 @@ def _get(sess, path) -> dict:
return {}
def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
In OCF this is the one standardized "what am I" field: alongside the
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet --
device-type detection currently parses board part numbers out of
/information/vs/0's modelNum instead (see registry/by_type/__init__.py) --
because no captured dump has ever included it: /device/0 batch responses
don't carry /oic/d, and diagnostics didn't report it. It's surfaced in
diagnostics so incoming issue reports can tell us whether real hardware
populates it usefully enough to route on.
"""
rt = d.get('rt')
if isinstance(rt, str):
rt = [rt]
if not isinstance(rt, (list, tuple)):
return ()
return tuple(t for t in rt if isinstance(t, str))
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
@@ -34,4 +57,9 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
model=p.get('mnmo') or '',
name=d.get('n') or '',
serial=serial,
device_types=_device_types(d),
# Kept whole rather than field-by-field: these resources are outside
# the /device/0 dump diagnostics already captures, and we don't yet
# know which of their fields will turn out to identify a device type.
raw={'/oic/p': p, '/oic/d': d},
)
@@ -19,9 +19,26 @@ _SENSITIVE_SUBSTRINGS = (
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
)
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
# with bare one- and two-letter keys that the rules above cannot see, being
# far too short to match on -- 'di' alone is a substring of 'condition',
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
#
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
# number above.
# 'n' -- /oic/d's device name. Free text the owner can set from the
# SmartThings app, so it may well carry a person's name. Nothing
# in the /device/0 dump has ever exposed it; it only became
# reachable when diagnostics started reporting /oic/d, and the
# device-type signal we actually want from that resource is `rt`,
# which is not redacted.
_SENSITIVE_EXACT = frozenset({'di', 'pi', 'n'})
def _is_sensitive_key(key: str) -> bool:
lowered = key.lower()
if lowered in _SENSITIVE_EXACT:
return True
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS)
@@ -669,6 +669,21 @@
"powersaving": "Power saving"
}
},
"absence_power_saving_mode": {
"name": "Absence power saving mode",
"state": {
"eco": "Eco",
"normal": "Normal",
"comfort": "Comfort"
}
},
"motion_detect_wind_mode": {
"name": "Motion-detect wind mode",
"state": {
"direct": "Direct",
"indirect": "Indirect"
}
},
"current_temp_c": {
"name": "Temperature"
},
@@ -898,6 +913,12 @@
"auto_clean": {
"name": "Auto clean"
},
"absence_power_saving_active": {
"name": "Absence power saving active"
},
"motion_detect_wind_active": {
"name": "Motion-detect wind avoidance active"
},
"beep": {
"name": "Beep"
},
@@ -964,6 +985,9 @@
"favorite_coffee_enabled": {
"name": "Favorite coffee"
},
"filter_remind": {
"name": "Filter reminder"
},
"fridge_sound": {
"name": "Sound"
},
@@ -1006,6 +1030,9 @@
"rapid_fridge": {
"name": "Rapid fridge"
},
"remind_beep": {
"name": "End signal reminder"
},
"sabbath_mode": {
"name": "Sabbath mode"
},
@@ -1068,11 +1095,7 @@
},
"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."
},
"confirm_unknown_type_no_version": {
"title": "Appliance type not recognized",
"description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. 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."
"description": "This appliance's type couldn't be recognized. 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": {
@@ -669,6 +669,21 @@
"powersaving": "Energiebesparing"
}
},
"absence_power_saving_mode": {
"name": "Modus energiebesparing bij afwezigheid",
"state": {
"eco": "Eco",
"normal": "Normaal",
"comfort": "Comfort"
}
},
"motion_detect_wind_mode": {
"name": "Modus luchtstroomvermijding",
"state": {
"direct": "Direct",
"indirect": "Indirect"
}
},
"current_temp_c": {
"name": "Temperatuur"
},
@@ -898,6 +913,12 @@
"auto_clean": {
"name": "Automatisch reinigen"
},
"absence_power_saving_active": {
"name": "Energiebesparing bij afwezigheid actief"
},
"motion_detect_wind_active": {
"name": "Bewegingsdetectie luchtstroomvermijding actief"
},
"beep": {
"name": "Piep"
},
@@ -964,6 +985,9 @@
"favorite_coffee_enabled": {
"name": "Favoriete koffie"
},
"filter_remind": {
"name": "Filterherinnering"
},
"fridge_sound": {
"name": "Geluid"
},
@@ -1006,6 +1030,9 @@
"rapid_fridge": {
"name": "Snelkoelen"
},
"remind_beep": {
"name": "Eindsignaal herinnering"
},
"sabbath_mode": {
"name": "Sabbatmodus"
},
@@ -1068,11 +1095,7 @@
},
"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."
"description": "Het apparaattype van dit apparaat 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": {
+3
View File
@@ -1,2 +1,5 @@
[project]
requires-python = ">=3.13"
[tool.pytest.ini_options]
asyncio_mode = "auto"
+3 -2
View File
@@ -1,6 +1,7 @@
# Test harness — pulls in home-assistant, pytest, and pytest-socket at the
# matching versions. Current Home Assistant requires Python >= 3.14; pip will
# resolve the newest home-assistant your interpreter supports.
# matching versions. pip resolves the newest home-assistant your interpreter
# supports: Python 3.13 gets 0.13.316 (the floor below), 3.14 gets newer. On
# 3.12 or older nothing resolves and the whole install fails.
pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test
+15
View File
@@ -41,3 +41,18 @@ def fridge_resources() -> dict[str, dict]:
@pytest.fixture
def washer_resources() -> dict[str, dict]:
return _load_device('washer')
@pytest.fixture
def all_device_fixtures() -> dict[str, dict[str, dict]]:
"""Every scrubbed device dump, keyed by fixture name.
For invariants that must hold across the whole corpus rather than for one
device -- so a newly added dump exercises them automatically.
"""
return {
path.name[:-len('_device.json')]: _resources_from_dump(
json.loads(path.read_text())
)
for path in sorted(FIXTURES.glob('*_device.json'))
}
@@ -0,0 +1,626 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"MotionIndirect",
"MotionDirect",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "3",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"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": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T19:55:08",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm",
"x.com.samsung.da.triggeredTime": "2026-07-28T19:55:08",
"x.com.samsung.da.state": "Created"
}
]
}
},
{
"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": "70.0",
"x.com.samsung.da.current": "69.0",
"x.com.samsung.da.maximum": "86",
"x.com.samsung.da.minimum": "47",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
47,
86
],
"units": "F",
"temperature": 69.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
47,
86
],
"units": "F",
"temperature": 70.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0.000000",
"x.com.samsung.da.cumulativePower": "631483",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.modes": [
"Heat"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_990",
"OutdoorTemp_78",
"CoolCapa_35",
"WarmCapa_35",
"Volume_Mute",
"StopAutoClean_Set",
"Autoclean_On",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"KeyInputPermit_On",
"ModePermit_NoLimit",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"OptionCode_56636",
"ExtendOptionCode_246669",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "48"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_LNX-AC-RAC-01001_0000|10269441|60010523001811014E0048220090A000",
"x.com.samsung.da.description": "TP1X_LNX-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJV",
"x.com.samsung.da.diagSetupid": "AR2",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"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": "02646A260327",
"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": "102694A24062500",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102472A24042300,102579A10000200",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_HEATPUMP_3.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "45"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000001D6035C016102490C0F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-WW-TP1-24-ARXX00",
"versions": [
"11260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "045210269441FFFFFFFF",
"versions": [
"24062500",
"FFFFFFFF"
],
"visVersion": "240625"
},
{
"type": "Micom",
"modelId": "04521024724110257941",
"versions": [
"24042300",
"10000200"
],
"visVersion": "240423"
}
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/Halifax",
"offset": "-03:00",
"DST": "ON"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF3D564156FFFF2F56FFFF00001F001F00010000001F0000009C00FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencepowersaving/vs/0",
"rep": {
"status": "Off",
"modes": "Cooling_Heating",
"switchPowerSaveMode": "Normal",
"motionState": "Normal",
"supportedModes": [
"Cooling_Heating",
"Standby"
],
"supportedSwitchPowerSaveMode": [
"Eco",
"Normal",
"Comfort"
],
"supportedMotionState": [
"Normal",
"MotionNano",
"MotionSoftOff"
]
}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {
"status": "Off",
"maxDetectCount": [
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254"
],
"supportedTime": [
"0",
"30",
"60"
]
}
},
{
"href": "/option/motiondetectwind/stateful/vs/0",
"rep": {
"status": "Off",
"modes": "Indirect",
"supportedModes": [
"Direct",
"Indirect"
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "KG_IoT"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
@@ -0,0 +1,24 @@
{
"state_keys": [
"absence_power_saving_active",
"absence_power_saving_mode",
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"humidity",
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"power_watts",
"tropical_night_mode"
]
}
+2
View File
@@ -10,6 +10,7 @@
"door_open",
"energy_kwh",
"fan",
"filter_remind",
"finish_time",
"firmware_update",
"lamp",
@@ -17,6 +18,7 @@
"operation_time_minutes",
"power_level",
"progress_percentage",
"remind_beep",
"sound"
]
}
@@ -10,6 +10,7 @@
"door_open",
"energy_kwh",
"fan",
"filter_remind",
"finish_time",
"firmware_update",
"lamp",
@@ -17,6 +18,7 @@
"operation_time_minutes",
"power_level",
"progress_percentage",
"remind_beep",
"sound"
]
}
-2
View File
@@ -105,7 +105,6 @@ def mock_probe():
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'one_ui_version': '7.0 Refrigerator',
'device_type_recognized': True,
},
) as m:
@@ -122,7 +121,6 @@ def mock_probe_unknown_type():
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'one_ui_version': '9.0 Space Heater',
'device_type_recognized': False,
},
) as m:
+20 -46
View File
@@ -211,7 +211,6 @@ async def test_unknown_type_shows_confirmation_step(
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'confirm_unknown_type'
assert result['description_placeholders']['one_ui_version'] == '9.0 Space Heater'
result = await hass.config_entries.flow.async_configure(
result['flow_id'], {},
@@ -220,37 +219,24 @@ 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,
async def test_unknown_type_step_description_makes_no_version_claim(
hass: HomeAssistant, mock_probe_unknown_type
) -> 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,
},
)
"""One confirmation step covers every unrecognized device. It used to be
two, differing only in whether they blamed a missing oneUiVersion -- a
distinction that stopped existing when detection stopped reading it."""
import json
from pathlib import Path
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'confirm_unknown_type_no_version'
assert not result.get('description_placeholders')
steps = json.loads(
(Path(__file__).parents[2] / 'custom_components' / 'localthings'
/ 'translations' / 'en.json').read_text()
)['config']['step']
assert 'confirm_unknown_type_no_version' not in steps
description = steps['confirm_unknown_type']['description']
assert 'oneUiVersion' not in description
assert '{' not in description # no unfilled placeholder
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
@@ -279,9 +265,8 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None
def test_probe_marks_washer_as_recognized(monkeypatch):
"""A washer's probe response (no oneUiVersion) must still resolve via
the modelNum/description fallback so setup doesn't warn about an
unrecognized device type."""
"""A washer reports no oneUiVersion at all -- its consumer-model code
must still resolve so setup doesn't warn about an unrecognized type."""
from custom_components.localthings import config_flow
device0 = [
@@ -298,19 +283,8 @@ def test_probe_marks_washer_as_recognized(monkeypatch):
from custom_components.localthings.registry.batch import parse_device0_batch
resources = parse_device0_batch(device0)
info_resource = resources.get('/information/vs/0', {})
one_ui_version = (
resources.get('/otninformation/vs/0', {}).get('swVersionInfo', {}).get('oneUiVersion', '')
)
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
recognized = bool(
(one_ui_version and for_device(one_ui_version) is not None)
or for_device_by_model(
info_resource.get('x.com.samsung.da.modelNum', ''),
info_resource.get('x.com.samsung.da.description', ''),
) is not None
)
assert recognized is True
from custom_components.localthings.registry.by_type import resolve
assert resolve(resources) is not None
async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
+55
View File
@@ -38,6 +38,61 @@ async def test_diagnostics_shape_and_redaction(
assert resources['/status/lock/vs/0']['x.com.samsung.da.ado.devicecontrol'] == 'On'
async def test_diagnostics_include_ocf_identity(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""/oic/p and /oic/d are outside the /device/0 batch, so diagnostics is
the only place an issue report can carry them -- and `rt` there is OCF's
own device-type declaration."""
from custom_components.localthings.registry.identity import DeviceIdentity
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
raw={
'/oic/p': {'mnmn': 'Samsung Electronics', 'pi': '12-34-56'},
'/oic/d': {'n': 'Family Hub', 'di': 'ab-cd-ef',
'rt': ['oic.wk.d', 'oic.d.refrigerator']},
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
identity = diag['identity']
assert identity['model'] == 'RF9000B'
assert identity['device_types'] == ['oic.wk.d', 'oic.d.refrigerator']
# The raw payloads ride along whole -- we don't yet know which of their
# fields identify a device type, so nothing is dropped up front beyond
# what redaction takes out.
assert identity['resources']['/oic/p']['mnmn'] == 'Samsung Electronics'
assert identity['resources']['/oic/d']['di'] == REDACTED
assert identity['resources']['/oic/p']['pi'] == REDACTED
# The owner-settable device name is redacted; `rt` -- the reason this
# block exists -- is not.
assert identity['resources']['/oic/d']['n'] == REDACTED
assert identity['resources']['/oic/d']['rt'] == ['oic.wk.d', 'oic.d.refrigerator']
async def test_diagnostics_identity_none_when_unavailable(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""read_identity is best-effort: a device that answers neither resource
(or a session that never connected) must not break the download."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['identity'] is None
async def test_diagnostics_include_observe_mode_fields(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
+135 -24
View File
@@ -6,7 +6,7 @@ climate entity itself lives in climate.py (imports homeassistant) and is not
importable here -- consistent with how the other HA platform files are untested.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc, SelectDesc
@@ -24,18 +24,12 @@ def _ac():
def _resolve(name):
"""Mirror the coordinator's detection order: oneUiVersion first, modelNum
fallback second (needed for issue #37's board, which reports neither
oneUiVersion nor a '_PRAC_' modelNum token)."""
"""Mirror the coordinator's detection: board tokens in modelNum."""
resources = _load_device(name)
otn = resources.get('/otninformation/vs/0', {})
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
info = resources['/information/vs/0']
reg = for_device(one_ui) if one_ui else None
if reg is None:
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
@@ -151,9 +145,7 @@ def test_climate_consumed_hrefs_declared_as_coverage():
# ---------------------------------------------------------------------------
def _ac_tp1x():
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion']
return for_device(one_ui), resources
return _resolve('airconditioner_tp1x_da_ac_rac_01011')
def test_tp1x_resolves_to_airconditioner_registry():
@@ -209,10 +201,11 @@ def test_tp2x_rac_20k_no_unbound_hrefs():
assert unbound == []
def test_tp1x_rac_model_resolves_via_one_ui_version():
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion
'7.0 Air conditioner' -- resolved via for_device(), not the modelNum
fallback."""
def test_tp1x_rac_model_resolves_via_rac_token():
"""TP1X_DA-AC-RAC-01001_0000 (issue #38). It self-reports oneUiVersion
'7.0 Air conditioner', which used to be what resolved it; the hyphenated
'-RAC-' board token types it now, so firmware that omits oneUiVersion (the
cool-only variant, issue #91) lands on the same registry."""
reg, _ = _resolve('airconditioner_tp1x_rac')
assert reg is not None and reg.name == 'airconditioner'
@@ -280,17 +273,18 @@ def test_current_limit_is_read_only():
# ---------------------------------------------------------------------------
# TP1X_DA-AC-RAC-01001 cool-only global variant (issue #91). Same modelNum as
# the issue #38 board above, but its /otninformation/vs/0 ships no
# swVersionInfo block, so oneUiVersion is empty and detection must fall back
# to the hyphenated '-RAC-' modelNum token (the older '_RAC_' underscore match
# doesn't fire on this DA-AC-RAC spelling). Adds /stepcontrol/vs/0 and
# swVersionInfo block, so it is typed purely by its 'RAC' modelNum token --
# which the tokenizer reads out of the hyphenated 'DA-AC-RAC' spelling and the
# underscored 'TP2X_RAC_20K' one alike. Adds /stepcontrol/vs/0 and
# /remotedeviceinfo/vs/0 (both ignored) and exposes the WindFree preset via
# the Nano/NanoSleep convenient-mode codes. Its panel light is carried inside
# /mode/vs/0's options blob instead of a dedicated /light/vs/0 switch.
# ---------------------------------------------------------------------------
def test_tp1x_rac_coolonly_resolves_via_hyphenated_model_fallback():
"""Empty oneUiVersion -> resolved by the '-RAC-' modelNum token, not
for_device(). Guards the regression where this unit loaded as 'unknown'."""
def test_tp1x_rac_coolonly_resolves_via_hyphenated_model_token():
"""This unit reports no oneUiVersion at all -- the 'RAC' board token is
the only thing that types it. Guards the regression where it loaded as
'unknown'."""
resources = _load_device('airconditioner_tp1x_rac_coolonly')
otn = resources.get('/otninformation/vs/0', {})
assert otn.get('swVersionInfo', {}).get('oneUiVersion', '') == ''
@@ -473,6 +467,87 @@ def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed():
assert '/runn/vs/0' in airconditioner._AC_IGNORED
# ---------------------------------------------------------------------------
# TP1X_LNX-AC-RAC-01001_0000 -- Lennox-branded heat pump on the Samsung RAC
# board family (issue #173). Routes via the existing '-RAC-' modelNum token,
# same registry as the plain RAC family. Adds two AI-feature resources not
# seen on prior AC dumps: /mds/absencepowersaving/vs/0 (absence-detection
# power saving) and /option/motiondetectwind/stateful/vs/0 (avoid-direct-
# wind-on-motion) -- both exposed read-only, same 'don't guess' precedent as
# CURRENT_LIMIT/ANOMALY_LOAD.
# ---------------------------------------------------------------------------
def _ac_lnx_rac_heatpump():
resources = _load_device('airconditioner_lnx_rac_heatpump')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def test_lnx_rac_heatpump_resolves_to_airconditioner_registry():
reg, _ = _ac_lnx_rac_heatpump()
assert reg is not None and reg.name == 'airconditioner'
def test_lnx_rac_heatpump_no_unbound_hrefs():
reg, resources = _ac_lnx_rac_heatpump()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_lnx_rac_heatpump_absence_power_saving_state():
reg, resources = _ac_lnx_rac_heatpump()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['absence_power_saving_active'] is False
assert state['absence_power_saving_mode'] == 'normal'
def test_lnx_rac_heatpump_motion_detect_wind_state():
reg, resources = _ac_lnx_rac_heatpump()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['motion_detect_wind_active'] is False
assert state['motion_detect_wind_mode'] == 'indirect'
def test_lnx_rac_heatpump_enable_switches_are_writable():
"""status is a bare On/Off boolean, same shape already shipped writable
elsewhere in this file (MUTE_ONCE, AUTO_CLEAN) -- worst case a wrong
token no-ops. The paired mode selects stay read-only sensors."""
absence_keys = {e.key for e in airconditioner.ABSENCE_POWER_SAVING.entities}
motion_keys = {e.key for e in airconditioner.MOTION_DETECT_WIND.entities}
assert absence_keys == {'absence_power_saving_active', 'absence_power_saving_mode'}
assert motion_keys == {'motion_detect_wind_active', 'motion_detect_wind_mode'}
absence_mode = next(e for e in airconditioner.ABSENCE_POWER_SAVING.entities
if e.key == 'absence_power_saving_mode')
motion_mode = next(e for e in airconditioner.MOTION_DETECT_WIND.entities
if e.key == 'motion_detect_wind_mode')
assert not hasattr(absence_mode, 'write_fn') or absence_mode.write_fn is None
assert not hasattr(motion_mode, 'write_fn') or motion_mode.write_fn is None
def test_lnx_rac_heatpump_absence_power_saving_write_target():
write = next(e for e in airconditioner.ABSENCE_POWER_SAVING.entities
if e.key == 'absence_power_saving_active').write_fn
assert write('On', {}) == (
['mds', 'absencepowersaving', 'vs', '0'], {'status': 'On'})
assert write('Off', {}) == (
['mds', 'absencepowersaving', 'vs', '0'], {'status': 'Off'})
def test_lnx_rac_heatpump_motion_detect_wind_write_target():
write = next(e for e in airconditioner.MOTION_DETECT_WIND.entities
if e.key == 'motion_detect_wind_active').write_fn
assert write('On', {}) == (
['option', 'motiondetectwind', 'stateful', 'vs', '0'], {'status': 'On'})
assert write('Off', {}) == (
['option', 'motiondetectwind', 'stateful', 'vs', '0'], {'status': 'Off'})
# ---------------------------------------------------------------------------
# Additive entities layered on the ARTIK051_PRAC family on top of the upstream
# registry: beep (Volume_* option), tropical night mode (Sleep_<N> option),
@@ -564,6 +639,19 @@ def test_tropical_night_write_is_single_token_options_merge():
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_6']})
def test_tropical_night_disabled_by_default():
"""Issue #166: Sleep_0 is present in /mode/vs/0's options on every dump
seen, including the original issue #17 dump this capability was verified
against (see airconditioner_device.json / _ac()) -- yet the #166 reporter
confirmed their unit (also an ARTIK051_PRAC_20K board, per its own
/information/vs/0) has no tropical night mode feature at all. The token
slot's presence proves nothing about the physical feature, so this can't
be existence-gated any tighter than it already is -- registered but
disabled by default instead, same precedent as fridge.rack_count /
cooktop.paired_hood_model."""
assert _tropical_desc().enabled_default is False
def test_tropical_night_absent_when_no_sleep_token():
"""TP1X_DA-AC-WAC (window AC) carries no Sleep_ option -- tropical night
mode must not bind."""
@@ -676,6 +764,29 @@ def test_air_quality_sensors_from_sensors_vs_items():
assert state2['clean_level'] == 1
def test_air_quality_disabled_by_default():
"""Issue #166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K): /sensors/vs/0
lists all five item types with permanent zero values on both its units --
the exact same shape as the WindFree/issue #17 dumps this capability was
first verified against -- yet the reporter confirmed none of these
sensors are physically present on their model.
A tighter exists_fn (requiring a corroborating top-level
x.com.samsung.da.cleanLevel scalar) was tried and reverted: it looked
like a real signal against this repo's AC fixtures, but
air_purifier_device.json, air_purifier_vtww_device.json, and
range_hood_device.json all carry genuinely populated Dust/FineDust/
SuperFineDust readings with no such scalar, so requiring it would
silently drop real readings on hardware this repo hasn't seen yet on an
AC. These stay bound whenever the item type is listed (see
_has_sensor_type) and disabled by default instead, same precedent as
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
units that do have the sensor can enable it themselves."""
for key in ('clean_level', 'odor', 'dust', 'fine_dust', 'super_fine_dust'):
desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key)
assert desc.enabled_default is False, key
def test_air_quality_absent_when_no_sensor_items():
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
RAC variant) binds no air-quality entities -- exists_fn gates each on its
+212 -87
View File
@@ -1,88 +1,28 @@
"""Tests for samsung_appliance/registry/by_type."""
import pytest
from custom_components.localthings.registry.by_type import _type_key, for_device, DeviceRegistry
class TestTypeKey:
"""Tests for _type_key() function."""
def test_type_key_strips_version_prefix(self):
"""'7.0 Dishwasher' -> 'dishwasher'"""
assert _type_key("7.0 Dishwasher") == "dishwasher"
def test_type_key_preserves_spaces_as_underscores(self):
"""'7.0 French Door Refrigerator' -> 'french_door_refrigerator'"""
assert _type_key("7.0 French Door Refrigerator") == "french_door_refrigerator"
def test_type_key_no_space_returns_lowercase(self):
"""'Oven' -> 'oven' (no space in string)"""
assert _type_key("Oven") == "oven"
class TestForDevice:
"""Tests for for_device() function."""
def test_for_device_returns_dishwasher_registry(self):
"""for_device("7.0 Dishwasher") returns a non-None DeviceRegistry."""
registry = for_device("7.0 Dishwasher")
assert registry is not None
assert isinstance(registry, DeviceRegistry)
assert registry.name == "dishwasher"
def test_for_device_unknown_returns_none(self):
"""for_device("7.0 Toaster") returns None for unknown device type."""
registry = for_device("7.0 Toaster")
assert registry is None
def test_for_device_suffix_fallback(self):
"""for_device("7.0 French Door Refrigerator") resolves via suffix fallback."""
registry = for_device("7.0 French Door Refrigerator")
assert registry is not None
assert isinstance(registry, DeviceRegistry)
assert registry.name == 'refrigerator'
def test_for_device_returns_cooktop_registry(self):
"""The registry's own .name is 'gas_cooktop' (disambiguated from
induction_cooktop), but the lookup key devices route through stays
'cooktop' -- oneUiVersion "Cooktop" still resolves here."""
registry = for_device('7.0 Cooktop')
assert registry is not None
assert registry.name == 'gas_cooktop'
def test_for_device_returns_range_hood_registry(self):
registry = for_device('7.0 Range Hood')
assert registry is not None
assert registry.name == 'range_hood'
from custom_components.localthings.registry.by_type import (
DeviceRegistry, _REGISTRY_BY_KEY,
)
class TestDeviceRegistries:
"""Tests for device registries themselves."""
def test_dishwasher_registry_has_no_dup_hrefs(self):
"""All caps in dishwasher registry have unique hrefs (or meet disambiguation rule)."""
registry = for_device("7.0 Dishwasher")
assert registry is not None
def test_every_key_maps_to_a_device_registry(self):
for key, registry in _REGISTRY_BY_KEY.items():
assert isinstance(registry, DeviceRegistry), key
# Each href should map to exactly one cap (or multiple with rt_filter/match_fn)
for href, caps in registry.capabilities.items():
if len(caps) > 1:
# If multiple caps share an href, all must have rt_filter or match_fn
for cap in caps:
assert cap.rt_filter is not None or cap.match_fn is not None, \
f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn"
def test_refrigerator_registry_has_no_dup_hrefs(self):
"""All caps in refrigerator registry have unique hrefs (or meet disambiguation rule)."""
registry = for_device("7.0 Refrigerator")
assert registry is not None
# Each href should map to exactly one cap (or multiple with rt_filter/match_fn)
for href, caps in registry.capabilities.items():
if len(caps) > 1:
# If multiple caps share an href, all must have rt_filter or match_fn
for cap in caps:
assert cap.rt_filter is not None or cap.match_fn is not None, \
f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn"
def test_no_registry_has_ambiguous_hrefs(self):
"""An href carrying more than one capability needs every one of them
to declare a discriminator, or discovery would bind both."""
for key, registry in _REGISTRY_BY_KEY.items():
for href, caps in registry.capabilities.items():
if len(caps) > 1:
for cap in caps:
assert cap.rt_filter is not None or cap.match_fn is not None, (
f"{key}: href {href!r} has multiple caps but {cap!r} "
f"lacks rt_filter and match_fn"
)
class TestWasherRegistry:
@@ -114,23 +54,60 @@ class TestWasherRegistry:
assert href in registry.capabilities, f"{href} missing from washer registry"
class TestModelNumSegments:
def test_splits_pipe_prefix_on_underscore(self):
from custom_components.localthings.registry.by_type import _model_num_segments
assert _model_num_segments('ARTIK051_DONGLE_REF|00127641|000800200014') == [
class TestBoardTokens:
def test_splits_pipe_prefix_into_whole_tokens(self):
from custom_components.localthings.registry.by_type import _board_tokens
assert _board_tokens('ARTIK051_DONGLE_REF|00127641|000800200014', '|') == [
'ARTIK051', 'DONGLE', 'REF',
]
def test_ignores_everything_after_first_pipe(self):
from custom_components.localthings.registry.by_type import _model_num_segments
assert _model_num_segments('TP2X_RAC_20K|abc|REF_should_not_appear') == [
def test_ignores_everything_after_the_cut(self):
from custom_components.localthings.registry.by_type import _board_tokens
assert _board_tokens('TP2X_RAC_20K|abc|REF_should_not_appear', '|') == [
'TP2X', 'RAC', '20K',
]
def test_both_delimiters_produce_the_same_tokens(self):
"""The whole point of tokenizing: Samsung spells one board family
with either delimiter, and both must reduce to the same tokens."""
from custom_components.localthings.registry.by_type import _board_tokens
assert (_board_tokens('TP1X_DA-AC-RAC-01001_0000', '|')
== _board_tokens('TP1X_DA_AC_RAC_01001_0000', '|')
== ['TP1X', 'DA', 'AC', 'RAC', '01001', '0000'])
def test_upper_cases_and_drops_empty_runs(self):
from custom_components.localthings.registry.by_type import _board_tokens
assert _board_tokens('a--b__c', '|') == ['A', 'B', 'C']
def test_empty_for_none_or_empty_input(self):
from custom_components.localthings.registry.by_type import _model_num_segments
assert _model_num_segments('') == ['']
assert _model_num_segments(None) == ['']
from custom_components.localthings.registry.by_type import _board_tokens
assert _board_tokens('', '|') == []
assert _board_tokens(None, '|') == []
class TestBoardTokenTable:
def test_no_board_family_token_shadows_a_specific_type(self):
"""'DA-AC-' prefixes RAC/WAC/DHM/AIR alike -- a bare 'AC' entry would
type the dehumidifier and the air purifier as air conditioners."""
from custom_components.localthings.registry.by_type import _BOARD_TOKEN_TO_KEY
for family_token in ('AC', 'DA', 'KS', 'WM', 'TP1X', 'TP2X', 'ARTIK051'):
assert family_token not in _BOARD_TOKEN_TO_KEY
def test_every_token_resolves_to_a_real_registry(self):
from custom_components.localthings.registry.by_type import (
_BOARD_TOKEN_TO_KEY, _CONSUMER_PREFIX_TO_KEY, _REGISTRY_BY_KEY,
)
for token, key in _BOARD_TOKEN_TO_KEY.items():
assert key in _REGISTRY_BY_KEY, f"{token!r} -> unknown registry {key!r}"
for prefix, key in _CONSUMER_PREFIX_TO_KEY.items():
assert key in _REGISTRY_BY_KEY, f"{prefix!r} -> unknown registry {key!r}"
def test_tokens_are_upper_case(self):
"""`_board_tokens` upper-cases before lookup, so a lower-case entry
would be dead."""
from custom_components.localthings.registry.by_type import _BOARD_TOKEN_TO_KEY
for token in _BOARD_TOKEN_TO_KEY:
assert token == token.upper()
class TestConsumerModelKey:
@@ -452,6 +429,136 @@ class TestForDeviceByModel:
from custom_components.localthings.registry.by_type import for_device_by_model
assert for_device_by_model('', '') is None
@pytest.mark.parametrize('model_num', [
'TP1X_DA-AC-RAC-01001_0000', # hyphenated (issue #91)
'TP1X_DA_AC_RAC_01001_0000', # underscored
'TP2X_RAC_20K', # bare, no board-family prefix (issue #37)
'TP2X-RAC-20K',
])
def test_delimiter_spelling_does_not_change_the_answer(self, model_num):
"""One board family, four spellings, one table entry."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(model_num, '')
assert reg is not None
assert reg.name == 'airconditioner'
def test_model_num_wins_when_the_two_fields_disagree(self):
"""The legacy gas cooktop is the one known device whose fields
conflict: modelNum says CT (gas), description says COOKTOP (which
otherwise means induction). The board is right, so modelNum is
consulted first."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('ARTIK051_GB_CT_001', 'ARTIK051_GLOBAL_COOKTOP')
assert reg is not None
assert reg.name == 'gas_cooktop'
def test_board_token_in_description_used_when_model_num_has_none(self):
"""Some units report a placeholder modelNum and carry the board token
only in `description`."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('TEST-MODEL', 'TP1X_REF_21K')
assert reg is not None
assert reg.name == 'refrigerator'
def test_board_token_beats_consumer_prefix(self):
"""'WAC' (window AC, issue #87) starts with 'WA' (top-load washer,
issue #106). The board table runs first, so the AC wins."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('TP1X_DA_AC_WAC_01001_0000', 'TP1X_DA_AC_WAC_01001_0000')
assert reg is not None
assert reg.name == 'airconditioner'
class TestBoardTokenAmbiguity:
"""`_board_family_key` returns the first matching token, which is only
safe while no real model string contains two tokens naming different
device types. Guard that against every dump we have."""
def test_no_fixture_model_string_yields_two_conflicting_keys(self, all_device_fixtures):
from custom_components.localthings.registry.by_type import (
_BOARD_TOKEN_TO_KEY, _board_tokens,
)
for name, resources in all_device_fixtures.items():
info = resources.get('/information/vs/0', {})
for field, cut in (
(info.get('x.com.samsung.da.modelNum', ''), '|'),
(info.get('x.com.samsung.da.description', ''), '/'),
):
keys = {
_BOARD_TOKEN_TO_KEY[t]
for t in _board_tokens(field, cut)
if t in _BOARD_TOKEN_TO_KEY
}
assert len(keys) <= 1, (
f"{name}: {field!r} matches conflicting board tokens {keys}"
)
class TestOneUiVersionIsNotConsulted:
"""oneUiVersion used to be the first detection stage. It named the type
directly ('7.0 Dishwasher'), but only a minority of hardware reports it,
every device that does is already typed by its modelNum board token, and
no device-support issue was ever fixed by adding a mapping for it. It is
still reported in diagnostics as a firmware-generation marker."""
def test_resolve_ignores_a_recognizable_one_ui_version(self):
from custom_components.localthings.registry.by_type import resolve
resources = {
'/otninformation/vs/0': {'swVersionInfo': {'oneUiVersion': '7.0 Dishwasher'}},
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'SOME-UNKNOWN-BOARD',
'x.com.samsung.da.description': 'SOME-UNKNOWN-BOARD',
},
}
assert resolve(resources) is None
def test_resolve_types_every_one_ui_reporting_fixture_without_it(
self, all_device_fixtures
):
"""The claim above, checked: for every dump that reports a
oneUiVersion, the model strings alone reach a registry."""
from custom_components.localthings.registry.by_type import resolve
seen = 0
for name, resources in all_device_fixtures.items():
one_ui = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {}).get('oneUiVersion', ''))
if not one_ui:
continue
seen += 1
assert resolve(resources) is not None, (
f"{name} reports oneUiVersion {one_ui!r} and nothing else types it"
)
assert seen, "no fixture reports oneUiVersion -- has the corpus changed?"
class TestResolve:
def test_prefers_model_strings_over_resource_signature(self, all_device_fixtures):
"""Every fixture with usable model strings resolves the same way
through `resolve` as through `for_device_by_model` directly."""
from custom_components.localthings.registry.by_type import (
resolve, for_device_by_model,
)
for name, resources in all_device_fixtures.items():
info = resources.get('/information/vs/0', {})
by_model = for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if by_model is not None:
assert resolve(resources) is by_model, name
def test_falls_back_to_resource_signature(self, all_device_fixtures):
"""The three dumps with no /information/vs/0 still type."""
from custom_components.localthings.registry.by_type import resolve
for name in ('cooktop', 'range_ne63a6511', 'range_no_info'):
resources = all_device_fixtures[name]
assert '/information/vs/0' not in resources, name
assert resolve(resources) is not None, name
def test_returns_none_for_an_unrecognizable_dump(self):
from custom_components.localthings.registry.by_type import resolve
assert resolve({'/some/unknown/vs/0': {}}) is None
class TestForDeviceByResources:
def test_na9300k_without_one_ui_or_information_is_cooktop(self):
@@ -529,3 +636,21 @@ class TestForDeviceByResources:
},
}
assert for_device_by_resources(resources) is None
def test_microwave_without_information_is_microwave(self):
"""Issue #172: Samsung Microwave (ME8000T-/AA0) has no /information/vs/0
resource and empty oneUiVersion; 'MicroWave' in supportedModes alongside
/oven/vs/0 must route to the microwave registry."""
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['MicroWave', 'Autocook', 'Convection'],
'x.com.samsung.da.options': ['DeviceType_ME8000T-/AA0', 'Lamp_Off'],
},
'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'},
'/hood/fanspeed/vs/0': {'x.com.samsung.da.hood.fanSpeed': '0'},
}
reg = for_device_by_resources(resources)
assert reg is not None
assert reg.name == 'microwave'
+8 -8
View File
@@ -1,23 +1,23 @@
import pytest
from tests.conftest import _load_device
from custom_components.localthings.registry.by_type import for_device, _type_key
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.adapter import flatten
@pytest.mark.parametrize('name,expected_type_key', [
@pytest.mark.parametrize('name,expected_type', [
('dishwasher', 'dishwasher'),
('refrigerator', 'refrigerator'),
])
def test_full_pipeline_v2(name, expected_type_key):
def test_full_pipeline_v2(name, expected_type):
resources = _load_device(name)
otn = resources.get('/otninformation/vs/0', {})
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
assert _type_key(one_ui) == expected_type_key
reg = for_device(one_ui)
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 == expected_type
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
assert bound
+26 -16
View File
@@ -7,22 +7,10 @@ GOLDEN = Path(__file__).parent / 'fixtures' / 'golden'
def _new_state_keys(name, resources):
from custom_components.localthings.registry.by_type import (
for_device, for_device_by_model, for_device_by_resources,
)
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.adapter import flatten
otn = resources.get('/otninformation/vs/0', {})
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
info = resources.get('/information/vs/0', {})
reg = for_device(one_ui) if one_ui else None
if reg is None:
reg = for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if reg is None:
reg = for_device_by_resources(resources)
reg = resolve(resources)
if reg is None:
from custom_components.localthings.registry.registry import CAPABILITIES
caps, pats = CAPABILITIES, []
@@ -422,7 +410,7 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
def test_registry_reproduces_golden_state_keys_for_tp1x_rac_coolonly():
"""TP1X_DA-AC-RAC-01001 cool-only global variant (issue #91) whose
/otninformation/vs/0 ships no swVersionInfo block -- resolves via the
hyphenated '-RAC-' modelNum fallback rather than for_device()."""
'RAC' board token in its modelNum."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_rac_coolonly')
golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac_coolonly.json').read_text())
@@ -451,6 +439,27 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree():
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_lnx_rac_heatpump():
"""Lennox-branded heat pump on the Samsung RAC board family (issue #173,
modelNum TP1X_LNX-AC-RAC-01001_0000) -- routes via the existing '-RAC-'
token, same registry as the plain RAC family. Adds two AI-feature
resources not seen on prior AC dumps: /mds/absencepowersaving/vs/0
(absence-detection power saving) and /option/motiondetectwind/stateful/vs/0
(avoid-direct-wind-on-motion), both exposed read-only per the 'don't
guess' rule."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_lnx_rac_heatpump')
golden = json.loads(
(GOLDEN / 'airconditioner_lnx_rac_heatpump.json').read_text()
)
state_keys = _new_state_keys('airconditioner_lnx_rac_heatpump', 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_registry_reproduces_golden_state_keys_for_range():
"""Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) --
reports no oneUiVersion; resolved via the '-RANGE-' modelNum token
@@ -692,7 +701,8 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d_lamp_high()
def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air():
"""TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion
'7.0 Air purifier' and resolves via for_device() onto the existing
'7.0 Air purifier' (unused for routing) and resolves via its 'AIR'
board token onto the existing
air_purifier registry (shared with the older ARTIK051_TVTL family via
per-href match_fn discrimination -- see capabilities/air_purifier.py).
Its /mode/vs/0 reports modes/supportedModes directly (Smart/Max/Mid/
+38
View File
@@ -31,3 +31,41 @@ def test_read_identity_tolerates_missing_resources():
assert ident.manufacturer == 'Samsung'
assert ident.model == ''
assert ident.serial is None
assert ident.device_types == ()
assert ident.raw == {'/oic/p': {}, '/oic/d': {}}
def test_read_identity_captures_oic_d_device_types():
"""/oic/d's `rt` is OCF's own device-type declaration -- captured so
diagnostics can show whether real hardware populates it usefully."""
sess = FakeSession({
('oic', 'd'): {
'n': 'Living Room AC',
'rt': ['oic.wk.d', 'oic.d.airconditioner'],
},
})
ident = read_identity(sess, serial=None)
assert ident.device_types == ('oic.wk.d', 'oic.d.airconditioner')
def test_read_identity_normalizes_scalar_and_malformed_rt():
"""Firmware that reports a bare string, or a non-list, must not explode."""
assert read_identity(
FakeSession({('oic', 'd'): {'rt': 'oic.d.refrigerator'}}), None
).device_types == ('oic.d.refrigerator',)
assert read_identity(
FakeSession({('oic', 'd'): {'rt': 42}}), None
).device_types == ()
assert read_identity(
FakeSession({('oic', 'd'): {'rt': ['oic.wk.d', 7, None]}}), None
).device_types == ('oic.wk.d',)
def test_read_identity_keeps_raw_payloads_for_diagnostics():
sess = FakeSession({
('oic', 'p'): {'mnmn': 'Samsung Electronics', 'mnmo': 'RF9000B'},
('oic', 'd'): {'n': 'Family Hub', 'di': 'abc-123'},
})
ident = read_identity(sess, serial=None)
assert ident.raw['/oic/p']['mnmo'] == 'RF9000B'
assert ident.raw['/oic/d']['di'] == 'abc-123'
+66
View File
@@ -217,3 +217,69 @@ def test_lamp_reads_any_non_off_level_as_true():
just a literal 'On'."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
assert desc.value_fn(['Lamp_High']) is True
# ---------------------------------------------------------------------------
# MICROWAVE_MODE — filter_remind/remind_beep options-array writes (issue #181)
# ---------------------------------------------------------------------------
def test_filter_remind_gated_absent_when_no_option():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind')
rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']}
assert desc.exists_fn(rep, {}) is False
def test_filter_remind_gated_present_when_option_reported():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind')
rep = {'x.com.samsung.da.options': ['FilterRemind_Off']}
assert desc.exists_fn(rep, {}) is True
def test_filter_remind_reads_on_off():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind')
assert desc.value_fn(['FilterRemind_On']) is True
assert desc.value_fn(['FilterRemind_Off']) is False
def test_filter_remind_write_is_single_token():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind')
rep = {'x.com.samsung.da.options': ['FilterRemind_Off']}
path, body = desc.write_fn('On', rep)
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['FilterRemind_On']}
def test_filter_remind_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind')
assert desc.write_fn('On', {}) is None
def test_remind_beep_gated_absent_when_no_option():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep')
rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']}
assert desc.exists_fn(rep, {}) is False
def test_remind_beep_gated_present_when_option_reported():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep')
rep = {'x.com.samsung.da.options': ['RemindBeep_On']}
assert desc.exists_fn(rep, {}) is True
def test_remind_beep_reads_on_off():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep')
assert desc.value_fn(['RemindBeep_On']) is True
assert desc.value_fn(['RemindBeep_Off']) is False
def test_remind_beep_write_is_single_token():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep')
rep = {'x.com.samsung.da.options': ['RemindBeep_On']}
path, body = desc.write_fn('Off', rep)
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['RemindBeep_Off']}
def test_remind_beep_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep')
assert desc.write_fn('On', {}) is None
+35
View File
@@ -72,3 +72,38 @@ def test_redact_resources_does_not_mutate_input():
redact_resources(resources)
assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == original_serial
def test_redacts_bare_ocf_identity_keys():
"""/oic/d and /oic/p identify the unit with two-letter keys ('di', 'pi')
that the substring rules can't see."""
redacted = redact_resources({
'/oic/d': {'di': 'ab-cd-ef', 'n': "Marc's Fridge",
'rt': ['oic.wk.d', 'oic.d.refrigerator']},
'/oic/p': {'pi': '12-34-56', 'mnmo': 'RF9000B'},
})
assert redacted['/oic/d']['di'] == REDACTED
assert redacted['/oic/p']['pi'] == REDACTED
# 'n' is free text the owner sets from the SmartThings app, so it can
# carry a person's name -- redacted too. `rt`, the device-type signal
# we actually want out of /oic/d, is not.
assert redacted['/oic/d']['n'] == REDACTED
assert redacted['/oic/d']['rt'] == ['oic.wk.d', 'oic.d.refrigerator']
assert redacted['/oic/p']['mnmo'] == 'RF9000B'
def test_bare_key_redaction_does_not_leak_into_substring_matching():
"""The bare keys are whole-key matches only -- plenty of ordinary
appliance fields contain those letters and must survive untouched."""
redacted = redact_resources({
'/x': {
'condition': 'Normal', 'display': 'On', 'dispenser': 'Cubed',
'humidity': '45', 'spinSpeed': '1200', 'name': 'FilterProgress',
},
})
assert redacted['/x'] == {
'condition': 'Normal', 'display': 'On', 'dispenser': 'Cubed',
'humidity': '45', 'spinSpeed': '1200', 'name': 'FilterProgress',
}