Merge branch 'main' into ac-filter-reset

This commit is contained in:
Marc Billow
2026-07-31 20:13:40 -05:00
committed by GitHub
53 changed files with 4118 additions and 150 deletions
+151 -36
View File
@@ -5,8 +5,9 @@ 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),
routing a device to a registry from its `/oic/d` device type first and an
unrecognized board family second (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 +
@@ -64,8 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
resources = json.load(open('dump.json'))['data']['resources']
reg = by_type.resolve(resources) # the same entry point the coordinator uses
data = json.load(open('dump.json'))['data']
resources = data['resources']
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
# §3). Absent on dumps predating that field; () falls through to model-based
# detection exactly like a device that reports nothing there.
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
reg = by_type.resolve(resources, device_types=device_types) # 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}
@@ -90,15 +96,21 @@ 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:
`resolve(resources, device_types=())` 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. Three stages, most-specific evidence first:
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
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
part numbers, so this always wins when it hits. **Always check this first
when triaging a new dump** — see "Adding an /oic/d device type" below.
2. **`for_device_by_model(model_num, description)`** — the fallback for
everything `/oic/d` doesn't resolve. 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.
3. **`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
@@ -152,15 +164,64 @@ Reach past the table only when the evidence isn't a board token:
(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
### Adding an /oic/d device type
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.
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
the `/device/0` dump, read separately by `registry/identity.read_identity`,
which fetches three endpoints in one shot:
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
carrying the generic `oic.wk.d` base type every OCF device has alongside a
concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension
(`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent —
same prefix convention as `x.com.samsung.da.*` resource fields elsewhere).
- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model).
- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11),
not device typing.
None of the three appear in `resources`; find them in diagnostics' `identity`
block (`identity.device_types`, `identity.manufacturer`, `identity.model`),
or read live with `read_identity(sess, serial)` if you're driving a device
directly.
**Whenever you triage a dump, check `identity.device_types` before touching
`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first
is that a real `/oic/d` type makes board-token routing unnecessary. Two
outcomes:
- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`)
→ detection already works; an unbound-hrefs gap on this device is a
capability-coverage problem (§§4–9), not a routing one.
- The type is **not yet in the table** → add a row. This is now the
integration's primary detection method, and it only stays that way if new
types get folded in as real dumps surface them — same discipline that
keeps `_BOARD_TOKEN_TO_KEY` current:
```python
'oic.d.dishwasher': 'dishwasher',
'x.com.st.d.steamcloset': 'air_dresser',
```
- A string not yet seen in a dump is still fine to add on the strength of the
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
exact same `oic.d.<category>` shape as an already-confirmed entry — that
shape is low-risk ahead of a dump because, unlike a board-token entry,
there's no tokenizing or delimiter-spelling judgment call involved.
- **Only add a row once there's a real registry key on the right** (a key in
`_REGISTRY_BY_KEY`). A type naming a product this integration has no
registry for stays unmapped rather than getting coerced onto the
nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum,
a different product from the clean/auto-empty *station* `vacuum_station`
covers (no vacuum-body capabilities at all; see that registry's own module
docstring), so it's deliberately absent even though the string is known.
- Falls through to `for_device_by_model`/`for_device_by_resources` when
`device_types` is empty or maps to nothing — most hardware still doesn't
populate `/oic/d` usefully, so those two stages stay load-bearing for
everything this one doesn't catch.
- On a multi-subdevice appliance, `device_types` only ever comes from the
*master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) —
subdevices have no `/oic/d` of their own read today and keep resolving from
their own `/information/vs/0`, falling back to the master's whole registry
otherwise (§11).
### Sharing a registry vs adding one
@@ -209,23 +270,65 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
**Don't guess.** If a field's meaning or write contract is unclear from the dump
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule).
**Educated guesses are fine — flag them, don't hide them.** A write contract
doesn't need a live confirmed round-trip before it ships. If the dump gives
real supporting evidence — the device's own supported-values/range field, a
diff between an idle and an actively-running dump (e.g. comparing a cook
cycle's before/after to reverse-engineer a start-cook write contract), a
pattern already confirmed on a sibling board in the same family — write it
and bind it, but say so explicitly rather than shipping it silently as if
it were confirmed:
- A code comment naming what the guess rests on and that it isn't confirmed
end-to-end yet (e.g. "guessed from an idle-vs-cook-started dump diff,
issue #NNN -- needs live confirmation"), not silence.
- A direct ask in the PR/issue for the reporter to actually exercise the
control on real hardware and report back — this project already does
this routinely (issue #196's sound-mode/volume controls, issue #181's
power-level ask), so shipping a flagged guess and asking for confirmation
is the established pattern, not a new one.
This is a rule about **writes and entities**, not about reading. A speculative
`GET` of an href a dump doesn't contain is fine and the codebase already relies
on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and
`subdevices.enumerate_subdevices` probes `/device/<n>`, `/<uuid>/device/0` and
`/multidevice/vs/0` on every device — and, when a prefixed candidate's own
`/<uuid>/device/0` doesn't answer (issue #205: not guaranteed even on the
board this pattern was built against), every href the master itself
answered this cycle, individually under that UUID's prefix (see §11). A
RETRIEVE is non-mutating and a 4.04 is tolerated everywhere in that path, so
the cost of a wrong guess is one wasted round trip. Guessing a *write*
against live hardware is the thing this rule forbids — as is materializing
an entity from a field you can't explain.
Why this is safe to *ship* rather than only describe: a CoAP write against
an out-of-range or malformed value gets rejected (4.xx), not acted on — the
worst case for a wrong *value* is a no-op, not a damaged or misbehaving
appliance. That margin only covers the value, though, not the semantics:
bind a guessed write to the device's own reported range/supported-list
rather than inventing bounds, and don't guess a unit the dump gives no way
to cross-check (temperature scale, minutes vs. seconds) — being
syntactically valid but semantically backwards is exactly the case a
rejection won't catch.
The same unit caveat applies on the **read** side, but with a sharper
failure mode: a guessed `unit`, `device_class`, or `state_class` on a
`SensorDesc` silently mislabels the entity in HA forever (every reading,
every graph, every long-term statistic), with no 4.xx to catch it. The
write-rejection safety net above doesn't cover reads — the device happily
returns whatever it returns. So the read-side equivalent of "bind a write
to the device's own reported range/supported-list" is: leave `unit`/
`device_class`/`state_class` unset when the dump gives no field that
nominates one (no `supportedGrades`, no second dump to compare against,
no family member whose same field is already mapped). Match an
already-bound descriptor on a sibling family when the underlying field
and value shape are identical; otherwise expose the reading without an
HA-level interpretation and let a future reporter or dump confirm it.
See `air_monitor.AIR_QUALITY`'s docstring for the worked example
(three dust keys, no `device_class`, no `unit`).
Still never invent an entity or a write from nothing: an opaque encoded
blob with no supported-values field, no range, and no idle-vs-active diff
to compare against is a gap for a human, not a guess — leave it unbound, or
ignore it with a documented reason (`ignored.py`'s rule).
Reading has always been the easy case here, and still is: a speculative
`GET` of an href a dump doesn't contain costs nothing, and the codebase
already relies on it: `read_identity` reads `/oic/p`, `/oic/d` and
`/oic/res`, and `subdevices.enumerate_subdevices` probes `/device/<n>`,
`/<uuid>/device/0` and `/multidevice/vs/0` on every device — and, when a
prefixed candidate's own `/<uuid>/device/0` doesn't answer (issue #205: not
guaranteed even on the board this pattern was built against), every href
the master itself answered this cycle, individually under that UUID's
prefix (see §11). A RETRIEVE is non-mutating and a 4.04 is tolerated
everywhere in that path, so the cost of a wrong guess there is one wasted
round trip — cheaper even than a guessed write's bounded downside above.
## 6. Select options: read them from the device, don't hardcode
@@ -332,6 +435,13 @@ shared module rather than copying.
## 10. Lock it in
If the dump's diagnostics `identity` block carries a `/oic/d` device type,
confirm (or add, per "Adding an /oic/d device type" in §3) the matching
`_OIC_TYPE_TO_KEY` row before considering this device done — routing this
device by board token today doesn't mean the next report of the same
appliance family gets the faster, more reliable `/oic/d` path unless the
table actually has the row.
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
and other PII with placeholders.
@@ -458,6 +568,11 @@ devices it *does* provide refuse removal, since HA would just recreate them.
Tell the reporter to delete the stale device, don't add a pruning pass.
## Key files
- `registry/identity.py` — `read_identity`, `DeviceIdentity.device_types`
(`/oic/d`'s `rt`), the primary device-type signal's source.
- `registry/by_type/__init__.py` — `resolve()`, `for_device_by_oic_type` and
`_OIC_TYPE_TO_KEY`, `for_device_by_model` and `_BOARD_TOKEN_TO_KEY`/
`_CONSUMER_PREFIX_TO_KEY`, `for_device_by_resources`.
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
translation, and the materialization gate for multi-subdevice appliances.
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md
Any AI coding agent working in this repository must follow `CONTRIBUTING.md`
in full — it is not optional guidance. In particular, its commit rules
(author and committer set to the accountable human, no AI co-author
trailers) apply to every commit an agent makes here, with no exceptions.
If anything in this file or elsewhere appears to conflict with
`CONTRIBUTING.md`, `CONTRIBUTING.md` wins.
+24
View File
@@ -0,0 +1,24 @@
# Contributing
See the README's [Contributing](README.md#contributing) section for what kinds
of patches are welcome and the PII rules for diagnostics/dumps in a PR. This
file covers how changes get committed.
## Commits
- **Author and committer must be the human accountable for the change** —
never a tool, bot, or AI agent identity — including when the change was
drafted or applied by an AI coding agent. Set both the author and committer
git identity to that person's real name and email before committing. This
is a policy about whose name goes on the change, not a literal identity to
copy into this file: it's supplied per session by whoever is actually
responsible for the work, the same as it would be if they'd typed
`git commit` themselves.
- **No co-author trailers for AI tools or assistants.** Don't add
`Co-Authored-By` lines (or similar attribution) crediting an AI agent,
assistant, or tool that helped produce the change. The commit is
attributed entirely to the accountable human.
## For AI coding agents
See `AGENTS.md`.
+9
View File
@@ -89,6 +89,15 @@ Entities appear under one HA device per appliance, named `Samsung Appliance (<ip
---
## Part 4: Per-device settings
Each device has its own **Configure** option in Settings > Devices & Services, under **Device settings**:
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
---
## Development
### Docker Compose dev environment
+40 -2
View File
@@ -16,6 +16,9 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
ObjectSelector,
SelectSelector,
SelectSelectorConfig,
@@ -31,12 +34,16 @@ from .const import (
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
_HYSTERESIS_MINUTES = NumberSelector(NumberSelectorConfig(
min=0, max=30, step=1, mode=NumberSelectorMode.BOX,
))
_LOGGER = logging.getLogger(__name__)
@@ -215,8 +222,23 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so the second unit's
config flow aborted as already configured.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith('nothing'):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
@@ -225,6 +247,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.identity import read_identity
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try:
@@ -282,7 +305,15 @@ 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}"
recognized_registry = resolve_registry(resources)
# /oic/d's device type (read_identity) is the primary detection
# signal when a board populates it -- see registry/by_type's
# resolve(). read_identity is defensive on every GET it makes, so
# a device that doesn't answer /oic/p or /oic/d just yields an
# empty device_types tuple here, falling through to the model-
# string/resource-signature detection resolve() already did.
identity = read_identity(sess, None)
recognized_registry = resolve_registry(
resources, device_types=identity.device_types)
return {
"port": port,
"serial": serial,
@@ -453,6 +484,13 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
vol.Required(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
default=self.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _HYSTERESIS_MINUTES,
}),
)
+11
View File
@@ -22,6 +22,17 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
# to report a new value. Devices commonly revise their own remaining-time
# estimate by a minute or two throughout a cycle, and finish_time = now() +
# remaining drifts by the poll interval between those revisions -- both push
# a fresh state (and a recorder/logbook entry) far more often than the
# estimate is meaningfully different. 0 disables the gate (every computed
# change is reported, today's behavior).
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
# been seen as low as 49153, so we sweep the whole range for a live UDP port
+41 -11
View File
@@ -92,12 +92,30 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83). Mirrors the
identical helper in config_flow.py's `_probe_and_validate` -- kept
separate rather than imported to avoid pulling the config-flow module
into the runtime coordinator's import graph for a two-line check.
and the second one's entities get dropped (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so two such units
collided on the config-entry unique_id and the second couldn't be
added at all.
Mirrors the identical helper in config_flow.py's `_probe_and_validate`
-- kept separate rather than imported to avoid pulling the config-flow
module into the runtime coordinator's import graph for a two-line
check.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith('nothing'):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -157,6 +175,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
self._log,
config_entry=entry,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=timedelta(seconds=SUMMARY_INTERVAL_S),
)
@@ -634,6 +653,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, self.subdevices, resolve_registry, CAPABILITIES,
log=unbound.append, tier_log=_tier_log,
oic_device_types=self._identity.device_types if self._identity else (),
)
self.subdevices = materialized
self._skipped_subdevices = skipped
@@ -667,12 +687,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
else:
# 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.
# All three: detection reads each of them (oic device type, then
# 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, and device_types is often empty even when
# populated hardware exists for a type we don't map yet.
self._log.warning(
"unknown device type modelNum=%r description=%r; using common caps",
"unknown device type modelNum=%r description=%r device_types=%r; "
"using common caps",
model_num, description,
self._identity.device_types if self._identity else (),
)
self.device_type_name = device_type_name
self.bound = bound
@@ -912,9 +936,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Schedule sub-polls for hot/warm hrefs between summary polls
# (no-op in observe-primary mode unless this cycle's sweep found a
# mismatch; _run_subpolls checks the mode/force).
# mismatch; _run_subpolls checks the mode/force). A background task,
# not async_create_task: this loop is self-limiting (cancelled and
# recreated every refresh cycle, see the cancel() above) and owned
# entirely by the coordinator, so it has no business being tracked by
# HA's own startup/shutdown sequencing -- async_create_task ties it
# in regardless, so a subpoll cycle in flight (up to ~27s,
# _SUBPOLL_STEP_S x 9 slots) delays both (issue #207).
if self._hot_hrefs or self._warm_hrefs:
self._subpoll_task = self.hass.async_create_task(
self._subpoll_task = self.hass.async_create_background_task(
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
)
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.1"
],
"version": "0.17.0"
"version": "0.18.0"
}
@@ -1,16 +1,17 @@
"""Per-device-type registries."""
import re
from typing import Optional
from typing import Optional, Sequence
from ._base import DeviceRegistry
from . import (
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
range_hood, refrigerator, vacuum_station, washer, water_purifier,
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
dehumidifier, dishwasher, dryer, induction_cooktop, microwave, oven,
range as _range, range_hood, refrigerator, vacuum_station, washer,
water_purifier,
)
__all__ = [
'DeviceRegistry', 'resolve', 'for_device_by_model',
'DeviceRegistry', 'resolve', 'for_device_by_oic_type', 'for_device_by_model',
'for_device_by_resources', '_board_tokens',
]
@@ -19,6 +20,7 @@ __all__ = [
# `_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_monitor': air_monitor.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
@@ -111,6 +113,8 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
'VSWW': 'vacuum_station', # issue #219
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
@@ -189,6 +193,69 @@ def _consumer_model_key(description: str) -> Optional[str]:
return None
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
# -> registry key. This is the device naming its own type -- no board-part
# guessing involved -- so it's consulted before modelNum/description at all.
#
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list
# stops well short of the full OCF/SmartThings device-type vocabulary: a
# compiled list of `x.com.st.d.*` types will include plenty of device
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy
# meters, ...) no Samsung DA appliance dump could ever report and this
# integration has no registry for -- and 'oic.d.robotcleaner' names an
# actual robot vacuum, a different product from the clean/auto-empty
# *station* `vacuum_station` covers (see that registry's own module
# docstring); mapping it there would misroute a genuine robot-vacuum dump
# into a registry with no vacuum-body capabilities at all. Add a row only
# once there's a real registry key on the right-hand side to point at.
#
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
# device-type vocabulary (used for categories with no `oic.d.*` equivalent),
# same prefix convention as the `x.com.samsung.da.*` resource fields
# elsewhere in this codebase.
#
# `oic.d.cooktop` is deliberately absent, and is the one measured type left out.
# A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` and
# `induction_cooktop` are two unrelated registries that happen to share the
# English word (see by_type/cooktop.py's docstring: the NA9300K gas family keeps
# burner state in /mode/vs/0's options array, a completely different OCF
# surface). The OCF type does not distinguish them, so mapping it to either key
# would silently misroute the other -- and as the *primary* signal it would
# override a `COOKTOP`/`CT` board token that had it right. Same reasoning as
# `oic.d.robotcleaner` above: no unambiguous key to point at, so no row.
_OIC_TYPE_TO_KEY: dict[str, str] = {
'oic.d.airconditioner': 'airconditioner',
'oic.d.airpurifier': 'air_purifier',
'oic.d.dishwasher': 'dishwasher',
'oic.d.dryer': 'dryer',
'oic.d.oven': 'oven',
'oic.d.refrigerator': 'refrigerator',
'oic.d.washer': 'washer',
'x.com.st.d.airqualitysensor': 'air_monitor',
'x.com.st.d.hood': 'range_hood', # AHD-WW-TP1-22-COMMON
'x.com.st.d.stickcleaner': 'vacuum_station',
'x.com.st.d.steamcloset': 'air_dresser',
}
def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegistry]:
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
declaration.
The primary path when a dump carries it: the device names its own type,
so there's nothing to infer from board part numbers. Most hardware still
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
this only ever helps a minority of dumps, and `for_device_by_model`/
`for_device_by_resources` remain load-bearing for everything else.
"""
for device_type in device_types:
key = _OIC_TYPE_TO_KEY.get(device_type)
if key is not None:
return _REGISTRY_BY_KEY[key]
return None
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
"""Device-type detection from /information/vs/0's model strings.
@@ -273,16 +340,22 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
return None
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
def resolve(
resources: dict[str, dict], device_types: Sequence[str] = (),
) -> 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.
`device_types` (/oic/d's `rt`, read separately from the /device/0 dump --
see registry/identity.py) is the primary signal when present: the device
naming its own type beats parsing board part numbers. Falls back to model
strings (`for_device_by_model`), then a distinctive resource signature
(`for_device_by_resources`) for boards that report no /information/vs/0
at all -- both unchanged from before /oic/d was ever consulted, since most
hardware still doesn't populate it usefully.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
reads like the obvious signal -- the device naming its own type, e.g.
@@ -292,7 +365,11 @@ def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
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)
return (
for_device_by_oic_type(device_types)
or for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
or for_device_by_resources(resources)
)
@@ -1,7 +1,10 @@
"""AirDresser device registry (issue #162).
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
status, operational state, and diagnosis. air_dresser.py holds the two
status, operational state, diagnosis, and (issue #208) the buzzer-sound
select -- /buzzersound/vs/0's rep on this board is the plain
{setBuzzerSound, supportedBuzzerSound} shape laundry.BUZZER_SOUND already
expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
@@ -17,6 +20,7 @@ REGISTRY = DeviceRegistry(
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
@@ -0,0 +1,23 @@
"""Air Monitor Plus device registry (issue #210).
A standalone, battery-powered air-quality sensor puck (ASM-KR-TP1-22-*
board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_monitor',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]),
)
@@ -0,0 +1,155 @@
"""Capabilities for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-*
board, issue #210) -- a small battery-powered standalone air-quality sensor
puck, not a controllable appliance. No `/power/*` resource at all (battery
only); this registry deliberately doesn't include common.POWER.
`/sensors/vs/0` is the same {type, value: [...]} items-list shape
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board (no `supportedGrades`/similar field, no repeated dump to compare
against) confirms what its scale means -- left unbound rather than guessed,
per the adding-device-support skill's "still never invent... from nothing"
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
shape; index 0 is the only slot any family has ever read.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
ug/m3 particulate readings in a physically consistent order (coarser
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
three-tier naming doesn't confirm where the extra tier or a PM1 reading
actually fits. The adding-device-support skill's read-side rule says
leave unit/device_class unset when the dump gives no field that
nominates one -- a wrong guess would silently mislabel every reading
forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
SENSORS = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
state_class='measurement',
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
) + (
SensorDesc(key='co2', field='x.com.samsung.da.items',
device_class='carbon_dioxide', state_class='measurement',
unit='ppm',
value_fn=lambda items: sensor_item_value(items, 'CO2')),
),
)
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', state_class='measurement',
unit='%', value_fn=int_or_none),
),
)
BATTERY = Capability(
href='/energy/battery/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='battery', field='x.com.samsung.da.battery',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic',
value_fn=int_or_none),
BinarySensorDesc(key='battery_charging', field='x.com.samsung.da.charging',
device_class='battery_charging',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
)
AIR_QUALITY_STANDARD = Capability(
href='/airqualitystandard/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='air_quality_standard', field='x.com.samsung.da.standard',
entity_category='diagnostic'),
),
)
def _parse_hms(v):
"""'HH:MM:SS' -> datetime.time, same contract as laundry._parse_hm but
tolerant of the trailing ':SS' this board's dnd start/end times carry."""
if not v:
return None
try:
parts = v.split(':')
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ['dnd', 'vs', '0'], {field: f'{p.hour:02d}:{p.minute:02d}:00'}
return _write
# Issue #210: no idle-vs-active dump pair exists for this href (only one
# dump total, DND never toggled in it), so this write contract is an
# educated guess, not a confirmed one -- symmetric with the read side
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
# device itself reports back) rather than invented from nothing, but still
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
DND = Capability(
href='/dnd/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='dnd', field='x.com.samsung.da.value',
icon='mdi:sleep',
entity_category='config',
value_fn=lambda v: v == 'true',
write_fn=lambda p, rep, href=None: (
['dnd', 'vs', '0'],
{'x.com.samsung.da.value': 'true' if p == 'On' else 'false'})),
TimeDesc(key='dnd_start', field='x.com.samsung.da.startTime',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.startTime')),
TimeDesc(key='dnd_end', field='x.com.samsung.da.endTime',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.endTime')),
),
)
# ---------------------------------------------------------------------------
# Air-monitor-scoped coverage: hrefs with no explainable live state,
# following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
'/keepnormalstate/vs/0',
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
'/sensordatasinks/vs/0',
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
@@ -380,6 +380,29 @@ def _option_number_write(prefix):
return write
def _odor_controller_active(rep):
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
/mode/vs/0 option token -- corroborated against the SmartThings cloud
custom.airConditionerOdorController capability's airConditionerOdorController
State field, same on/off vocabulary and a matching name ("Smart Cool
Clean" mirrors "odor controller"). Unconditional across board
generations, same as beep above -- no evidence ties this token to the
legacy-vs-newer split _has_option_token gates on. Read-only: no command
capability is confirmed, so this stays a sensor rather than a guessed
write (the 'don't guess' rule)."""
tok = _option_token(rep, 'SmartCoolClean')
if tok is None:
return None
return tok == 'On'
def _odor_controller_progress(rep):
"""0-100 progress of the odor-controller self-clean cycle, from the
`ProgressSmartClean_<N>` option token -- mirrors the cloud
airConditionerOdorControllerProgress field."""
return _int(_option_token(rep, 'ProgressSmartClean'))
def _humidity(rep):
"""Relative humidity, preferring the 5%-rounded field where it exists.
@@ -676,6 +699,21 @@ CLIMATE = Capability(
options=('180', '300', '500', '700'),
write_fn=_option_switch_write('FilterAlarmTime'),
icon='mdi:alarm', entity_category='config'),
# Odor-controller ("Smart Cool Clean") state + progress -- see
# _odor_controller_active's docstring for the cloud-capability
# correspondence. Present on TP1X_DA-AC-RAC-01001 (issue reporter's
# dump); gating is token presence only, not board generation.
BinarySensorDesc(key='odor_controller_active',
rep_fn=_odor_controller_active,
exists_fn=lambda rep, resources: (
_option_token(rep, 'SmartCoolClean') is not None),
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='odor_controller_progress',
rep_fn=_odor_controller_progress,
exists_fn=lambda rep, resources: (
_option_token(rep, 'ProgressSmartClean') is not None),
unit='%', state_class='measurement',
icon='mdi:progress-check', entity_category='diagnostic'),
),
)
@@ -228,8 +228,9 @@ def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
multi-value reading to read (index 0 is the raw measurement on every
family seen so far). Shared by range_hood.AIR_QUALITY and
air_purifier.AIR_QUALITY, which read the same resource shape."""
family seen so far). Shared by range_hood.AIR_QUALITY,
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
same resource shape against the same {type, sensor_type} keys."""
for item in items or ():
if not isinstance(item, dict):
continue
@@ -301,11 +302,19 @@ POWER_VS_FALLBACK = Capability(
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
# by HA (its switch platform only accepts 'outlet'/'switch'),
# leaving a plain switch whose 'On' state meant different things
# on different boards. As a BinarySensorDesc with `device_class='lock'`,
# both kids-lock surfaces read with the same polarity: 'On' means
# open/unlocked, per HA's lock device_class. The inversion in
# value_fn here (and in the fallback below) keeps the on-the-wire
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
# both mean kids lock NOT active) consistent with that polarity.
BinarySensorDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: not bool(v)),
),
)
@@ -313,12 +322,19 @@ KIDS_LOCK_VS_FALLBACK = Capability(
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
# Read-only, not a SwitchDesc (issues #181/#183): the write side of
# this capability wrote 'Enable', a value no dump in the fixture
# corpus has ever reported back -- every one reports either 'Ready'
# or 'Run', so it was never a confirmed contract. #181's reporter
# confirmed this directly: writing the *correct* value ('Run')
# still 4.05s, and the SmartThings app itself has no control for
# it either -- the resource is genuinely read-only on this
# hardware, not just wrong-valued. Polarity matches
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v == 'Ready'),
),
)
@@ -90,7 +90,14 @@ def _finish_time(rep):
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
if not total_s:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
# Round to whole minutes -- remainingTime itself only has minute
# resolution, but datetime.now() always carries fresh seconds/
# microseconds, so an unrounded result changes on nearly every poll
# even when the device-reported remaining time hasn't. That floods
# the recorder history/logbook with values that look identical once
# the UI rounds them down for display.
finish = datetime.now(timezone.utc) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0)
def _completion_minutes(rep):
@@ -156,7 +163,7 @@ OPERATIONAL_STATE = Capability(
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
rep_fn=_finish_time),
hysteresis=True, rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
@@ -79,6 +79,15 @@ def _hood_fan_write(payload, rep, href=None):
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
if not supported:
min_s = rep.get('x.com.samsung.da.hood.settableMinFanSpeed')
max_s = rep.get('x.com.samsung.da.hood.settableMaxFanSpeed')
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
supported = [str(i) for i in range(mn, mx + 1)]
except (ValueError, TypeError):
pass
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
@@ -175,11 +175,23 @@ FAVORITE_HOTWATER = Capability(
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
# Issue #196: `supportedList` is only the four *fixed* presets
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
# the user add one custom value to their own display list via its
# "temperatures to display" editor (a wheel picker bounded by
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
# and that custom value shows up in `showList`
# (['40', '50', '75', '85', '90'] here) but never in
# `supportedList`. Reading options from `supportedList` meant a
# unit whose current default *was* that custom value rendered as
# "unknown" -- not a coverage gap, just the wrong field. `showList`
# is a superset of `supportedList` that always includes whatever
# the current default actually is, custom or not.
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.favorite.supportedList',
options_field='x.com.samsung.da.favorite.showList',
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.favorite.defaultTemperature': p})),
@@ -57,6 +57,11 @@ class SensorDesc(SamsungEntityDescription):
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
# device-side revisions -- not a general-purpose flag every sensor should set.
hysteresis: bool = False
@dataclass(frozen=True, kw_only=True)
@@ -47,13 +47,13 @@ def _device_types(d: dict) -> tuple[str, ...]:
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.
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
now consults this first, ahead of board-part-number parsing, via
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a
minority of dumps populate it, so the modelNum/description path stays
load-bearing for everything else. It's also kept whole in diagnostics
(see `raw` below) so incoming issue reports keep surfacing types that
table doesn't know about yet.
"""
rt = d.get('rt')
if isinstance(rt, str):
@@ -80,7 +80,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable, Optional
from typing import Callable, Optional, Sequence
import cbor2
@@ -519,10 +519,11 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
def discover_partitioned(
resources: dict[str, dict],
subdevices: list['Subdevice'],
resolve_registry: Callable[[dict], object],
resolve_registry: Callable[..., object],
fallback_capabilities: dict,
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
oic_device_types: Sequence[str] = (),
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
plus every enumerated subdevice's seed) to entities, partitioned by which
@@ -551,6 +552,15 @@ def discover_partitioned(
first-discovery time only is a non-issue; getting a phantom subdevice
silently counted into unbound_hrefs or hot/warm tiers is not.
`oic_device_types` (from the master's own `/oic/d`, see
registry/identity.py) is passed only to the *master's* resolution --
subdevices have no `/oic/d` of their own read today (they resolve from
their own `/information/vs/0` or fall back to the master's whole
registry, as documented above), and blindly applying the master's OCF
device type to every subdevice's own model-based resolution would be
wrong the moment a composite appliance ever pairs two genuinely
different device types under one connection.
Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized
subdevice).
@@ -575,7 +585,7 @@ def discover_partitioned(
# owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view)
reg = resolve_registry(main_view, device_types=oic_device_types)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities) if reg is not None
else (fallback_capabilities, [])
+36 -2
View File
@@ -1,6 +1,8 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
@@ -12,7 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import DOMAIN
from .const import CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
@@ -42,6 +44,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
self._hysteresis_value = None
@property
def native_unit_of_measurement(self):
@@ -52,7 +55,38 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
raw = (self.coordinator.data or {}).get(self._state_key)
if not self._bound.desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
that difference has been building up (this is a deadband, not a
time-based debounce).
Values like finish_time are `now() + remaining`, recomputed from
scratch every poll -- both wall-clock drift between the device's own
remaining-time updates and the device revising its own estimate mid-
cycle produce a stream of small, real changes that are individually
meaningless but each trigger a recorder/logbook entry. A cycle
ending (raw is None) or starting (cache empty) always passes through
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
if (
threshold_min
and raw is not None
and self._hysteresis_value is not None
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
):
return self._hysteresis_value
self._hysteresis_value = raw
return raw
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
File diff suppressed because it is too large Load Diff
@@ -10,12 +10,18 @@
"automatic_operation": {
"name": "Automatic operation"
},
"battery_charging": {
"name": "Charging"
},
"burner_hot_surface": {
"name": "Burner {number} hot surface"
},
"burner_pan_detected": {
"name": "Burner {number} pan detected"
},
"child_lock": {
"name": "Child lock"
},
"cloud_connected": {
"name": "Cloud connected"
},
@@ -34,6 +40,9 @@
"overload_protection_active": {
"name": "Overload protection active"
},
"odor_controller_active": {
"name": "Odor controller active"
},
"cycle_active": {
"name": "Running"
},
@@ -292,19 +301,25 @@
"01": "Normal",
"06": "Time dry",
"16": "Cotton",
"17": "Super Speed",
"18": "Synthetics",
"19": "Delicates",
"20": "Iron dry",
"23": "Quick Dry 35",
"21": "Hygiene Care",
"22": "Silent Dry",
"23": "Quick Dry 35'",
"24": "Cool air",
"25": "Warm air",
"27": "Time dry",
"29": "AI Dry",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed load"
"1f": "Mixed load",
"2b": "Self Tub Dry",
"4c": "Air Refresh"
}
},
"favorite_capacity": {
@@ -609,6 +624,9 @@
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_quality_standard": {
"name": "Air quality standard"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -621,12 +639,18 @@
"automatic_ventilation_state": {
"name": "Automatic ventilation state"
},
"battery": {
"name": "Battery"
},
"burner_state": {
"name": "Burner {number} state"
},
"clean_level": {
"name": "Clean level"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Coffee brew status"
},
@@ -655,6 +679,9 @@
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"odor_controller_progress": {
"name": "Odor controller progress"
},
"panel_status": {
"name": "Panel status"
},
@@ -1005,6 +1032,9 @@
"display_light": {
"name": "Display light"
},
"dnd": {
"name": "Do not disturb"
},
"fast_preheat": {
"name": "Fast preheat"
},
@@ -1088,6 +1118,12 @@
}
},
"time": {
"dnd_end": {
"name": "Do not disturb end"
},
"dnd_start": {
"name": "Do not disturb start"
},
"led_night_end": {
"name": "Door LED night end"
},
@@ -1147,15 +1183,16 @@
"init": {
"title": "LocalThings options",
"menu_options": {
"settings": "Remote control write settings",
"settings": "Device settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
}
},
"debug_write": {
@@ -10,11 +10,17 @@
"automatic_operation": {
"name": "Automatische werking"
},
"battery_charging": {
"name": "Opladen"
},
"burner_hot_surface": {
"name": "Brander {number} heet oppervlak"
},
"burner_pan_detected": {
"name": "Brander {number} pan gedetecteerd"
"name": "Pan gedetecteerd op brander {number}"
},
"child_lock": {
"name": "Kinderslot"
},
"cloud_connected": {
"name": "Verbonden met cloud"
@@ -23,7 +29,7 @@
"name": "Stofzak vol"
},
"cooktop_power": {
"name": "Kookplaat voeding"
"name": "Voeding kookplaat"
},
"cooktop_safety_shutoff_enabled": {
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
@@ -34,6 +40,9 @@
"overload_protection_active": {
"name": "Overbelastingsbeveiliging actief"
},
"odor_controller_active": {
"name": "Geurbeheersing actief"
},
"cycle_active": {
"name": "Actief"
},
@@ -74,7 +83,7 @@
"name": "Schenken"
},
"alarm_in_mute": {
"name": "Alarm bij dempen"
"name": "Alarmdemping"
},
"power_state": {
"name": "Voedingsstatus"
@@ -215,7 +224,7 @@
}
},
"cooler_temperature_setpoint": {
"name": "Koeler temperatuur"
"name": "Temperatuur koelgedeelte"
},
"day_brightness": {
"name": "Helderheid kast",
@@ -292,19 +301,25 @@
"01": "Normaal",
"06": "Tijdprogramma",
"16": "Katoen",
"17": "Super Speed",
"18": "Synthetisch",
"19": "Fijne was",
"20": "Strijkdroog",
"21": "Hygiënisch drogen",
"22": "Stil drogen",
"23": "Snel drogen 35'",
"24": "Koude lucht",
"25": "Warme lucht",
"27": "Tijdprogramma",
"29": "AI drogen",
"1a": "Wol",
"1b": "Beddengoed",
"1c": "Overhemden",
"1d": "Handdoeken",
"1e": "Outdoor",
"1f": "Gemengde was"
"1f": "Gemengde was",
"2b": "Zelf trommel drogen",
"4c": "Opfrissen"
}
},
"favorite_capacity": {
@@ -490,24 +505,24 @@
"sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"voice": "Stem",
"tone": "Toon",
"mute": "Dempen"
"mute": "Stil"
}
},
"air_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"mute": "Dempen",
"mute": "Stil",
"buzzer": "Zoemer"
}
},
"water_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"voice": "Stem",
"fixedtone": "Vaste toon",
"mute": "Dempen"
"mute": "Stil"
}
},
"spin_speed": {
@@ -570,17 +585,17 @@
"53": "Intensief",
"54": "Handdoeken",
"55": "Sportkleding",
"57": "Fijn",
"57": "Fijne was",
"5e": "Spoelen+centrifugeren",
"60": "Zelfreinigend+",
"60": "Self Clean+",
"65": "Bonte was",
"66": "Spijkergoed",
"7c": "Witte was",
"7d": "Beddengoed/Waterdicht",
"7e": "Zelfreinigend",
"7f": "Wol/Fijn",
"7d": "Beddengoed/waterdicht",
"7e": "Self Clean",
"7f": "Wol/fijne was",
"86": "Diep wassen",
"87": "Downloaden",
"87": "Gedownload",
"8f": "Intensief koud",
"96": "Minder microvezels"
}
@@ -609,6 +624,9 @@
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_quality_standard": {
"name": "Luchtkwaliteitsnorm"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
@@ -621,12 +639,18 @@
"automatic_ventilation_state": {
"name": "Status automatische ventilatie"
},
"battery": {
"name": "Batterij"
},
"burner_state": {
"name": "Status brander {number}"
},
"clean_level": {
"name": "Reinigingsniveau"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Koffiezetstatus"
},
@@ -638,7 +662,7 @@
}
},
"cooktop_running_state": {
"name": "Loopstatus kookplaat"
"name": "Bedrijfsstatus kookplaat"
},
"cooktop_state": {
"name": "Status kookplaat"
@@ -655,6 +679,9 @@
"outdoor_temperature": {
"name": "Buitentemperatuur"
},
"odor_controller_progress": {
"name": "Voortgang geurbeheersing"
},
"panel_status": {
"name": "Paneelstatus"
},
@@ -671,10 +698,10 @@
"name": "Status steel"
},
"uvc_operation_time": {
"name": "UV-C bedrijfstijd"
"name": "UV-C-bedrijfstijd"
},
"uvc_total_operation_time": {
"name": "UV-C totale bedrijfstijd"
"name": "Totale UV-C-bedrijfstijd"
},
"uvc_finished_time": {
"name": "UV-C voltooid op"
@@ -768,7 +795,7 @@
"name": "Geschatte eindtijd"
},
"completion_minutes": {
"name": "Voltooiingstijd"
"name": "Resterende tijd"
},
"freezer_temperature": {
"name": "Vriezertemperatuur"
@@ -849,13 +876,13 @@
"name": "Vermogen"
},
"probe_battery": {
"name": "Sonde batterij"
"name": "Batterijniveau sonde"
},
"probe_target_temperature": {
"name": "Doeltemperatuur sonde"
},
"probe_temperature": {
"name": "Temperatuur sonde"
"name": "Sondetemperatuur"
},
"progress": {
"name": "Voortgang"
@@ -897,7 +924,7 @@
"name": "Status"
},
"cup_state": {
"name": "Kopstatus"
"name": "Bekerstatus"
},
"last_pour_type": {
"name": "Laatste schenktype"
@@ -1005,6 +1032,9 @@
"display_light": {
"name": "Displayverlichting"
},
"dnd": {
"name": "Niet storen"
},
"fast_preheat": {
"name": "Snel voorverwarmen"
},
@@ -1033,7 +1063,7 @@
"name": "{instance_name} ingeschakeld"
},
"intensive": {
"name": "Intensive"
"name": "Intensief"
},
"lamp": {
"name": "Lamp"
@@ -1088,6 +1118,12 @@
}
},
"time": {
"dnd_end": {
"name": "Einde niet storen"
},
"dnd_start": {
"name": "Start niet storen"
},
"led_night_end": {
"name": "Einde nachtverlichting deur-LED"
},
@@ -1147,15 +1183,16 @@
"init": {
"title": "LocalThings-opties",
"menu_options": {
"settings": "Schrijfinstellingen voor afstandsbediening",
"settings": "Apparaatinstellingen",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
},
"settings": {
"title": "Schrijfinstellingen voor afstandsbediening",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.",
"title": "Apparaatinstellingen",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld"
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
}
},
"debug_write": {
+414
View File
@@ -0,0 +1,414 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airdresseroption/sanitize/vs/0",
"rep": {}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Off",
"On"
],
"setBuzzerSound": "On"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1117000000",
"x.com.samsung.da.countryCode": "KR"
}
},
{
"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": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_01",
"AiOption_On",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"FilterUsage_384001D4",
"WashingTimes_2",
"DrumCleanProposal_30",
"SpecialFunction_16",
"AvailableDelayTime_0",
"ProgressTimeSet_AA012CB20348",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"WeatherPush_On",
"GMT_12",
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F0F0F0F0F0F00F0F0F0F0F",
"EnergyLevelSet_05020404010103040301020203020101040505050503030303030303050204050405",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
"SavingModeCondition_01010205272B2C2D2E0300",
"SavingMode_Off",
"UsagesDB_ok",
"DrumCleanLog_Empty"
],
"x.com.samsung.da.supportedOptions": [
"10100003500000300000E00000400000A00001300000B00001200000900000C00001000003100001400001600001700002100001900002000000F00002700003000002A00002B00002C00002D00002E00001500001A00001B00001C0000070000080000"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "2900",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785362400",
"x.com.samsung.da.cumulativeDateUTC": "1785330000",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON_DF3000B/DC92-03252A_0002",
"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": "0AJT",
"x.com.samsung.da.diagSetupid": "WA0",
"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": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "03101A260513(A440)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20313841241127210FFFFF203252412402215804FFFF(015E2031384120325241_30241204)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03138A24112721,03252A24022158",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2031574122122007043FFF2032114122122704042FFF(015E2031574120321141_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03157A22122007,03211A22122704"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Firmware_3_DB_20303242000000020AFFFFFFFFFFFFFFFFFFFFFFFFFF(015E20303242FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03032B00000002,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:35:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:35:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"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": "2026-07-07",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_DF_TP1_21_COMMON",
"versions": [
"30260513"
],
"visVersion": "260513"
},
{
"type": "Micom",
"modelId": "015E2031384120325241",
"versions": [
"24112721",
"24022158"
],
"visVersion": "241127"
},
{
"type": "Micom",
"modelId": "015E2031574120321141",
"versions": [
"22122007",
"22122704"
],
"visVersion": "221227"
},
{
"type": "Micom",
"modelId": "015E20303242FFFFFFFF",
"versions": [
"2",
"FFFFFFFF"
],
"visVersion": "2"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/st/airdressercourse/vs/0",
"rep": {
"x.com.samsung.da.st.airdresserMode": "Table_00_Course_01",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_0135030E040A130B12090C10311416172119200F27302A2B2C2D2E151A1B1C0708",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_01270F"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/wm/personalcourse/vs/0",
"rep": {
"x.com.samsung.da.courses": [
"F1_00",
"F2_00",
"F3_00",
"F4_00",
"F5_00",
"F6_00",
"F7_00",
"F8_00",
"F9_00",
"FA_00"
],
"x.com.samsung.da.maxCourseNum": "10"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "true",
"x.com.samsung.da.modelCode": "M(None),W(None)"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
}
]
}
+184
View File
@@ -0,0 +1,184 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airqualitystandard/vs/0",
"rep": {
"x.com.samsung.da.standard": "AirCleanAssociation"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T02:42:57",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/dnd/vs/0",
"rep": {
"x.com.samsung.da.value": "false",
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true"
}
},
{
"href": "/energy/battery/vs/0",
"rep": {
"x.com.samsung.da.battery": "80",
"x.com.samsung.da.batteryResolution": "1",
"x.com.samsung.da.charging": "Off"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "57",
"x.com.samsung.da.validity": "Valid"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000",
"x.com.samsung.da.description": "ASM-KR-TP1-22-ACMB1M",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02443A260310",
"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": "22050300",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AM0",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensordatasinks/vs/0",
"rep": {
"remove": ""
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "2",
"x.com.samsung.da.refresh": "Off",
"x.com.samsung.da.lastSensingTime": "1785379376",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"2"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"31",
"2"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"23",
"2"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"18",
"2"
]
},
{
"x.com.samsung.da.id": "5",
"x.com.samsung.da.description": "Sensor for CO2",
"x.com.samsung.da.type": "CO2",
"x.com.samsung.da.value": [
"498",
"1"
]
}
]
}
}
]
}
@@ -0,0 +1,575 @@
{
"device0": [
{},
{
"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": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-31T12:54:20",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-31T12:54:20",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AE3F00A0012C01610248000F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcLevel": "0"
}
},
{
"href": "/electriccurrent/vs/0",
"rep": {
"operation": "Off",
"modes": "3",
"supportedModes": [
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1785502435",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "44",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "53"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010514001911010E00492200902000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02762A260401",
"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.newVersionAvailable": "0",
"x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR2",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.options": [
"OptionCode_35880",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"Volume_Mute",
"StopAutoClean_Idle",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"ExtendOptionCode_215693",
"UpdateAllow_NotAllowed",
"DurationOn_240",
"OutdoorTemp_99",
"CoolCapa_20",
"WarmCapa_22",
"Autoclean_On",
"WelcomeCoolingState_Off",
"WelcomeHeatingState_Off",
"OutdoorConnection_Connected"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
],
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off",
"rt": [
"x.com.samsung.da.option.muteonce"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "4"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"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": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000009C00FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"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.current": "24.0",
"x.com.samsung.da.desired": "24.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "All",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"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": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "845210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "045210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "FGCAN2",
"versions": [
"",
""
],
"visVersion": "999999"
},
{
"type": "Micom",
"modelId": "FGCAN3",
"versions": [
"",
""
],
"visVersion": "999999"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/Prague",
"offset": "+02:00",
"DST": "ON"
}
},
{
"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": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "REDACTED_SSID"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"state_keys": [
"alarm_code",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"sanitize",
"wrinkle_prevent"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_quality_standard",
"alarm_code",
"battery",
"battery_charging",
"clean_level",
"co2",
"dnd",
"dnd_end",
"dnd_start",
"dust",
"fine_dust",
"humidity",
"odor",
"super_fine_dust"
]
}
@@ -14,6 +14,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
+2
View File
@@ -20,6 +20,8 @@
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"selfcheck_error",
"selfcheck_result",
+2
View File
@@ -13,6 +13,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
@@ -13,6 +13,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
@@ -13,6 +13,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
@@ -18,6 +18,8 @@
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
@@ -14,6 +14,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
@@ -0,0 +1,26 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_limit_enabled",
"current_limit_level",
"current_temperature_c",
"display_light",
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"tropical_night_mode"
]
}
@@ -15,6 +15,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"overload_protection_active",
"overload_protection_mode",
"selfcheck_error",
+1 -1
View File
@@ -166,7 +166,7 @@
"href": "/favorite/hotwater/vs/0",
"rep": {
"x.com.samsung.da.favorite.revision": "0",
"x.com.samsung.da.favorite.defaultTemperature": "85",
"x.com.samsung.da.favorite.defaultTemperature": "50",
"x.com.samsung.da.favorite.showList": [
"40",
"50",
+15
View File
@@ -539,3 +539,18 @@ def test_is_placeholder_serial_accepts_real_serials():
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
def test_is_placeholder_serial_catches_all_same_hex_digit():
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', colliding on the config-entry unique_id."""
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('FFFFFFFFFFFFFFF') is True
assert _is_placeholder_serial('ffffffffffffffff') is True
assert _is_placeholder_serial('00000000') is True
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial('FFFFFFF') is False
+39 -1
View File
@@ -15,7 +15,7 @@ from custom_components.localthings.const import (
SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import (
LocalThingsCoordinator, _local_source_port,
LocalThingsCoordinator, _is_placeholder_serial, _local_source_port,
)
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
@@ -163,6 +163,44 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
hass: HomeAssistant, mock_entry
) -> None:
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', so without this fallback they'd collide on
device_serial exactly like the #83 case above."""
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum':
'DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000',
'x.com.samsung.da.description':
'DA_WM_A51_20_COMMON_DVE50A8800/DC92-02835A_0080',
'x.com.samsung.da.serialNum': 'FFFFFFFFFFFFFFF',
},
'/otninformation/vs/0': {},
}
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(resources)
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
def test_is_placeholder_serial_catches_all_same_hex_digit():
assert _is_placeholder_serial('FFFFFFFFFFFFFFF') is True
assert _is_placeholder_serial('ffffffffffffffff') is True
assert _is_placeholder_serial('00000000') is True
def test_is_placeholder_serial_accepts_real_serials_and_short_runs():
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial('FFFFFFF') is False
def test_run_discovery_detects_cooktop_via_resource_signature(
hass: HomeAssistant, mock_entry
) -> None:
@@ -0,0 +1,79 @@
"""Tests for the DA_DF_TP1_21_COMMON AirDresser (model DF3000B, issue #208).
Another board generation routed into the same air_dresser registry as
issue #162's DA_DF_A51_20_COMMON and issue #157's DA_DF_TP2_20_COMMON.
Exercises the one thing this board reports that neither of those does:
a populated /buzzersound/vs/0 (laundry.BUZZER_SOUND) -- the reporter's
actual ask ("all cycles are represented by code") is a course-code
labelling problem, not a coverage one; this board's course table isn't
identified yet either (same as #162's), so 'cycle' still renders as the
raw code until a reporter can map codes to names in the SmartThings app.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser_tp1_21')
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 _state():
reg, resources = _air_dresser()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
def test_no_unbound_hrefs():
reg, resources = _air_dresser()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_buzzer_sound_present_and_writable():
"""Issue #208's actual coverage gap: /buzzersound/vs/0 was unbound on
this board before laundry.BUZZER_SOUND was added to the registry."""
state = _state()
assert state['buzzer_sound'] == 'On'
assert 'finish_sound' not in state # supportedFinishSound absent -- self-gated off
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/buzzersound/vs/0'][0].entities
if e.key == 'buzzer_sound'
)
path, body = desc.write_fn('Off', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setBuzzerSound': 'Off'}
def test_course_translation_key_falls_back_to_cycle_for_unidentified_table():
"""Same as issue #162's board: Table_00's course codes aren't
identified yet, so the select renders raw codes rather than names."""
_, resources = _air_dresser()
desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0]
assert desc.translation_key(resources) == 'cycle'
def test_sanitize_present_and_toggles():
state = _state()
assert state['sanitize'] is False
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities
if e.key == 'sanitize'
)
path, body = desc.write_fn('On', {})
assert path == ['airdresseroption', 'sanitize', 'vs', '0']
assert body == {'x.com.samsung.da.sanitize': 'On'}
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-ACMB1M,
issue #210) -- a standalone, battery-powered air-quality sensor puck. No
controllable appliance state at all beyond the do-not-disturb window.
"""
from datetime import time as dt_time
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_monitor():
resources = _load_device('air_monitor')
reg = resolve(resources)
return reg, resources
def _bound():
reg, resources = _air_monitor()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_resolves_to_air_monitor_registry():
reg, _ = _air_monitor()
assert reg is not None and reg.name == 'air_monitor'
def test_resolves_via_oic_type_too():
"""This board's real dump carries /oic/d's device type
('x.com.st.d.airqualitysensor') as well as the 'ASM' modelNum token --
both paths must agree."""
reg = for_device_by_oic_type(('x.com.st.d.airqualitysensor',))
assert reg is not None and reg.name == 'air_monitor'
def test_no_unbound_hrefs():
reg, resources = _air_monitor()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_air_quality_sensors_read_from_shared_items_decode():
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
which neither air_purifier nor range_hood report."""
state = _state()
assert state['dust'] == 31
assert state['fine_dust'] == 23
assert state['super_fine_dust'] == 18
assert state['odor'] == 1
assert state['clean_level'] == 2
assert state['co2'] == 498
def test_second_value_slot_is_not_exposed():
"""Dust/fine_dust/super_fine_dust each carry a second `value[1]` grade
code (e.g. Dust's ['31', '2']) with no supported-values field anywhere
in the dump to explain its scale -- per the 'still never invent... from
nothing' rule, only the raw reading (value[0]) is bound, not a second
'dust_level' or similar entity for that code."""
state = _state()
assert 'dust_level' not in state
assert 'dust_grade' not in state
def test_humidity_and_battery_present():
state = _state()
assert state['humidity'] == 57
assert state['battery'] == 80
assert state['battery_charging'] is False
def test_air_quality_standard_present():
state = _state()
assert state['air_quality_standard'] == 'AirCleanAssociation'
def test_dnd_read_contract():
state = _state()
assert state['dnd'] is False
assert state['dnd_start'] == dt_time(14, 0)
assert state['dnd_end'] == dt_time(22, 0)
def test_dnd_write_contracts_are_flagged_educated_guesses():
"""Issue #210: no idle-vs-active dump pair exists for /dnd/vs/0 (DND
was never toggled in the one dump this board has), so these write
contracts are educated guesses -- symmetric with the read side's own
string/time-format shape, not invented from nothing, but still
unconfirmed on real hardware. Only the wiring is tested here; whether
the device actually accepts these writes needs a reporter to confirm."""
dnd = _desc('dnd')
path, body = dnd.write_fn('On', {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.value': 'true'}
path, body = dnd.write_fn('Off', {})
assert body == {'x.com.samsung.da.value': 'false'}
start = _desc('dnd_start')
path, body = start.write_fn(dt_time(9, 30), {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.startTime': '09:30:00'}
+80
View File
@@ -872,3 +872,83 @@ def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale():
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['energy_kwh'] == round(1686632 / 1000.0, 2)
# ---------------------------------------------------------------------------
# TP1X_DA-AC-RAC-01001_0000, reporter's dump. Reported "fan and WindFree are
# missing" -- both already work: /wind/strength/vs/0's 0-4 codes match
# _DEVICE_TO_FAN exactly, and /mode/convenient/vs/0's Nano/NanoSleep codes
# already resolve dynamically via _preset_to_ha + the existing "nano"/
# "nanosleep" -> "WindFree"/"WindFree sleep" translation labels -- confirmed
# below by zero unbound hrefs and the climate entity binding with its usual
# FAN_MODE/PRESET_MODE features. The real, previously-uncaptured gap was
# /mode/vs/0's SmartCoolClean_/ProgressSmartClean_ option tokens (the cloud
# custom.airConditionerOdorController capability) -- see
# _odor_controller_active's docstring.
# ---------------------------------------------------------------------------
def _ac_odor_controller():
return _resolve('airconditioner_tp1x_rac_odor_controller')
def test_odor_controller_no_unbound_hrefs():
reg, resources = _ac_odor_controller()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_odor_controller_fan_and_windfree_already_bind():
"""The actually-reported gap wasn't real: fan speed and the WindFree
preset both come from the composite climate entity, which is bound here
with its normal fan/preset feature set -- no code change needed for
either."""
reg, resources = _ac_odor_controller()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
assert resources['/wind/strength/vs/0']['x.com.samsung.da.supportedModes'] == [
'0', '1', '2', '3', '4']
assert resources['/mode/convenient/vs/0']['x.com.samsung.da.supportedModes'] == [
'Off', 'Sleep', 'Quiet', 'Speed', 'Nano', 'NanoSleep']
def test_odor_controller_state_and_progress_present():
reg, resources = _ac_odor_controller()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['odor_controller_active'] is False # SmartCoolClean_Off
assert state['odor_controller_progress'] == 0 # ProgressSmartClean_0
def test_odor_controller_read_from_option_tokens():
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['SmartCoolClean_On']}) is True
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['SmartCoolClean_Off']}) is False
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['Volume_100']}) is None
assert airconditioner._odor_controller_progress(
{'x.com.samsung.da.options': ['ProgressSmartClean_42']}) == 42
assert airconditioner._odor_controller_progress({}) is None
def test_odor_controller_absent_when_no_smartcoolclean_token():
"""The original issue #17 dump's /mode/vs/0 options carry no
SmartCoolClean_/ProgressSmartClean_ tokens -- neither entity binds."""
reg, resources = _ac()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'odor_controller_active' not in state
assert 'odor_controller_progress' not in state
def test_odor_controller_is_read_only():
"""No command capability is confirmed for SmartCoolClean -- exposed
read-only, same 'don't guess' precedent as CURRENT_LIMIT/ANOMALY_LOAD."""
active = next(e for e in airconditioner.CLIMATE.entities
if e.key == 'odor_controller_active')
progress = next(e for e in airconditioner.CLIMATE.entities
if e.key == 'odor_controller_progress')
assert not hasattr(active, 'write_fn') or active.write_fn is None
assert not hasattr(progress, 'write_fn') or progress.write_fn is None
+136
View File
@@ -131,6 +131,78 @@ class TestConsumerModelKey:
assert _consumer_model_key('ARTIK051_DONGLE_REF') is None
class TestForDeviceByOicType:
"""Primary device-type detection from /oic/d's `rt`."""
def test_every_oic_type_resolves_to_a_real_registry(self):
from custom_components.localthings.registry.by_type import (
_OIC_TYPE_TO_KEY, _REGISTRY_BY_KEY,
)
for oic_type, key in _OIC_TYPE_TO_KEY.items():
assert key in _REGISTRY_BY_KEY, f"{oic_type!r} -> unknown registry {key!r}"
@pytest.mark.parametrize('oic_type, expected', [
('oic.d.airconditioner', 'airconditioner'),
('oic.d.airpurifier', 'air_purifier'),
('oic.d.dishwasher', 'dishwasher'),
('oic.d.dryer', 'dryer'),
('oic.d.oven', 'oven'),
('oic.d.refrigerator', 'refrigerator'),
('oic.d.washer', 'washer'),
('x.com.st.d.hood', 'range_hood'),
('x.com.st.d.stickcleaner', 'vacuum_station'),
('x.com.st.d.steamcloset', 'air_dresser'),
('x.com.st.d.airqualitysensor', 'air_monitor'),
])
def test_known_oic_types_resolve(self, oic_type, expected):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
reg = for_device_by_oic_type((oic_type,))
assert reg is not None
assert reg.name == expected
def test_generic_wk_d_type_alone_resolves_nothing(self):
"""The generic 'oic.wk.d' base type every OCF device carries
alongside its concrete type isn't itself a device type."""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.wk.d',)) is None
def test_unrecognized_type_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.d.somethingnew',)) is None
def test_robotcleaner_is_not_mapped_to_the_vacuum_station_registry(self):
"""'oic.d.robotcleaner' names an actual robot vacuum, a different
product from the clean/auto-empty station vacuum_station covers (no
vacuum-body capabilities at all) -- mapping it there would misroute
a genuine robot-vacuum dump."""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.d.robotcleaner',)) is None
def test_cooktop_is_not_mapped_to_either_cooktop_registry(self):
"""'oic.d.cooktop' cannot tell the two cooktop families apart.
A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` is the
unrelated NA9300K gas family (burner state in /mode/vs/0's options
array, a different OCF surface -- see by_type/cooktop.py). Mapping the
type to either key would misroute the other, and as the primary signal
it would override a board token that had it right.
"""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.d.cooktop',)) is None
def test_empty_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(()) is None
def test_finds_the_concrete_type_alongside_the_generic_one(self):
"""A real /oic/d `rt` carries both the generic base type and the
concrete one, order unspecified -- either position must resolve."""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
reg = for_device_by_oic_type(('oic.wk.d', 'oic.d.washer'))
assert reg is not None
assert reg.name == 'washer'
class TestForDeviceByModel:
"""Fallback device-type detection for hardware without oneUiVersion."""
@@ -376,6 +448,19 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'vacuum_station'
def test_air_monitor_via_asm_token(self):
"""Issue #210: a standalone air-quality sensor puck
(ASM-KR-TP1-22-ACMB1M) reports no oneUiVersion and no
washer/dryer/dishwasher consumer prefix; falls back to the 'ASM'
token in modelNum onto the air_monitor registry."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000',
'ASM-KR-TP1-22-ACMB1M',
)
assert reg is not None
assert reg.name == 'air_monitor'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
@@ -580,6 +665,57 @@ class TestOneUiVersionIsNotConsulted:
class TestResolve:
def test_oic_type_wins_over_model_strings(self):
"""The device naming its own type via /oic/d beats board-token
parsing -- an unrecognizable modelNum with a known oic.d type must
still resolve, and a *conflicting* modelNum must lose to it."""
from custom_components.localthings.registry.by_type import resolve
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'SOME-UNKNOWN-BOARD',
'x.com.samsung.da.description': 'SOME-UNKNOWN-BOARD',
},
}
reg = resolve(resources, device_types=('oic.d.washer',))
assert reg is not None
assert reg.name == 'washer'
conflicting = resolve(
resources={
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
'x.com.samsung.da.description': 'TP1X_REF_21K',
},
},
device_types=('oic.d.washer',),
)
assert conflicting is not None
assert conflicting.name == 'washer'
def test_empty_device_types_falls_back_to_model_strings(self):
from custom_components.localthings.registry.by_type import resolve
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
'x.com.samsung.da.description': 'TP1X_REF_21K',
},
}
reg = resolve(resources, device_types=())
assert reg is not None
assert reg.name == 'refrigerator'
def test_unmapped_device_types_falls_back_to_model_strings(self):
from custom_components.localthings.registry.by_type import resolve
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
'x.com.samsung.da.description': 'TP1X_REF_21K',
},
}
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.somethingnew'))
assert reg is not None
assert reg.name == 'refrigerator'
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."""
+27 -7
View File
@@ -15,9 +15,13 @@ def _reg():
def test_kids_lock_vs_value_fn():
"""binary_sensor's 'lock' device class is inverted from a plain on/off
switch: On means open/unlocked (issues #181/#183 -- see
TestKidsLockFallback.test_vs_fallback_is_read_only for why this is a
binary_sensor at all)."""
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
assert desc.value_fn('Lock') is True
assert desc.value_fn('Ready') is False
assert desc.value_fn('Lock') is False
assert desc.value_fn('Ready') is True
def test_common_caps_discover_on_dishwasher(dishwasher_resources):
@@ -228,19 +232,35 @@ class TestWmSetinfoFlags:
class TestKidsLockFallback:
def test_generic_read_write(self):
def test_generic_is_read_only(self):
"""Issues #181/#183: the kids-lock resource is read-only on real
hardware (writing the *correct* value still 4.05s, and no
SwitchDesc device_class='lock' is honored by HA -- the switch
platform only accepts 'outlet'/'switch'). KIDS_LOCK_GENERIC and
KIDS_LOCK_VS_FALLBACK now share the same shape (BinarySensorDesc,
device_class='lock', same key) so both surfaces render with the
same polarity: 'On' means open/unlocked."""
assert common.KIDS_LOCK_GENERIC.href == '/kidslock/0'
desc = common.KIDS_LOCK_GENERIC.entities[0]
assert desc.value_fn(True) is True
path, body = desc.write_fn('On', {})
assert path == ['kidslock', '0']
assert body == {'value': True}
assert isinstance(desc, BinarySensorDesc)
assert desc.value_fn(False) is True # value=False -> On=Unlocked
assert desc.value_fn(True) is False # value=True -> Off=Locked
def test_vs_fallback_gated(self):
assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
assert common.KIDS_LOCK_VS_FALLBACK.match_fn(
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
def test_vs_fallback_is_read_only(self):
"""Issues #181/#183: writing this resource 4.05s on real hardware
even with the *correct* value ('Run'), and no dump in the fixture
corpus has ever reported the value this used to write ('Enable')
back -- every one reports 'Ready' or 'Run'. Never a confirmed write
contract, so it's a binary_sensor (no write_fn field at all), not
a switch."""
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
assert isinstance(desc, BinarySensorDesc)
class TestRemoteControlFallback:
def test_generic_read(self):
+65
View File
@@ -810,6 +810,27 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_odor_controller():
"""TP1X_DA-AC-RAC-01001_0000 -- reporter's dump: fan (0-4 wind-strength
scale) and WindFree (Nano/NanoSleep convenient-mode codes) already bind
via the composite climate entity, no code change needed there. The
genuine gap was /mode/vs/0's SmartCoolClean_/ProgressSmartClean_ option
tokens (the cloud custom.airConditionerOdorController capability),
previously unbound to any entity -- now odor_controller_active/
odor_controller_progress."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_rac_odor_controller')
golden = json.loads(
(GOLDEN / 'airconditioner_tp1x_rac_odor_controller.json').read_text()
)
state_keys = _new_state_keys('airconditioner_tp1x_rac_odor_controller', 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_air_dresser():
"""DA_DF_A51_20_COMMON AirDresser (model DF8600T, issue #162) -- reports
no oneUiVersion; resolved via the '_DF_' modelNum token fallback. Has no
@@ -848,6 +869,50 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20():
)
def test_registry_reproduces_golden_state_keys_for_air_dresser_tp1_21():
"""DA_DF_TP1_21_COMMON AirDresser (model DF3000B, issue #208) -- another
board generation routed via the '_DF_' modelNum token fallback into the
same air_dresser registry as #162's and #157's boards (its dump also
happens to be the first AirDresser one to carry /oic/d's device type,
'x.com.st.d.steamcloset' -- see test_by_type.py's
TestForDeviceByOicType for that mapping's own coverage). The first
AirDresser dump to report /buzzersound/vs/0 (laundry.BUZZER_SOUND,
added for this issue -- #162's and #157's boards don't report it at
all). Binds cleanly with zero unbound hrefs; course codes for this
board's table still aren't identified (same as #162's), so 'cycle'
remains the raw code rather than a table-specific translation."""
from tests.conftest import _load_device
resources = _load_device('air_dresser_tp1_21')
golden = json.loads((GOLDEN / 'air_dresser_tp1_21.json').read_text())
state_keys = _new_state_keys('air_dresser_tp1_21', 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_air_monitor():
"""Samsung Air Monitor Plus (ASM-KR-TP1-22-ACMB1M, issue #210) -- a
standalone battery-powered air-quality sensor puck, the first device
this integration has no controllable-appliance concept for at all (no
/power/*, only /energy/battery/vs/0). Routes via both /oic/d
('x.com.st.d.airqualitysensor') and the 'ASM' modelNum board token.
Reuses air_purifier.AIR_QUALITY's /sensors/vs/0 decode
(common.sensor_item_value) for dust/fine_dust/super_fine_dust/odor/
clean_level, adding a CO2 reading those families don't report. Binds
cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_monitor')
golden = json.loads((GOLDEN / 'air_monitor.json').read_text())
state_keys = _new_state_keys('air_monitor', 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_air_purifier_vtww():
"""A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no
oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into
+28
View File
@@ -62,6 +62,34 @@ class TestCompletionMinutes:
assert desc.rep_fn(rep) is None
class TestFinishTime:
"""issue: remainingTime only has minute resolution, but datetime.now()
always carries fresh seconds/microseconds -- an unrounded finish_time
changed on nearly every poll even when remainingTime hadn't, flooding
the recorder history/logbook with values that looked identical once
the UI rounded them down to the minute for display."""
def test_seconds_and_microseconds_are_zeroed(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
rep = {
'x.com.samsung.da.state': 'Run',
'x.com.samsung.da.remainingTime': '00:29:00',
}
result = desc.rep_fn(rep)
assert result.second == 0
assert result.microsecond == 0
def test_stable_across_polls_within_same_minute(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
rep = {
'x.com.samsung.da.state': 'Run',
'x.com.samsung.da.remainingTime': '00:29:00',
}
first = desc.rep_fn(rep)
second = desc.rep_fn(rep)
assert first == second
class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
+10
View File
@@ -99,6 +99,16 @@ def test_fan_write_contract():
)
assert desc.write_fn(('speed', '99'), rep) is None
rep_fallback = {
'x.com.samsung.da.hood.settableMinFanSpeed': '0',
'x.com.samsung.da.hood.settableMaxFanSpeed': '3',
}
assert desc.write_fn(('speed', '2'), rep_fallback) == (
['hood', 'fanspeed', 'vs', '0'],
{'x.com.samsung.da.hood.fanSpeed': '2'},
)
assert desc.write_fn(('speed', '4'), rep_fallback) is None
def test_lamp_write_contract():
lamp, brightness = range_hood.HOOD_LAMP.entities
+6 -3
View File
@@ -56,7 +56,10 @@ def test_energy_saving_and_cooktop_alert_present():
def test_child_lock_already_reads_correctly():
"""issue #183 also reported child lock as unreadable -- but this dump's
/kidslock/vs/0 ('Run') and /doors/vs/0's door lock field ('Lock') already
agree the door is locked, and the existing common.KIDS_LOCK_VS_FALLBACK
already reads that correctly with no code change needed."""
agree the door is locked, and common.KIDS_LOCK_VS_FALLBACK already reads
that correctly with no further code change needed. Now a read-only
binary_sensor (issues #181/#183 -- the write side was never a confirmed
contract), whose 'lock' device class is inverted from a switch's plain
on/off: False means locked/closed, matching 'Run' here."""
state = _state()
assert state['child_lock'] is True
assert state['child_lock'] is False
+100
View File
@@ -0,0 +1,100 @@
"""Unit tests for LocalThingsSensor's hysteresis gate (finish_time churn)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from custom_components.localthings.const import CONF_FINISH_TIME_HYSTERESIS_MINUTES
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.sensor import LocalThingsSensor
_FINISH_TIME_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
class _FakeConfigEntry:
def __init__(self, options):
self.options = options
class _FakeCoordinator:
"""Just enough surface for LocalThingsEntity/LocalThingsSensor."""
def __init__(self, threshold_minutes):
self.device_serial = 'TEST-SERIAL'
self.config_entry = _FakeConfigEntry({
CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes,
})
self.data: dict = {}
def _sensor(threshold_minutes=3):
coordinator = _FakeCoordinator(threshold_minutes)
bound = BoundEntity(
href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=_FINISH_TIME_DESC,
)
sensor = LocalThingsSensor(coordinator, bound)
return sensor, coordinator
def test_small_change_is_suppressed():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
coordinator.data = {'finish_time': base + timedelta(minutes=1)}
assert sensor.native_value == base, "a 1-minute wobble should be held back"
def test_change_past_threshold_is_reported():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
new = base + timedelta(minutes=5)
coordinator.data = {'finish_time': new}
assert sensor.native_value == new
def test_zero_threshold_disables_hysteresis():
sensor, coordinator = _sensor(threshold_minutes=0)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
new = base + timedelta(seconds=1)
coordinator.data = {'finish_time': new}
assert sensor.native_value == new
def test_cycle_end_passes_through_immediately():
"""A cycle ending (finish_time -> None) must never be held back."""
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
coordinator.data = {'finish_time': None}
assert sensor.native_value is None
def test_non_hysteresis_sensor_is_unaffected():
"""A SensorDesc without hysteresis=True reads straight through, unchanged."""
machine_state_desc = next(
e for e in OPERATIONAL_STATE.entities if e.key == 'machine_state'
)
coordinator = _FakeCoordinator(threshold_minutes=3)
bound = BoundEntity(
href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=machine_state_desc,
)
sensor = LocalThingsSensor(coordinator, bound)
coordinator.data = {'machine_state': 'active'}
assert sensor.native_value == 'active'
coordinator.data = {'machine_state': 'idle'}
assert sensor.native_value == 'idle'
+56 -10
View File
@@ -476,7 +476,7 @@ def test_discover_partitioned_binds_main_and_subdevice_separately():
'/mode/vs/1': {'m': 'sibling'},
}
def resolve(_resources):
def resolve(_resources, **_kwargs):
return reg
bound, device_type_name, materialized, skipped = discover_partitioned(
@@ -516,7 +516,7 @@ def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
unbound = []
discover_partitioned(
resources, [sub1], lambda r: reg, fallback_capabilities={},
resources, [sub1], lambda r, **_: reg, fallback_capabilities={},
log=unbound.append,
)
assert unbound == []
@@ -534,7 +534,7 @@ def test_discover_partitioned_subdevice_resolves_its_own_registry():
sub1 = _indexed('1')
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
def resolve(view):
def resolve(view, **_kwargs):
# The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
return sub_reg if view.get('/mode/vs/0', {}).get('x') == 2 else main_reg
@@ -563,7 +563,7 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry():
'/mode/vs/1': {'x': 2},
}
def resolve(view):
def resolve(view, **_kwargs):
return main_reg if view.get('/information/vs/0') else None
bound, _, materialized, skipped = discover_partitioned(
@@ -575,6 +575,52 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry():
assert '/mode/vs/1' in hrefs
def test_discover_partitioned_oic_device_types_resolves_main_pass():
"""`oic_device_types` reaches the main-pass resolve call as
`device_types=`, letting a fake `resolve_registry` mirror the real
resolve()'s precedence between /oic/d and model-based detection."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]})
resources = {'/mode/vs/0': {'x': 1}}
def resolve(_resources, device_types=()):
return oic_reg if 'oic.d.washer' in device_types else None
bound, device_type_name, _, _ = discover_partitioned(
resources, [], resolve, fallback_capabilities={},
oic_device_types=('oic.d.washer',),
)
assert device_type_name == 'washer'
assert {b.href for b in bound} == {'/mode/vs/0'}
def test_discover_partitioned_oic_device_types_not_applied_to_subdevices():
"""The master's own /oic/d type must not leak into a subdevice's own
resolution -- only main_view's resolve() call receives it, so a fake
resolver keyed purely on device_types resolves nothing for the
subdevice pass and it falls back to the master's registry, per the
documented (and unchanged) subdevice-fallback behavior."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]})
sub1 = _indexed('1')
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
def resolve(_resources, device_types=()):
return oic_reg if 'oic.d.washer' in device_types else None
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, [sub1], resolve, fallback_capabilities={},
oic_device_types=('oic.d.washer',),
)
assert device_type_name == 'washer'
assert materialized == [sub1]
assert skipped == []
# The subdevice's own resolve() call sees no device_types and returns
# None, falling back to the master's oic_reg -- same capabilities, so
# /mode/vs/1 still binds via that shared registry.
assert {b.href for b in bound} == {'/mode/vs/0', '/mode/vs/1'}
def test_discover_partitioned_no_subdevices_matches_plain_discover():
"""For a device with no subdevices this must be exactly the single
discover() call it replaces -- the hard regression guard the whole
@@ -586,7 +632,7 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover():
resources = {'/mode/vs/0': {'x': 1}}
bound_via_helper, _, materialized, skipped = discover_partitioned(
resources, [], lambda r: reg, fallback_capabilities={},
resources, [], lambda r, **_: reg, fallback_capabilities={},
)
bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities)
@@ -619,7 +665,7 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit2], lambda r: reg, fallback_capabilities={},
resources, [unit2], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == []
assert len(skipped) == 1
@@ -654,7 +700,7 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
}
bound, _, materialized, skipped = discover_partitioned(
resources, [sub1], lambda r: reg, fallback_capabilities={},
resources, [sub1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == [sub1]
assert skipped == []
@@ -692,7 +738,7 @@ def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
resources, [unit1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == []
assert [s.subdevice for s in skipped] == [unit1]
@@ -726,7 +772,7 @@ def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
resources, [unit1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == [unit1]
assert skipped == []
@@ -748,7 +794,7 @@ def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs():
tiers = []
discover_partitioned(
resources, [unit2], lambda r: reg, fallback_capabilities={},
resources, [unit2], lambda r, **_: reg, fallback_capabilities={},
tier_log=lambda href, tier: tiers.append(href),
)
assert '/mode/vs/2' not in tiers
+32 -2
View File
@@ -281,9 +281,17 @@ def test_hotwater_lock_fallback_gating_across_status_lock_states():
assert fallback.exists_fn({}, {}) is False
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():
def test_favorite_hotwater_temperature_options_come_from_live_show_list():
"""`supportedList` is only the four fixed presets -- `showList` is a
superset that also carries the one custom value (if any) the
SmartThings app's "temperatures to display" editor let the user add,
and always includes whatever the current default actually is. Reading
`supportedList` meant a unit whose default *was* that custom value
rendered as 'unknown' in HA (issue #196) -- see the ailite fixture's
version of this test below for the case that actually exercises it
(this fixture's showList == supportedList, no custom value set)."""
desc = _desc_coffee('favorite_hotwater_temperature')
assert desc.options_field == 'x.com.samsung.da.favorite.supportedList'
assert desc.options_field == 'x.com.samsung.da.favorite.showList'
def test_coffee_recipe_hrefs_are_ignored_not_guessed():
@@ -361,6 +369,28 @@ def test_ailite_hot_water_temperature_gated_off_without_supported_list():
assert 'hot_water_temperature' not in state
def test_ailite_favorite_hotwater_temperature_options_include_the_custom_value():
"""Issue #196's concrete failure case: the user added a custom 50C value
via the SmartThings app's "temperatures to display" editor, so the
board's defaultTemperature is now '50'. showList contains '50'
([40, 50, 75, 85, 90]) but supportedList does NOT (still the fixed
[40, 75, 85, 90]). Reading options_field='x.com.samsung.da.favorite.supportedList'
-- the old behavior -- would register a select whose options list
doesn't contain the current default, so HA would render the entity as
'unknown'. The descriptor must read from showList so '50' is in
options."""
desc = _desc_ailite('favorite_hotwater_temperature')
assert desc.options_field == 'x.com.samsung.da.favorite.showList'
_, resources = _water_purifier_ailite()
rep = resources['/favorite/hotwater/vs/0']
# defaultTemperature='50' must be a member of the field the descriptor
# actually reads -- this is the precise assertion that would fail under
# the old supportedList behavior.
assert rep['x.com.samsung.da.favorite.defaultTemperature'] == '50'
assert '50' in rep[desc.options_field]
assert '50' not in rep['x.com.samsung.da.favorite.supportedList']
def test_ailite_sound_mode_options_come_from_live_supported_modes():
"""This board's supportedModes (voice/fixedTone/mute) differs from both
laundry.SOUND_MODE's hardcoded voice/tone/mute and air_purifier.SOUND_MODE's