Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b67dda5dc | ||
|
|
0c8d39fb73 | ||
|
|
03958dd994 | ||
|
|
a7aea3764b | ||
|
|
a6eb1c62d4 | ||
|
|
90d79f117a | ||
|
|
2b24152276 | ||
|
|
6f21699c37 | ||
|
|
40c7033b1b | ||
|
|
c9620b47b3 | ||
|
|
cafd7d5afa | ||
|
|
a863df1e59 | ||
|
|
288ba02cc5 | ||
|
|
668aec401a | ||
|
|
1f331f3aa6 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,23 @@ 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, /oic/d, and /oic/res 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; /oic/res is OCF's
|
||||
# discovery endpoint, listing every href/Collection the connection
|
||||
# hosts -- relevant to the "Composite Device" model (issue #177) where
|
||||
# a single physical unit exposes more than one logical Device. 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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -677,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-
|
||||
|
||||
@@ -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)."""
|
||||
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
|
||||
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,12 +28,62 @@ def _get(sess, path) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_links(sess, path) -> list:
|
||||
"""Like _get, but for /oic/res: a baseline-Interface RETRIEVE on it
|
||||
returns a CBOR array of Link objects (href/rt/if/di/...), not a single
|
||||
Property map."""
|
||||
try:
|
||||
code, pl = sess.get(path, timeout=10.0)
|
||||
if code == 0x45 and pl:
|
||||
body = cbor2.loads(pl)
|
||||
return body if isinstance(body, list) else []
|
||||
except Exception:
|
||||
pass
|
||||
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'])
|
||||
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
|
||||
# RETRIEVE on it returns every Resource/Collection href this endpoint
|
||||
# hosts, not just the one /device/0 seed path the coordinator polls.
|
||||
# Relevant for the OCF "Composite Device" model (issue #177: a single
|
||||
# physical unit -- one IP, one /oic/p -- exposing more than one logical
|
||||
# Device, each as its own Collection resource, same rt shape as our own
|
||||
# /device/0). Nothing consumes this yet; captured so a report from a
|
||||
# multi-unit device shows us whether its firmware actually implements
|
||||
# that model before any code assumes it does.
|
||||
res = _get_links(sess, ['oic', 'res'])
|
||||
return DeviceIdentity(
|
||||
manufacturer=p.get('mnmn') or 'Samsung',
|
||||
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, '/oic/res': res},
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
[project]
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -38,6 +38,103 @@ 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_include_oic_res_links(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""/oic/res -- OCF's resource-discovery endpoint -- rides along in
|
||||
identity.raw the same way /oic/p and /oic/d do. Its response is a list
|
||||
of Link objects (one per href/Collection the endpoint hosts), not a
|
||||
single Property map, so redaction has to walk into the list too."""
|
||||
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'},
|
||||
'/oic/d': {'rt': ['oic.wk.d', 'oic.d.refrigerator']},
|
||||
'/oic/res': [
|
||||
{'di': 'aaaa-1111', 'href': '/device/0',
|
||||
'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'di': 'bbbb-2222', 'href': '/device/1',
|
||||
'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
|
||||
|
||||
links = diag['identity']['resources']['/oic/res']
|
||||
assert len(links) == 2
|
||||
assert links[0]['di'] == REDACTED
|
||||
assert links[0]['href'] == '/device/0'
|
||||
assert links[1]['di'] == REDACTED
|
||||
assert links[1]['href'] == '/device/1'
|
||||
assert links[1]['rt'] == ['x.com.samsung.devcol', 'oic.wk.col']
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
+212
-87
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -31,3 +31,71 @@ 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': {}, '/oic/res': []}
|
||||
|
||||
|
||||
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'
|
||||
|
||||
|
||||
def test_read_identity_captures_oic_res_links():
|
||||
"""/oic/res is OCF's discovery endpoint: a baseline RETRIEVE returns an
|
||||
array of Link objects, one per Resource/Collection the endpoint hosts --
|
||||
including, per the OCF 'Composite Device' model, a second logical
|
||||
Device's Collection on a multi-unit system (issue #177). Nothing routes
|
||||
on this yet; captured so a report from such a device shows us whether
|
||||
its firmware actually exposes more than the one /device/0 seed href."""
|
||||
sess = FakeSession({
|
||||
('oic', 'res'): [
|
||||
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
],
|
||||
})
|
||||
ident = read_identity(sess, serial=None)
|
||||
assert ident.raw['/oic/res'] == [
|
||||
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
]
|
||||
|
||||
|
||||
def test_read_identity_tolerates_malformed_oic_res():
|
||||
"""A single Property map instead of an array (or anything else
|
||||
non-list-shaped) must not explode -- same defensive posture as
|
||||
_device_types' handling of a malformed /oic/d rt."""
|
||||
ident = read_identity(
|
||||
FakeSession({('oic', 'res'): {'not': 'a list'}}), serial=None
|
||||
)
|
||||
assert ident.raw['/oic/res'] == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user