Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547388cc2b | ||
|
|
81b83f4175 | ||
|
|
c5f37ea280 | ||
|
|
b4550cc4b1 | ||
|
|
42a1812967 | ||
|
|
e926905517 | ||
|
|
6bcf8f3bec | ||
|
|
9737684c9f | ||
|
|
6efee761d9 | ||
|
|
e309d3e7b8 | ||
|
|
34b2291bef | ||
|
|
5c275753d4 | ||
|
|
57eb3e80c7 | ||
|
|
d544644e0e | ||
|
|
f9cd857ced | ||
|
|
93744e80f4 | ||
|
|
a74b92f758 | ||
|
|
d639aa693f | ||
|
|
3c6c7f246f | ||
|
|
0d0844ea7f | ||
|
|
618fc5fa51 | ||
|
|
f36b3016cc | ||
|
|
36f53da6a0 | ||
|
|
6166f63570 | ||
|
|
b7d3f86ec7 | ||
|
|
fd86c54f18 | ||
|
|
e55c55c55e | ||
|
|
7a43fcb988 | ||
|
|
9f15d06e84 | ||
|
|
9ab3c5827c | ||
|
|
d90562017f | ||
|
|
fe9a1128ea | ||
|
|
2f231b5c19 | ||
|
|
873ed562de | ||
|
|
f15ee7e563 | ||
|
|
d0c68fb5ac | ||
|
|
e99a8a6b05 | ||
|
|
87e0c43b23 | ||
|
|
9ee0dbb8f9 | ||
|
|
f19abc7936 | ||
|
|
a7d57086e9 | ||
|
|
4afd11a357 | ||
|
|
d317a85988 | ||
|
|
4d4de23fbb | ||
|
|
e46e935ba0 | ||
|
|
5bbc7aeece | ||
|
|
e642db462d | ||
|
|
822c6814b6 | ||
|
|
61f13e7484 | ||
|
|
b8d04b4ae3 | ||
|
|
2d09ef108a | ||
|
|
8512aef2d2 | ||
|
|
3cd22824ca | ||
|
|
9f4c47ea63 | ||
|
|
0a4b80afc4 | ||
|
|
4f9e630ae9 | ||
|
|
4c827bf9e4 | ||
|
|
8e8f08109a |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -128,7 +137,7 @@ custom_components/localthings/
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
const.py Domain, config keys, probe ports
|
||||
entity.py Base entity wiring capability registry -> HA entity
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
|
||||
One module per HA platform
|
||||
catalog.py Reads the shipped translation catalog (which keys/states exist)
|
||||
translations/ Config-flow copy + entity name/state translations, one file per
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ DOMAIN = "localthings"
|
||||
|
||||
PLATFORMS = [
|
||||
"sensor", "binary_sensor", "switch", "number", "select", "button",
|
||||
"time", "climate", "fan",
|
||||
"time", "climate", "fan", "water_heater",
|
||||
]
|
||||
|
||||
CONF_HOST = "host"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.1"
|
||||
],
|
||||
"version": "0.17.0"
|
||||
"version": "0.18.0"
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ class ObserveManager:
|
||||
self.subscribed_hrefs: set[str] = set()
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||
self._notify_cond = threading.Condition()
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
self._refresh_task: ObserveRefreshTask | None = None
|
||||
self._refresh_stop: threading.Event | None = None
|
||||
@@ -147,8 +150,10 @@ class ObserveManager:
|
||||
return
|
||||
if not isinstance(rep, dict):
|
||||
return
|
||||
self._notified.add(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
with self._notify_cond:
|
||||
self._notified.add(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
self._notify_cond.notify_all()
|
||||
self.log.debug("observe notify: %s", href)
|
||||
self.apply(href, rep, source='observe')
|
||||
|
||||
@@ -172,10 +177,12 @@ class ObserveManager:
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — subscribes to every href then sleeps for the whole
|
||||
grace period. Caller must run this in an executor, never on the
|
||||
"""Blocking — subscribes to every href then waits up to
|
||||
`grace_period_s`, returning early once `success_fraction` of hrefs
|
||||
have notified. Caller must run this in an executor, never on the
|
||||
event loop."""
|
||||
self._notified.clear()
|
||||
with self._notify_cond:
|
||||
self._notified.clear()
|
||||
subscribed: set[str] = set()
|
||||
for href in hrefs:
|
||||
segs = [s for s in href.strip('/').split('/') if s]
|
||||
@@ -190,10 +197,18 @@ class ObserveManager:
|
||||
self.subscribed_hrefs = set()
|
||||
return False
|
||||
|
||||
time.sleep(grace_period_s)
|
||||
def _fraction_reached() -> bool:
|
||||
return (
|
||||
len(set(self._notified) & subscribed) / len(subscribed)
|
||||
>= success_fraction
|
||||
)
|
||||
|
||||
fraction = len(set(self._notified) & subscribed) / len(subscribed)
|
||||
if fraction >= success_fraction:
|
||||
with self._notify_cond:
|
||||
reached = self._notify_cond.wait_for(
|
||||
_fraction_reached, timeout=grace_period_s,
|
||||
)
|
||||
|
||||
if reached:
|
||||
self.subscribed_hrefs = subscribed
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
|
||||
@@ -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, ehs, 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,12 +20,14 @@ __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,
|
||||
'dehumidifier': dehumidifier.REGISTRY,
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'ehs': ehs.REGISTRY,
|
||||
'induction_cooktop': induction_cooktop.REGISTRY,
|
||||
'microwave': microwave.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
@@ -85,6 +88,10 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
|
||||
'ARA': 'airconditioner',
|
||||
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
|
||||
'EHS': 'ehs', # Eco Heating System air-to-water heat pump --
|
||||
# zone1 space heating/cooling + dhw domestic
|
||||
# hot water, its own /mode/*/vs/0 and
|
||||
# /temperatures/*/vs/0 resource shapes
|
||||
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
|
||||
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
|
||||
'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a
|
||||
@@ -111,6 +118,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 +198,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.
|
||||
|
||||
@@ -229,13 +301,16 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
"""Detect a device family from a distinctive local-resource signature.
|
||||
|
||||
For boards that ship no ``/information/vs/0`` at all, leaving
|
||||
`for_device_by_model` nothing to read. Some newer cooktops are the
|
||||
This runs first as an override path for non-standard devices, not because
|
||||
resource signatures are inherently more trustworthy than OIC/model
|
||||
metadata. It also types boards that ship no ``/information/vs/0`` at all,
|
||||
leaving `for_device_by_model` nothing to read. Some newer cooktops are the
|
||||
original case: their mode resource still identifies them, carrying a
|
||||
DeviceType option and multiple per-burner OperationState options.
|
||||
|
||||
Require two independent shapes for every signature here, never one, so
|
||||
an unrelated family's ``/mode/vs/0`` isn't misclassified.
|
||||
putting this ahead of OIC/model metadata cannot let a common resource
|
||||
misclassify an unrelated family.
|
||||
"""
|
||||
mode = resources.get('/mode/vs/0', {})
|
||||
options = mode.get('x.com.samsung.da.options') or ()
|
||||
@@ -258,9 +333,14 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
|
||||
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
|
||||
# absent from the dump, not just empty) can't be matched via
|
||||
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
|
||||
# the oven cavity resource (/oven/vs/0) is a safe signature.
|
||||
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
|
||||
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
|
||||
# PR #225) when resource detection runs before metadata.
|
||||
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
|
||||
if '/oven/vs/0' in resources:
|
||||
if not isinstance(supported_modes, (list, tuple)):
|
||||
supported_modes = ()
|
||||
cavity = resources.get('/oven/vs/0')
|
||||
if isinstance(cavity, dict):
|
||||
if any(
|
||||
m in supported_modes
|
||||
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
|
||||
@@ -273,16 +353,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.
|
||||
Distinctive resource signatures run first because they describe the live
|
||||
capability surface a registry must bind. They are deliberately strict in
|
||||
`for_device_by_resources`: each requires multiple independent details, so
|
||||
this can correct misleading metadata (Qooker's generic ``oic.d.oven``)
|
||||
without a common href overriding an unrelated family. When no signature
|
||||
matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see
|
||||
registry/identity.py) wins over model-string parsing.
|
||||
|
||||
`/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 +378,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_resources(resources)
|
||||
or 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', ''),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,29 @@
|
||||
"""EHS (Eco Heating System) air-to-water heat pump device registry
|
||||
(Samsung TP1X_DA_AC_EHS-class).
|
||||
|
||||
Shares the DA_AC_ board prefix with the room-AC family in
|
||||
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
|
||||
are its own shape (two independent loops: zone1 space heating/cooling and
|
||||
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
|
||||
pattern -- so nothing from that module is reused here except MUTE_ONCE,
|
||||
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
|
||||
family's dump.
|
||||
"""
|
||||
from ..capabilities import airconditioner, common, ehs, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='ehs',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
airconditioner.MUTE_ONCE,
|
||||
ehs.ZONE_POWER,
|
||||
ehs.ZONE_MODE,
|
||||
ehs.ZONE_TEMPERATURE,
|
||||
ehs.DHW,
|
||||
*ehs.DHW_CONSUMED,
|
||||
ehs.AWAY_MODE,
|
||||
*ehs.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -573,15 +596,124 @@ CLIMATE = Capability(
|
||||
unit='°C', icon='mdi:home-thermometer-outline'),
|
||||
# Filter time in tenths of an hour: the token read 1710 while the official
|
||||
# Samsung app displayed "171 hours 0 minutes" for the filter on the same
|
||||
# unit, and the .0 matching the app's "0 minutes" pins the scale. Whether
|
||||
# it counts up or down is NOT established -- it was seen rising (171.0 ->
|
||||
# 171.5) while the unit ran, which contradicts the app's "remaining"
|
||||
# wording, so the entity is deliberately named neutrally.
|
||||
# unit, and the .0 matching the app's "0 minutes" pins the scale.
|
||||
#
|
||||
# It counts UP -- running time accumulated since the last filter reset,
|
||||
# not time remaining. An earlier revision of this comment called the
|
||||
# direction unestablished; three independent things now settle it. It was
|
||||
# seen rising while the unit ran (171.0 -> 171.5). FilterAlarmTime_ in the
|
||||
# same options[] blob is the threshold it is measured against (500 on
|
||||
# every unit on record). And across two units on one site, the alarm
|
||||
# tracks the counter in the right direction: at FilterTime_5595 the
|
||||
# /alarms/vs/0 filter entry is live (unsuffixed code 'FilterAlarm', state
|
||||
# 'Created'), while at FilterTime_1915 it is still the
|
||||
# 'FilterAlarm_OFF'/'Deleted' placeholder. That also matches what the app
|
||||
# does at 500 hours -- ask the user to clean the filter and reset it.
|
||||
#
|
||||
# The key stays 'filter_time' rather than becoming something like
|
||||
# filter_time_elapsed: renaming it would change every existing unit's
|
||||
# entity_id and unique_id for a wording improvement.
|
||||
#
|
||||
# (The alarm clearing on its own is the causal proof of the direction
|
||||
# above: the board recomputes it the moment the counter crosses the
|
||||
# threshold, rather than the app clearing counter and alarm separately.)
|
||||
#
|
||||
# ---------------------------------------------------------------------
|
||||
# RESETTING THIS COUNTER: NOT SOLVED YET -- no reset entity here, and
|
||||
# the notes below are so the next attempt starts from the evidence
|
||||
# rather than from scratch. I could not work out how to drive the reset
|
||||
# locally; that is not the same as it being impossible, and none of the
|
||||
# results below rule out a mechanism I simply haven't found.
|
||||
#
|
||||
# What the reset actually is: a *command*, not a value write. Samsung
|
||||
# models it as capability `custom.dustFilter`, command
|
||||
# `resetDustFilter`, no arguments (implemented in several SmartThings HA
|
||||
# forks; not in the core integration). That reframes every failed
|
||||
# attempt below -- nothing changes the counter by writing to it,
|
||||
# because the board zeroes it itself on receiving a command.
|
||||
#
|
||||
# Tried, all against a live unit, all failed:
|
||||
# * FilterTime_0 via the single-token options merge that works for
|
||||
# every other setting on this href -- accepted with no error, then
|
||||
# discarded. Two units, opposite power states, to rule out the
|
||||
# obvious confound: 5595 -> back to 5595 after 69 s (powered off,
|
||||
# alarm active) and 1925 -> back to 1925 after 65 s (actively
|
||||
# cooling).
|
||||
# * A full options[] read-modify-write with FilterTime_0 substituted,
|
||||
# instead of the single-token merge -- zero fields changed anywhere.
|
||||
# * A write to /consumable/vs/0, the board's own filter resource
|
||||
# (items[{name: FilterProgress, state: N}]) -- discarded. /oic/res
|
||||
# declares that resource oic.if.s, i.e. read-only, which fits.
|
||||
# * /actions/vs/0 (x.com.samsung.da.actions, oic.if.a) is the obvious
|
||||
# local command channel, but publishes no schema: GET returns {} on
|
||||
# baseline and on oic.if.a, and five POSTs probing the *shape*
|
||||
# (empty map, empty string, empty array, invalid value, items shape)
|
||||
# all returned 4.00 with an empty body -- no echo of accepted field
|
||||
# names, unlike the laundry firmware's "Control fail, <...>". I
|
||||
# deliberately did not enumerate guessed action names against a live
|
||||
# appliance: an unknown vocabulary on a channel called "actions" can
|
||||
# hold a factory reset next to the one we want.
|
||||
# * /hass/state/vs/0 and /hass/command/vs/0 (advertised in /oic/res,
|
||||
# and /opt/data/hass.db exists in /file/list) -> 4.04 on every
|
||||
# interface, so unimplemented scaffolding on this firmware.
|
||||
# * /file/transfer/vs/0 serves only /mnt/usage.db; selecting another
|
||||
# path returns 4.05/4.00, so the firmware can't be pulled that way
|
||||
# to read the action vocabulary out of it.
|
||||
# * /rm/micomdata/vs/0 (channel toward the MICOM board the physical
|
||||
# panel talks to) stays empty even after successfully enabling
|
||||
# remote management.
|
||||
#
|
||||
# What the failures are NOT: a transport, permission or cert problem.
|
||||
# A control write of rmState on /rm/state/vs/0 was accepted (2.04
|
||||
# Changed, value held, restored afterwards), and FilterAlarmTime_ below
|
||||
# is written through the very same options merge and kept. So writes
|
||||
# work; this one value just isn't driven that way.
|
||||
#
|
||||
# Where I'd look next: the action vocabulary for /actions/vs/0 from an
|
||||
# independent source (firmware image, or a capture of what the cloud
|
||||
# sends the device), or the IR path -- the physical remote has a filter
|
||||
# reset (Options -> Filter Reset -> SET), and IRremoteESP8266 decodes
|
||||
# this AC family, though issue #1277's dump doesn't include that button.
|
||||
# ---------------------------------------------------------------------
|
||||
SensorDesc(key='filter_time',
|
||||
rep_fn=_option_token_num('FilterTime', divisor=10),
|
||||
exists_fn=_has_option_token('FilterTime'),
|
||||
device_class='duration', unit='h',
|
||||
state_class='measurement', icon='mdi:air-filter'),
|
||||
# The interval FilterTime_ is measured against -- the app offers it as a
|
||||
# four-way radio (180/300/500/700 hours, default 500) next to the filter
|
||||
# reminder. The token value is the hour count verbatim: captured by
|
||||
# watching all 19 resources while the owner stepped through every radio
|
||||
# position, one field changing per step and nothing else moving.
|
||||
#
|
||||
# Static options here, unlike air_filter_threshold on the newer boards
|
||||
# which reads supportedFilterDesiredUsage: options[] tokens carry no
|
||||
# supported-values list anywhere in this board's dump, so there is
|
||||
# nothing to read them from. The four values are the app's own radio
|
||||
# rather than a guess extrapolated from one observed value -- but a
|
||||
# board offering different steps would need this revisited, which is
|
||||
# why the tuple is documented rather than just written down.
|
||||
SelectDesc(key='filter_alarm_time',
|
||||
rep_fn=lambda rep: _option_token(rep, 'FilterAlarmTime'),
|
||||
exists_fn=_has_option_token('FilterAlarmTime'),
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -610,6 +742,25 @@ AUTO_CLEAN = Capability(
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'autoclean', 'vs', '0'],
|
||||
{'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})),
|
||||
# The cycle, as opposed to the switch above -- `settingStatus` says the
|
||||
# feature is enabled, which it is whether or not the unit is drying right
|
||||
# now. `status` is the run state, and the resource advertises exactly
|
||||
# Start/Stop in `supportedStatus`. Observed on a TP1X_DA-AC-CAC-01001:
|
||||
# 'Start' with progress 98 while a cycle ran, then 'Stop' with progress 0
|
||||
# the moment it finished.
|
||||
BinarySensorDesc(key='auto_clean_running',
|
||||
field='x.com.samsung.da.status',
|
||||
icon='mdi:spray-bottle',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'Start'),
|
||||
# Percent through that cycle, matching the figure the appliance shows on
|
||||
# its own display (checked against 55% mid-run).
|
||||
SensorDesc(key='auto_clean_progress',
|
||||
field='x.com.samsung.da.progress',
|
||||
icon='mdi:progress-clock',
|
||||
unit='%', state_class='measurement',
|
||||
entity_category='diagnostic',
|
||||
value_fn=common.int_or_none),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Capabilities for the Samsung EHS (Eco Heating System) air-to-water heat
|
||||
pump family (TP1X_DA_AC_EHS-class, model TP1X_DA_AC_EHS_01001_0000).
|
||||
|
||||
An EHS unit runs two independently-controlled loops off one outdoor unit:
|
||||
space heating/cooling ("zone1", through /mode/vs/0, /power/vs/0,
|
||||
/temperatures/indoor/vs/0) and domestic hot water ("dhw", through
|
||||
/mode/dhw/vs/0, /power/dhw/vs/0, /temperatures/dhw/vs/0). There's no shared
|
||||
vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
|
||||
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
|
||||
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
|
||||
|
||||
zone1 has no HA platform with matching semantics (it's a leaving-water-
|
||||
temperature setpoint, not a thermostat with HVAC modes airconditioner.py's
|
||||
climate.py would fit), so it stays switch/select/number/sensor -- same shape
|
||||
as dehumidifier.py's power/mode/humidity split. dhw is a real HA
|
||||
water_heater.py -- see DHW below and water_heater.py's module docstring --
|
||||
following the same primary-resource-plus-sibling-reads pattern as
|
||||
airconditioner.py's CLIMATE/climate.py.
|
||||
|
||||
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
|
||||
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _first_mode(rep):
|
||||
"""Representative scalar for a mode select -- `modes` is a single-element
|
||||
list on every dump seen so far, mirroring airconditioner._first_mode /
|
||||
dehumidifier._first_mode's handling of the same field shape."""
|
||||
modes = rep.get('x.com.samsung.da.modes')
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
def _temp_unit(rep):
|
||||
return normalize_temp_unit(rep.get('x.com.samsung.da.unit'), '°C')
|
||||
|
||||
|
||||
def _bounds(rep, default_min, default_max):
|
||||
"""The resource's own (minimum, maximum) pair, or the defaults.
|
||||
|
||||
Both ends together or neither -- a board reporting only one would
|
||||
otherwise pair a real device bound with an invented default, which
|
||||
looks plausible and is silently wrong. Same rule as
|
||||
climate._range()/water_heater._range(), and the same reason
|
||||
oven._setpoint_bounds resolves its pair in one place.
|
||||
"""
|
||||
lo = _num(rep.get('x.com.samsung.da.minimum'))
|
||||
hi = _num(rep.get('x.com.samsung.da.maximum'))
|
||||
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
|
||||
|
||||
|
||||
def _step(rep, default):
|
||||
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
|
||||
step = _num(rep.get('x.com.samsung.da.increment'))
|
||||
return default if step is None else step
|
||||
|
||||
|
||||
ZONE_POWER = Capability(
|
||||
href='/power/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='zone_power', field='x.com.samsung.da.power',
|
||||
icon='mdi:radiator',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
ZONE_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='zone_mode', rep_fn=_first_mode,
|
||||
icon='mdi:sun-snowflake-variant',
|
||||
options_field='x.com.samsung.da.supportedModes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
|
||||
),
|
||||
)
|
||||
|
||||
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
|
||||
# room setpoint, not a literal water temperature -- Samsung EHS zone control
|
||||
# is leaving-water-temperature-based, same convention as the dhw loop below.
|
||||
ZONE_TEMPERATURE = Capability(
|
||||
href='/temperatures/indoor/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='zone_temperature', field='x.com.samsung.da.current',
|
||||
device_class='temperature', unit_fn=_temp_unit,
|
||||
state_class='measurement', value_fn=_num),
|
||||
NumberDesc(key='zone_target_temperature', field='x.com.samsung.da.desired',
|
||||
device_class='temperature', unit_fn=_temp_unit,
|
||||
entity_category='config', value_fn=_num,
|
||||
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
|
||||
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
|
||||
step_fn=lambda rep: _step(rep, 0.5),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['temperatures', 'indoor', 'vs', '0'],
|
||||
{'x.com.samsung.da.desired': str(float(p))})),
|
||||
),
|
||||
)
|
||||
|
||||
# Canonical dhw resource hrefs. water_heater.py binds the primary HREF_DHW_MODE
|
||||
# via DHW below and reads the sibling power/temperature hrefs off the
|
||||
# coordinator snapshot -- same primary-plus-siblings shape as
|
||||
# airconditioner.py's HREF_MODE/CLIMATE_CONSUMED_HREFS. Declared once here
|
||||
# and imported by water_heater.py, so a new sibling read can't drift out of
|
||||
# sync with its DHW_CONSUMED_HREFS coverage entry below.
|
||||
HREF_DHW_POWER = '/power/dhw/vs/0' # on/off
|
||||
HREF_DHW_MODE = '/mode/dhw/vs/0' # primary (bound by DHW) -- current_operation
|
||||
HREF_DHW_TEMPERATURE = '/temperatures/dhw/vs/0' # current/target temperature
|
||||
|
||||
DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
|
||||
|
||||
|
||||
def _dhw_write(payload, rep, href=None):
|
||||
"""Map a (kind, value) command from the water_heater platform to the
|
||||
(path_segs, body) for that one sub-write -- same contract as
|
||||
airconditioner._climate_write, just across the dhw loop's three
|
||||
resources instead of the AC's power/mode/temperature/wind set."""
|
||||
kind, value = payload
|
||||
if kind == 'power':
|
||||
return (['power', 'dhw', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
if kind == 'mode':
|
||||
return (['mode', 'dhw', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
|
||||
if kind == 'temperature':
|
||||
return (['temperatures', 'dhw', 'vs', '0'],
|
||||
{'x.com.samsung.da.desired': str(float(value))})
|
||||
return None
|
||||
|
||||
|
||||
DHW = Capability(
|
||||
href=HREF_DHW_MODE,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
WaterHeaterDesc(key='water_heater', translation_key='dhw',
|
||||
rep_fn=_first_mode, write_fn=_dhw_write),
|
||||
),
|
||||
)
|
||||
|
||||
# Power and temperature are read by the composite DHW entity above, not
|
||||
# given their own entities -- coverage-only caps so discover() reports no
|
||||
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS for the same pattern).
|
||||
DHW_CONSUMED = [Capability(href=h, poll_tier='warm') for h in DHW_CONSUMED_HREFS]
|
||||
|
||||
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
|
||||
# HA core's smartthings water_heater does wire this same Samsung capability
|
||||
# (CUSTOM_OUTING_MODE) up to WaterHeaterEntityFeature.AWAY_MODE, and the DHW
|
||||
# operation-mode map above is taken from that integration -- so the
|
||||
# divergence is worth stating. /option/outgoing/vs/0 is device-wide: one
|
||||
# `away` flag covering the whole unit, zone1 included (it has no dhw-scoped
|
||||
# sibling href, unlike every other resource in this loop). Hanging it off
|
||||
# the DHW card would present a device-wide setting as if it only affected
|
||||
# hot water. It stays a switch until a board turns up with a per-loop away
|
||||
# resource to bind instead.
|
||||
AWAY_MODE = Capability(
|
||||
href='/option/outgoing/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='away_mode', field='x.com.samsung.da.away',
|
||||
icon='mdi:home-export-outline',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'outgoing', 'vs', '0'],
|
||||
{'x.com.samsung.da.away': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EHS-scoped coverage: opaque vendor plumbing (hex-encoded factory/cycle/
|
||||
# schedule blobs) or resources with no confirmed write contract on this
|
||||
# dump, following the same 'don't guess' rule as dehumidifier._DHM_IGNORED.
|
||||
# Not in the global ignored.IGNORED since these are EHS-only shapes that
|
||||
# would need their own verification on other device families.
|
||||
# ---------------------------------------------------------------------------
|
||||
_EHS_IGNORED = [
|
||||
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: EHS)
|
||||
'/da/softreset/vs/0', # soft-reset trigger plumbing
|
||||
'/diagnosis/vs/0', # empty {} on this dump
|
||||
'/ehscycle/vs/0', # opaque hex-encoded indoor/outdoor cycle log
|
||||
'/ehsfsv/vs/0', # opaque hex-encoded factory setting values
|
||||
'/option/dhwdisplay/vs/0', # front-panel DHW-display show/hide, cosmetic only
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/sac/installationinfo/vs/0', # static outdoor/indoor installation info, diagnostic only
|
||||
'/actions/zone1/vs/0', # zone1 schedule/timer program -- unmodeled for now
|
||||
'/actions/dhw/vs/0', # DHW schedule/timer program -- unmodeled for now
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
|
||||
@@ -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)
|
||||
@@ -128,6 +133,16 @@ class FanDesc(SamsungEntityDescription):
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class WaterHeaterDesc(SamsungEntityDescription):
|
||||
# Composite water_heater entity: binds one primary resource (its href,
|
||||
# typically an operation-mode resource) but the water_heater platform
|
||||
# reads sibling resources (power, temperature) from the coordinator
|
||||
# snapshot and writes to several of them. Same (kind, value) -> (path_segs,
|
||||
# body) write_fn shape as ClimateDesc/FanDesc.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
PLATFORM_OF: dict[type, str] = {
|
||||
SensorDesc: 'sensor',
|
||||
BinarySensorDesc: 'binary_sensor',
|
||||
@@ -138,4 +153,5 @@ PLATFORM_OF: dict[type, str] = {
|
||||
TimeDesc: 'time',
|
||||
ClimateDesc: 'climate',
|
||||
FanDesc: 'fan',
|
||||
WaterHeaterDesc: 'water_heater',
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -36,7 +36,18 @@ the only thing ever actually confirmed to work for this pattern -- on the
|
||||
assumption that a composite device's siblings share the master's resource
|
||||
surface. See `Subdevice.flat_hrefs`.
|
||||
|
||||
Both are "the same thing wearing different clothes": a logical subdevice is a
|
||||
Pattern C -- UUID prefix advertised only via `/oic/res`
|
||||
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
|
||||
`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0`
|
||||
(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer
|
||||
subdevice's UUID appears nowhere except as the path prefix of the
|
||||
`x.com.samsung.da.multidevice` link in `/oic/res`, and
|
||||
`GET /<uuid>/device/0` answers the washer's own full Collection batch
|
||||
(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's
|
||||
transform with the UUID sourced from the link prefix instead of
|
||||
`subdeviceIdList`.
|
||||
|
||||
All of these are "the same thing wearing different clothes": a logical subdevice is a
|
||||
seed collection path to poll, plus an href transform between the canonical
|
||||
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
|
||||
detection signals don't overlap on either captured board (the Pattern A
|
||||
@@ -80,7 +91,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
import cbor2
|
||||
|
||||
@@ -88,6 +99,12 @@ from .batch import parse_device0_batch
|
||||
|
||||
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
|
||||
|
||||
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
|
||||
# discovery signal (issue #241): a subdevice tree whose UUID is advertised
|
||||
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
|
||||
_UUID_PREFIX_RE = re.compile(
|
||||
r'^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/')
|
||||
|
||||
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
|
||||
# second logical subdevice's Collection on this board (moved here from
|
||||
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
|
||||
@@ -333,22 +350,26 @@ def enumerate_subdevices(
|
||||
"""
|
||||
subdevices: list[Subdevice] = []
|
||||
fetched: dict[str, dict] = {}
|
||||
# Case-insensitive -- the same UUID can reach here once from
|
||||
# subdeviceIdList and once from an /oic/res link prefix with different
|
||||
# casing (Samsung's own fields disagree on this elsewhere too, e.g. the
|
||||
# redaction-prone subdeviceIdList handling below), and probing it twice
|
||||
# would materialize the same physical subdevice as two Subdevice
|
||||
# candidates under two different keys.
|
||||
probed_ids: set[str] = set()
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
probe_log(seed_href, bool(batch))
|
||||
|
||||
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
||||
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
|
||||
'x.com.samsung.da.subdeviceIdList')
|
||||
# Tolerate anything but a list of strings -- this field is redaction-prone
|
||||
# (it matches the 'deviceid' substring rule in redact.py) and the existing
|
||||
# airconditioner_fac_bora fixture carries the literal string
|
||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
|
||||
# crash -- issue #177 is additive, it must never break an already-working
|
||||
# single-climate-entity device.
|
||||
ids = raw_ids if isinstance(raw_ids, list) else []
|
||||
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
|
||||
def _probe_prefixed(sub_id: str) -> None:
|
||||
"""Materialize one UUID-prefixed subdevice candidate -- shared by
|
||||
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
|
||||
/oic/res link prefixes) below, which differ only in where the UUID
|
||||
came from."""
|
||||
if sub_id.lower() in probed_ids:
|
||||
return
|
||||
probed_ids.add(sub_id.lower())
|
||||
seed = (sub_id, 'device', '0')
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
@@ -356,7 +377,7 @@ def enumerate_subdevices(
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
continue
|
||||
return
|
||||
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
|
||||
# pattern was built against -- turns out not to always expose its own
|
||||
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
|
||||
@@ -393,12 +414,53 @@ def enumerate_subdevices(
|
||||
flat_hrefs.append(href)
|
||||
fetched[actual] = rep
|
||||
if not flat_hrefs:
|
||||
continue
|
||||
return
|
||||
subdevices.append(Subdevice(
|
||||
kind='prefixed', key=sub_id, seed_path=(),
|
||||
flat_hrefs=tuple(flat_hrefs),
|
||||
))
|
||||
|
||||
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
||||
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
|
||||
'x.com.samsung.da.subdeviceIdList')
|
||||
# Tolerate anything but a list of strings -- this field is redaction-prone
|
||||
# (it matches the 'deviceid' substring rule in redact.py) and the existing
|
||||
# airconditioner_fac_bora fixture carries the literal string
|
||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
|
||||
# crash -- issue #177 is additive, it must never break an already-working
|
||||
# single-climate-entity device.
|
||||
ids = raw_ids if isinstance(raw_ids, list) else []
|
||||
listed = sorted(i for i in ids if isinstance(i, str) and i)
|
||||
for sub_id in listed:
|
||||
_probe_prefixed(sub_id)
|
||||
|
||||
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
||||
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third
|
||||
# multidevice shape: the board answers numofsubdevice='2' on
|
||||
# /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList
|
||||
# -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's).
|
||||
# The only trace of the sibling is a UUID-prefixed link in /oic/res
|
||||
# itself: the x.com.samsung.da.multidevice link,
|
||||
# '/<uuid>/multidevice/vs/0' on the reporting board. Its washer tree
|
||||
# answers a full Collection at /<uuid>/device/0, exactly Pattern B's
|
||||
# transform -- so treat every UUID path prefix seen in /oic/res as a
|
||||
# prefixed-subdevice candidate. Probing is the same tolerated-404
|
||||
# RETRIEVE as everything else here, and discover_partitioned's
|
||||
# entity-level liveness gate still decides materialization, so a board
|
||||
# that advertises a UUID link without a live sibling behind it
|
||||
# contributes nothing. _probe_prefixed's probed_ids guard -- not a set
|
||||
# difference against `listed` here -- is what keeps an id already named
|
||||
# by subdeviceIdList from being probed and materialized a second time,
|
||||
# since the two sources can disagree on that UUID's case.
|
||||
linked = sorted({
|
||||
m.group(1)
|
||||
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||
for m in [_UUID_PREFIX_RE.match(link.get('href', ''))]
|
||||
if m
|
||||
})
|
||||
for sub_id in linked:
|
||||
_probe_prefixed(sub_id)
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted({
|
||||
int(m.group(1))
|
||||
@@ -519,10 +581,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 +614,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 +647,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, [])
|
||||
|
||||
@@ -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
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"auto_clean_running": {
|
||||
"name": "Auto clean running"
|
||||
},
|
||||
"after_run_active": {
|
||||
"name": "After run active"
|
||||
},
|
||||
@@ -10,12 +13,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 +43,9 @@
|
||||
"overload_protection_active": {
|
||||
"name": "Overload protection active"
|
||||
},
|
||||
"odor_controller_active": {
|
||||
"name": "Odor controller active"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "Running"
|
||||
},
|
||||
@@ -190,6 +202,9 @@
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropical night mode"
|
||||
},
|
||||
"zone_target_temperature": {
|
||||
"name": "Zone target temperature"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -292,19 +307,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": {
|
||||
@@ -313,6 +334,15 @@
|
||||
"favorite_hotwater_temperature": {
|
||||
"name": "Favorite hot water temperature"
|
||||
},
|
||||
"filter_alarm_time": {
|
||||
"name": "Filter alarm interval",
|
||||
"state": {
|
||||
"180": "180 hours",
|
||||
"300": "300 hours",
|
||||
"500": "500 hours",
|
||||
"700": "700 hours"
|
||||
}
|
||||
},
|
||||
"finish_sound": {
|
||||
"name": "Finish sound",
|
||||
"state": {
|
||||
@@ -588,9 +618,15 @@
|
||||
"none": "Off",
|
||||
"cupboard": "Cupboard"
|
||||
}
|
||||
},
|
||||
"zone_mode": {
|
||||
"name": "Zone mode"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"auto_clean_progress": {
|
||||
"name": "Auto clean progress"
|
||||
},
|
||||
"after_run_progress": {
|
||||
"name": "After run progress"
|
||||
},
|
||||
@@ -600,6 +636,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filter usage hours"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Air quality standard"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Air sensing state"
|
||||
},
|
||||
@@ -612,12 +651,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"
|
||||
},
|
||||
@@ -646,6 +691,9 @@
|
||||
"outdoor_temperature": {
|
||||
"name": "Outdoor temperature"
|
||||
},
|
||||
"odor_controller_progress": {
|
||||
"name": "Odor controller progress"
|
||||
},
|
||||
"panel_status": {
|
||||
"name": "Panel status"
|
||||
},
|
||||
@@ -918,6 +966,9 @@
|
||||
"icestatus_stop": "Idle",
|
||||
"icestatus_run": "Making ice"
|
||||
}
|
||||
},
|
||||
"zone_temperature": {
|
||||
"name": "Zone temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
@@ -996,6 +1047,9 @@
|
||||
"display_light": {
|
||||
"name": "Display light"
|
||||
},
|
||||
"dnd": {
|
||||
"name": "Do not disturb"
|
||||
},
|
||||
"fast_preheat": {
|
||||
"name": "Fast preheat"
|
||||
},
|
||||
@@ -1076,9 +1130,21 @@
|
||||
},
|
||||
"wrinkle_prevent": {
|
||||
"name": "Wrinkle prevent"
|
||||
},
|
||||
"zone_power": {
|
||||
"name": "Zone power"
|
||||
},
|
||||
"away_mode": {
|
||||
"name": "Away mode"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"dnd_end": {
|
||||
"name": "Do not disturb end"
|
||||
},
|
||||
"dnd_start": {
|
||||
"name": "Do not disturb start"
|
||||
},
|
||||
"led_night_end": {
|
||||
"name": "Door LED night end"
|
||||
},
|
||||
@@ -1091,6 +1157,11 @@
|
||||
"night_start": {
|
||||
"name": "Night light start"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
"dhw": {
|
||||
"name": "Hot water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
@@ -1138,15 +1209,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": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"auto_clean_running": {
|
||||
"name": "Auto clean actief"
|
||||
},
|
||||
"after_run_active": {
|
||||
"name": "Nadraaien actief"
|
||||
},
|
||||
@@ -10,11 +13,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 +32,7 @@
|
||||
"name": "Stofzak vol"
|
||||
},
|
||||
"cooktop_power": {
|
||||
"name": "Kookplaat voeding"
|
||||
"name": "Voeding kookplaat"
|
||||
},
|
||||
"cooktop_safety_shutoff_enabled": {
|
||||
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
|
||||
@@ -34,6 +43,9 @@
|
||||
"overload_protection_active": {
|
||||
"name": "Overbelastingsbeveiliging actief"
|
||||
},
|
||||
"odor_controller_active": {
|
||||
"name": "Geurbeheersing actief"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "Actief"
|
||||
},
|
||||
@@ -74,7 +86,7 @@
|
||||
"name": "Schenken"
|
||||
},
|
||||
"alarm_in_mute": {
|
||||
"name": "Alarm bij dempen"
|
||||
"name": "Alarmdemping"
|
||||
},
|
||||
"power_state": {
|
||||
"name": "Voedingsstatus"
|
||||
@@ -190,6 +202,9 @@
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropische nachtmodus"
|
||||
},
|
||||
"zone_target_temperature": {
|
||||
"name": "Doeltemperatuur zone"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -215,7 +230,7 @@
|
||||
}
|
||||
},
|
||||
"cooler_temperature_setpoint": {
|
||||
"name": "Koeler temperatuur"
|
||||
"name": "Temperatuur koelgedeelte"
|
||||
},
|
||||
"day_brightness": {
|
||||
"name": "Helderheid kast",
|
||||
@@ -292,19 +307,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": {
|
||||
@@ -313,6 +334,15 @@
|
||||
"favorite_hotwater_temperature": {
|
||||
"name": "Favoriete temperatuur warm water"
|
||||
},
|
||||
"filter_alarm_time": {
|
||||
"name": "Filteralarminterval",
|
||||
"state": {
|
||||
"180": "180 uur",
|
||||
"300": "300 uur",
|
||||
"500": "500 uur",
|
||||
"700": "700 uur"
|
||||
}
|
||||
},
|
||||
"finish_sound": {
|
||||
"name": "Eindgeluid",
|
||||
"state": {
|
||||
@@ -481,24 +511,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": {
|
||||
@@ -561,17 +591,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"
|
||||
}
|
||||
@@ -588,9 +618,15 @@
|
||||
"none": "Uit",
|
||||
"cupboard": "Kastdroog"
|
||||
}
|
||||
},
|
||||
"zone_mode": {
|
||||
"name": "Zonemodus"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"auto_clean_progress": {
|
||||
"name": "Auto clean voortgang"
|
||||
},
|
||||
"after_run_progress": {
|
||||
"name": "Voortgang nadraaien"
|
||||
},
|
||||
@@ -600,6 +636,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filterverbruik (uren)"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Luchtkwaliteitsnorm"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Status luchtmeting"
|
||||
},
|
||||
@@ -612,12 +651,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"
|
||||
},
|
||||
@@ -629,7 +674,7 @@
|
||||
}
|
||||
},
|
||||
"cooktop_running_state": {
|
||||
"name": "Loopstatus kookplaat"
|
||||
"name": "Bedrijfsstatus kookplaat"
|
||||
},
|
||||
"cooktop_state": {
|
||||
"name": "Status kookplaat"
|
||||
@@ -646,6 +691,9 @@
|
||||
"outdoor_temperature": {
|
||||
"name": "Buitentemperatuur"
|
||||
},
|
||||
"odor_controller_progress": {
|
||||
"name": "Voortgang geurbeheersing"
|
||||
},
|
||||
"panel_status": {
|
||||
"name": "Paneelstatus"
|
||||
},
|
||||
@@ -662,10 +710,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"
|
||||
@@ -759,7 +807,7 @@
|
||||
"name": "Geschatte eindtijd"
|
||||
},
|
||||
"completion_minutes": {
|
||||
"name": "Voltooiingstijd"
|
||||
"name": "Resterende tijd"
|
||||
},
|
||||
"freezer_temperature": {
|
||||
"name": "Vriezertemperatuur"
|
||||
@@ -840,13 +888,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"
|
||||
@@ -888,7 +936,7 @@
|
||||
"name": "Status"
|
||||
},
|
||||
"cup_state": {
|
||||
"name": "Kopstatus"
|
||||
"name": "Bekerstatus"
|
||||
},
|
||||
"last_pour_type": {
|
||||
"name": "Laatste schenktype"
|
||||
@@ -918,6 +966,9 @@
|
||||
"icestatus_stop": "Inactief",
|
||||
"icestatus_run": "IJs maken"
|
||||
}
|
||||
},
|
||||
"zone_temperature": {
|
||||
"name": "Zonetemperatuur"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
@@ -996,6 +1047,9 @@
|
||||
"display_light": {
|
||||
"name": "Displayverlichting"
|
||||
},
|
||||
"dnd": {
|
||||
"name": "Niet storen"
|
||||
},
|
||||
"fast_preheat": {
|
||||
"name": "Snel voorverwarmen"
|
||||
},
|
||||
@@ -1024,7 +1078,7 @@
|
||||
"name": "{instance_name} ingeschakeld"
|
||||
},
|
||||
"intensive": {
|
||||
"name": "Intensive"
|
||||
"name": "Intensief"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lamp"
|
||||
@@ -1076,9 +1130,21 @@
|
||||
},
|
||||
"wrinkle_prevent": {
|
||||
"name": "Kreukpreventie"
|
||||
},
|
||||
"zone_power": {
|
||||
"name": "Zonevoeding"
|
||||
},
|
||||
"away_mode": {
|
||||
"name": "Afwezigheidsmodus"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"dnd_end": {
|
||||
"name": "Einde niet storen"
|
||||
},
|
||||
"dnd_start": {
|
||||
"name": "Start niet storen"
|
||||
},
|
||||
"led_night_end": {
|
||||
"name": "Einde nachtverlichting deur-LED"
|
||||
},
|
||||
@@ -1091,6 +1157,11 @@
|
||||
"night_start": {
|
||||
"name": "Start nachtverlichting"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
"dhw": {
|
||||
"name": "Warm water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
@@ -1138,15 +1209,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": {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Water heater platform for Local Things.
|
||||
|
||||
Second composite entity in this integration (see climate.py's module
|
||||
docstring for the general pattern this follows): a single HA water_heater
|
||||
card for a Samsung EHS heat pump's domestic hot water (DHW) loop. It binds
|
||||
the primary `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities
|
||||
in registry/capabilities/ehs.py) so the registry still tracks it, and reads
|
||||
the sibling `/power/dhw/vs/0` and `/temperatures/dhw/vs/0` resources straight
|
||||
from the coordinator snapshot -- the same cross-resource read climate.py uses
|
||||
for the AC's power/temperature/wind siblings.
|
||||
|
||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
|
||||
DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the
|
||||
right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs
|
||||
and applies the optimistic value/settle guard to that same href -- not the
|
||||
bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets
|
||||
fresh state back for, power, mode and temperature alike.
|
||||
|
||||
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
|
||||
Power) map onto HA's own standard water_heater states -- the same mapping
|
||||
Home Assistant's core `smartthings` integration uses for this exact Samsung
|
||||
capability over the cloud API (`samsungce.ehsThermostat` /
|
||||
`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/
|
||||
STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF
|
||||
resource's own code spelling. Reusing HA's standard states means no *state*
|
||||
translation catalog entry is needed for them (see the entity_component
|
||||
fallback in homeassistant.components.water_heater.strings.json).
|
||||
|
||||
Naming is a separate question from that, and the answer here differs from
|
||||
climate.py's: the AC *is* the device, so its climate card takes the bare
|
||||
device name (`_attr_name = None`). An EHS unit has two loops, and the DHW
|
||||
one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target
|
||||
Temperature", so a card labelled just "EHS" would misrepresent which loop
|
||||
it drives. This entity is named through the catalog like every other
|
||||
descriptor here, via the DHW descriptor's `translation_key='dhw'`
|
||||
(entity.water_heater.dhw.name -> "Hot water").
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
STATE_HEAT_PUMP,
|
||||
STATE_HIGH_DEMAND,
|
||||
STATE_PERFORMANCE,
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.capabilities.ehs import (
|
||||
HREF_DHW_MODE as MODE_HREF,
|
||||
HREF_DHW_POWER as POWER_HREF,
|
||||
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
from .registry.entities import WaterHeaterDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
|
||||
# Device mode <-> HA water_heater operation state -- see the module
|
||||
# docstring above for the SmartThings-cloud precedent this mirrors.
|
||||
_DEVICE_TO_STATE: dict[str, str] = {
|
||||
'Eco': STATE_ECO,
|
||||
'Std': STATE_HEAT_PUMP,
|
||||
'Force': STATE_HIGH_DEMAND,
|
||||
'Power': STATE_PERFORMANCE,
|
||||
}
|
||||
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
|
||||
|
||||
# Read-side lookup, case-folded. climate.py resolves write codes from the
|
||||
# unit's own supportedModes because two spellings there mean one HA value
|
||||
# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can
|
||||
# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board
|
||||
# spelling the same code differently ('eco'/'ECO'), and case is the one
|
||||
# variation worth absorbing rather than warning about.
|
||||
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
|
||||
|
||||
|
||||
def _to_state(code) -> str | None:
|
||||
if code is None:
|
||||
return None
|
||||
return _DEVICE_TO_STATE_CI.get(str(code).lower())
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
LocalThingsWaterHeater(coordinator, b)
|
||||
for b in coordinator.bound
|
||||
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
|
||||
)
|
||||
|
||||
|
||||
def _num(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _first(value):
|
||||
"""Samsung `modes` is a single-element list on this resource. Return the
|
||||
first element of a list, else the value itself."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
|
||||
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
||||
"""Composite water_heater entity for a Samsung EHS DHW loop."""
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
# No _attr_name here: unlike climate.py's AC, this is one loop of a
|
||||
# two-loop device and takes a catalog name ("Hot water") through the
|
||||
# descriptor's translation_key -- see the module docstring.
|
||||
self._attr_supported_features = (
|
||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||
| WaterHeaterEntityFeature.ON_OFF
|
||||
)
|
||||
# Raw device codes already logged by _warn_unmapped -- these
|
||||
# properties are read on every coordinator refresh, so an un-deduped
|
||||
# warning would spam the log for any unit reporting a genuinely
|
||||
# unrecognized code.
|
||||
self._warned_unmapped: set[str] = set()
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
"""`href` is one of this module's canonical HREF_* constants --
|
||||
translated through this bound entity's own subdevice (issue #177),
|
||||
same as climate.py's identical helper."""
|
||||
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||
|
||||
def _is_on(self) -> bool:
|
||||
return str(self._rep(POWER_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
|
||||
|
||||
def _supported(self) -> list[str]:
|
||||
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
|
||||
|
||||
def _warn_unmapped(self, code: str) -> None:
|
||||
if code in self._warned_unmapped:
|
||||
return
|
||||
self._warned_unmapped.add(code)
|
||||
_LOGGER.warning(
|
||||
"%s: device DHW mode %r has no HA mapping and was dropped; "
|
||||
"please file an issue with your diagnostics dump",
|
||||
self.entity_id, code,
|
||||
)
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
raw = self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.unit')
|
||||
return (UnitOfTemperature.FAHRENHEIT
|
||||
if normalize_temp_unit(raw, '°C') == '°F'
|
||||
else UnitOfTemperature.CELSIUS)
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.current'))
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.desired'))
|
||||
|
||||
def _range(self) -> list | None:
|
||||
"""The device's own (minimum, maximum) pair, or None.
|
||||
|
||||
Both ends together or neither, deliberately -- same rule as
|
||||
climate._range(). A board reporting minimum but not maximum would
|
||||
otherwise pair a device minimum (40) with HA's own default maximum
|
||||
(140 °F), which looks plausible and is silently wrong on a unit
|
||||
that really allows 62.
|
||||
"""
|
||||
rep = self._rep(TEMPERATURE_HREF)
|
||||
lo = _num(rep.get('x.com.samsung.da.minimum'))
|
||||
hi = _num(rep.get('x.com.samsung.da.maximum'))
|
||||
return [lo, hi] if (lo is not None and hi is not None) else None
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
r = self._range()
|
||||
return r[0] if r else super().min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
r = self._range()
|
||||
return r[1] if r else super().max_temp
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float:
|
||||
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0
|
||||
# into the fallback.
|
||||
step = _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.increment'))
|
||||
return 0.5 if step is None else step
|
||||
|
||||
# -- operation mode -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
if not self._is_on():
|
||||
return STATE_OFF
|
||||
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||
mapped = _to_state(code)
|
||||
if code is not None and mapped is None:
|
||||
self._warn_unmapped(code)
|
||||
return mapped
|
||||
|
||||
@property
|
||||
def operation_list(self) -> list[str]:
|
||||
modes = [STATE_OFF]
|
||||
for code in self._supported():
|
||||
mapped = _to_state(code)
|
||||
if mapped is None:
|
||||
self._warn_unmapped(code)
|
||||
continue
|
||||
if mapped not in modes:
|
||||
modes.append(mapped)
|
||||
return modes
|
||||
|
||||
# -- writes ---------------------------------------------------------------
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
# HA's water_heater.set_temperature service takes an optional
|
||||
# operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same
|
||||
# as climate.set_temperature does with hvac_mode. Honour it, and set
|
||||
# it first -- that also powers the loop on when it was off -- so a
|
||||
# dashboard "boost to 55" button that carries a mode actually changes
|
||||
# mode, instead of only moving the setpoint. Same fix as the AC's
|
||||
# (see climate.async_set_temperature).
|
||||
operation_mode = kwargs.get('operation_mode')
|
||||
if operation_mode is not None:
|
||||
await self.async_set_operation_mode(operation_mode)
|
||||
if operation_mode == STATE_OFF:
|
||||
return
|
||||
temp = kwargs.get('temperature')
|
||||
if temp is None:
|
||||
return
|
||||
await self.coordinator.async_send_command(self._bound, ('temperature', temp))
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
if operation_mode == STATE_OFF:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
||||
return
|
||||
device = _STATE_TO_DEVICE.get(operation_mode)
|
||||
if device is None:
|
||||
return
|
||||
if not self._is_on():
|
||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', device))
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
||||
+414
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+759
@@ -0,0 +1,759 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-01T21:32:45",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/indoor/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.type": "Water",
|
||||
"x.com.samsung.da.desired": "5.0",
|
||||
"x.com.samsung.da.current": "30.0",
|
||||
"x.com.samsung.da.maximum": "25.0",
|
||||
"x.com.samsung.da.minimum": "5.0",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.offset": "0.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.type": "Water",
|
||||
"x.com.samsung.da.unit": "Celsius",
|
||||
"x.com.samsung.da.desired": "40.0",
|
||||
"x.com.samsung.da.current": "38.0",
|
||||
"x.com.samsung.da.maximum": "62.0",
|
||||
"x.com.samsung.da.minimum": "40.0",
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "2448844",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Heat",
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OutdoorTemp_80",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit",
|
||||
"Thermostat_CoolHeat_Off"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Thermostat_CoolHeat",
|
||||
"DurationOn",
|
||||
"KeyInputPermit",
|
||||
"ModePermit",
|
||||
"Volume",
|
||||
"OutdoorTemp"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Eco",
|
||||
"Std",
|
||||
"Power",
|
||||
"Force"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Eco"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Thermostat_Dhw_Off",
|
||||
"KeyInputPermit_On"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Thermostat_Dhw",
|
||||
"KeyInputPermit"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000",
|
||||
"x.com.samsung.da.description": "TP1X_DA_AC_EHS_01001_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AE0",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"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": "02504A260402",
|
||||
"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": "02501A24062401,FFFFFFFFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "02572A23081000,02549A10000800"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0001003200FA0B0190026C0C0000CE32",
|
||||
"x.com.samsung.da.id": "EHS",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AEH-WW-TP1-22-AE6000",
|
||||
"versions": [
|
||||
"17260402"
|
||||
],
|
||||
"visVersion": "260402"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "040010250141FFFFFFFF",
|
||||
"versions": [
|
||||
"24062401",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240624"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04001025724110254941",
|
||||
"versions": [
|
||||
"23081000",
|
||||
"10000800"
|
||||
],
|
||||
"visVersion": "230810"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/London",
|
||||
"offset": "+01:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0EFFFFFFFF05190F370B283E",
|
||||
"x.com.samsung.da.id": "EHS",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/option/outgoing/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.away": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "12300000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "20010000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "00"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "02572A230810",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"info": "000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ehsfsv/vs/0",
|
||||
"rep": {
|
||||
"items": [
|
||||
{
|
||||
"setting": "0407010A01017202EE02260101"
|
||||
},
|
||||
{
|
||||
"setting": "0408010A010096017200960101"
|
||||
},
|
||||
{
|
||||
"setting": "041B010A0101F402BC026C0101"
|
||||
},
|
||||
{
|
||||
"setting": "041C010A01012C019001900101"
|
||||
},
|
||||
{
|
||||
"setting": "07DB010A01FF380032FFE20101"
|
||||
},
|
||||
{
|
||||
"setting": "07DC010A01006400C800960101"
|
||||
},
|
||||
{
|
||||
"setting": "07E5010A0100AA02EE01540101"
|
||||
},
|
||||
{
|
||||
"setting": "07E6010A0100AA02EE014A0101"
|
||||
},
|
||||
{
|
||||
"setting": "07EF010A0100AA02EE028A0101"
|
||||
},
|
||||
{
|
||||
"setting": "07F0010A0100AA02EE00AA0101"
|
||||
},
|
||||
{
|
||||
"setting": "082B0101000000000400000101"
|
||||
},
|
||||
{
|
||||
"setting": "082C0101000000000400000101"
|
||||
},
|
||||
{
|
||||
"setting": "082D0101000001000400040101"
|
||||
},
|
||||
{
|
||||
"setting": "0BC30101000000000200020101"
|
||||
},
|
||||
{
|
||||
"setting": "0BFF0101000000000100000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FAB0101000000000100000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FAC010A01FF6A00C800000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FB50101000000000200000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FCA010A010032009600640101"
|
||||
},
|
||||
{
|
||||
"setting": "0FDD0101000000000100000101"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ehscycle/vs/0",
|
||||
"rep": {
|
||||
"indoor": [
|
||||
{
|
||||
"cycledata": "4B055455050500000000000000000000000000000004CCA70000005D05054B",
|
||||
"datetime": "2026-08-01T21:23:08"
|
||||
},
|
||||
{
|
||||
"cycledata": "4B055454050500000000000000000000000000000004CCAC0000005D05054B",
|
||||
"datetime": "2026-08-01T21:28:08"
|
||||
}
|
||||
],
|
||||
"outdoor": [
|
||||
{
|
||||
"cycledata": "00000000555A4A53490207D000000000FFFF00480046325700000000",
|
||||
"datetime": "2026-08-01T21:23:08"
|
||||
},
|
||||
{
|
||||
"cycledata": "00000000555A4952490207D000000000000000480046325600000000",
|
||||
"datetime": "2026-08-01T21:28:08"
|
||||
}
|
||||
],
|
||||
"unit": "Celsius"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/dhwdisplay/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.dhwdisplay": "Show"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/actions/zone1/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/actions/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "0",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Sun"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "1",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Mon"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "2",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Tue"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "3",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Wed"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "4",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Thu"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "5",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Fri"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "6",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Sat"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+24
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
+2
@@ -6,6 +6,8 @@
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"clean_level",
|
||||
"climate",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -14,6 +16,8 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_alarm_time",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_alarm_time",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_alarm_time",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
|
||||
+5
-1
@@ -8,6 +8,8 @@
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -20,6 +22,8 @@
|
||||
"motion_detect_wind_active",
|
||||
"motion_detect_wind_mode",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
@@ -27,4 +31,4 @@
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -13,6 +15,8 @@
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -13,6 +15,8 @@
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -13,6 +15,8 @@
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -18,6 +20,8 @@
|
||||
"motion_detect_wind_active",
|
||||
"motion_detect_wind_mode",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_limit_enabled",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -14,6 +16,8 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"clean_level",
|
||||
"climate",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -15,6 +17,8 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"overload_protection_active",
|
||||
"overload_protection_mode",
|
||||
"selfcheck_error",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
+2
@@ -6,6 +6,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"away_mode",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"mute_once",
|
||||
"water_heater",
|
||||
"zone_mode",
|
||||
"zone_power",
|
||||
"zone_target_temperature",
|
||||
"zone_temperature"
|
||||
]
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"cavity_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooking_mode",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"power_level",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"setpoint",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
+183
File diff suppressed because one or more lines are too long
+1364
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -452,3 +452,71 @@ def test_downgrade_to_poll_stops_refresh_task():
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert mgr._refresh_thread is None
|
||||
|
||||
|
||||
def test_try_enter_observe_mode_exits_early_when_fraction_reached():
|
||||
"""The grace wait returns as soon as enough hrefs notify, not after the
|
||||
whole grace ceiling. Production data (fridge TP2X_REF_20K, 2026-07-30):
|
||||
all subscribed hrefs notify within ~0.34s of subscribe, yet the old fixed
|
||||
time.sleep(15) waited the remaining ~14.6s anyway — every successful
|
||||
first-refresh / observe-retry fetch paid that dead time."""
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
hrefs = ['/a/vs/0', '/b/vs/0']
|
||||
|
||||
def _notify_after_subscribe():
|
||||
# Let try_enter clear _notified + subscribe first (matches the existing
|
||||
# test pattern's 0.005 lead), then notify well before the 2s ceiling.
|
||||
time.sleep(0.01)
|
||||
for href in hrefs:
|
||||
mgr.on_notification(href, cbor2.dumps({'x': 1}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_after_subscribe, daemon=True)
|
||||
notifier.start()
|
||||
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=2.0)
|
||||
elapsed = time.monotonic() - t0
|
||||
notifier.join()
|
||||
|
||||
assert entered is True
|
||||
assert mgr.mode == 'observe'
|
||||
# Early exit: returns near the notify (~0.01s), not the 2.0s ceiling.
|
||||
# The old fixed sleep would make this >= 2.0.
|
||||
assert elapsed < 0.5
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
|
||||
def test_try_enter_observe_mode_waits_full_ceiling_when_fraction_not_reached():
|
||||
"""Early-exit must NOT fire below the success fraction. A partial set of
|
||||
notifies that doesn't meet the threshold waits the full grace ceiling,
|
||||
then falls back to poll — identical to the old fixed sleep. Guards
|
||||
against an over-eager predicate that enters observe on too few notifies."""
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
hrefs = ['/a/vs/0', '/b/vs/0', '/c/vs/0', '/d/vs/0'] # 4 hrefs
|
||||
|
||||
def _notify_one():
|
||||
time.sleep(0.01)
|
||||
mgr.on_notification(hrefs[0], cbor2.dumps({'x': 1})) # 1/4 = 0.25
|
||||
|
||||
notifier = threading.Thread(target=_notify_one, daemon=True)
|
||||
notifier.start()
|
||||
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
entered = mgr.try_enter_observe_mode(
|
||||
session, hrefs, grace_period_s=0.2, success_fraction=0.8,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
notifier.join()
|
||||
|
||||
assert entered is False
|
||||
assert mgr.mode == 'poll'
|
||||
assert mgr.subscribed_hrefs == set()
|
||||
# Waited the full 0.2s ceiling (1/4 = 0.25 < 0.8); did not early-exit.
|
||||
assert elapsed >= 0.18
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
@@ -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'}
|
||||
@@ -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'}
|
||||
@@ -9,8 +9,8 @@ differs from all of them in three ways, each covered below:
|
||||
* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*``
|
||||
token in ``/mode/vs/0``'s ``options`` array.
|
||||
* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep,
|
||||
outdoor temperature, filter time) are ``options`` tokens too, where newer
|
||||
boards have dedicated resources.
|
||||
outdoor temperature, filter time and its alarm interval) are ``options``
|
||||
tokens too, where newer boards have dedicated resources.
|
||||
|
||||
Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware
|
||||
by read-back on the unit this fixture is dumped from. The issue #136 unit is
|
||||
@@ -103,8 +103,8 @@ def test_token_entities_present_with_calibrated_values():
|
||||
state = _state()
|
||||
# token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
|
||||
# official app on this unit, which pins the scale (the .5 here is a later
|
||||
# reading). Whether it counts up or down is deliberately not asserted --
|
||||
# see the descriptor comment.
|
||||
# reading). It counts up -- see the descriptor comment and
|
||||
# test_filter_alarm_tracks_the_counter_against_its_threshold below.
|
||||
assert state['filter_time'] == 171.5
|
||||
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
|
||||
assert state['outdoor_temperature'] == 19.0
|
||||
@@ -180,6 +180,82 @@ def test_option_writes_carry_one_token():
|
||||
)
|
||||
|
||||
|
||||
# -- filter counter and its reset ---------------------------------------------
|
||||
|
||||
def _desc(resources, key):
|
||||
"""The bound descriptor for `key`, or None when its exists_fn declines it.
|
||||
|
||||
flatten() gives values, not descriptors, so this is how a test reaches a
|
||||
descriptor's own write_fn and options without standing up an HA entity.
|
||||
"""
|
||||
bound, _ = _discover(resources)
|
||||
for item in bound:
|
||||
if item.desc.key == key and (
|
||||
item.desc.exists_fn is None
|
||||
or item.desc.exists_fn(resources.get(item.href) or {}, resources)
|
||||
):
|
||||
return item.desc
|
||||
return None
|
||||
|
||||
|
||||
def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
|
||||
"""The interval FilterTime_ is measured against, offered by the app as a
|
||||
180/300/500/700 hour radio. All four were walked on hardware while watching
|
||||
all 19 resources: the token carries the hour count verbatim and each step
|
||||
moved that one field and nothing else, which is also what makes a static
|
||||
options tuple defensible here (the board advertises no supported-values
|
||||
list for options[] tokens)."""
|
||||
state = _state()
|
||||
assert state['filter_alarm_time'] == '500' # the fixture's own value
|
||||
|
||||
desc = _desc(_load_device(FIXTURE), 'filter_alarm_time')
|
||||
assert desc.options == ('180', '300', '500', '700')
|
||||
assert desc.write_fn('180', {}) == (
|
||||
['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': ['FilterAlarmTime_180']},
|
||||
)
|
||||
|
||||
|
||||
def test_filter_alarm_time_stays_off_boards_with_a_real_threshold_resource():
|
||||
"""Newer boards carry air_filter_threshold off supportedFilterDesiredUsage;
|
||||
two thresholds on one device would be a coin flip for the user.
|
||||
|
||||
No non-legacy fixture carries a FilterAlarmTime_ token today (only the two
|
||||
KRAC dumps have one at all), so asserting on an unmodified newer board
|
||||
would pass for the wrong reason -- absent token rather than the
|
||||
board-generation gate. The token is injected to exercise the gate itself,
|
||||
test_absent_token_yields_no_entity's technique in reverse."""
|
||||
newer = _load_device('airconditioner_tp1x_rac')
|
||||
assert _desc(newer, 'filter_alarm_time') is None
|
||||
|
||||
mode = newer['/mode/vs/0']
|
||||
mode['x.com.samsung.da.options'] = [
|
||||
*(mode.get('x.com.samsung.da.options') or []), 'FilterAlarmTime_500',
|
||||
]
|
||||
assert is_legacy_board(newer) is False
|
||||
assert _desc(newer, 'filter_alarm_time') is None
|
||||
|
||||
|
||||
def test_filter_alarm_tracks_the_counter_against_its_threshold():
|
||||
"""Why filter_time is read as elapsed rather than remaining: the same
|
||||
options blob carries the threshold, and /alarms/vs/0's filter entry is a
|
||||
'FilterAlarm_OFF'/'Deleted' placeholder below it. The sibling unit on the
|
||||
same site, at FilterTime_5595 against the same FilterAlarmTime_500, instead
|
||||
reported an unsuffixed 'FilterAlarm' in state 'Created'."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources['/mode/vs/0']['x.com.samsung.da.options']
|
||||
assert 'FilterTime_1715' in options
|
||||
assert 'FilterAlarmTime_500' in options
|
||||
|
||||
alarms = resources['/alarms/vs/0']['x.com.samsung.da.items']
|
||||
filter_alarm = next(
|
||||
item for item in alarms
|
||||
if item['x.com.samsung.da.code'].startswith('FilterAlarm')
|
||||
)
|
||||
assert filter_alarm['x.com.samsung.da.code'] == 'FilterAlarm_OFF'
|
||||
assert filter_alarm['x.com.samsung.da.state'] == 'Deleted'
|
||||
|
||||
|
||||
# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
|
||||
|
||||
def test_fan_mode_reads_the_airflow_speed_level():
|
||||
|
||||
@@ -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
|
||||
|
||||
+223
-8
@@ -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."""
|
||||
|
||||
@@ -265,6 +337,20 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'dehumidifier'
|
||||
|
||||
def test_ehs_via_ehs_token(self):
|
||||
"""A Samsung EHS air-to-water heat pump (TP1X_DA_AC_EHS_01001_0000)
|
||||
shares the DA_AC_ board family with the room-AC models but reports
|
||||
no oneUiVersion and carries the '_EHS_' (Eco Heating System) token
|
||||
instead of '_RAC_'/'_PRAC_'/'_WAC_'/'_DHM_'; falls back to the
|
||||
'_EHS_' token in modelNum."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000',
|
||||
'TP1X_DA_AC_EHS_01001_0000',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'ehs'
|
||||
|
||||
def test_water_purifier_via_waterpurifier_token(self):
|
||||
"""Issue #90: a water purifier (TP2X_WATERPURIFIER_20K) reports no
|
||||
oneUiVersion and no consumer-prefix match; falls back to the
|
||||
@@ -376,6 +462,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,22 +679,114 @@ class TestOneUiVersionIsNotConsulted:
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_prefers_model_strings_over_resource_signature(self, all_device_fixtures):
|
||||
"""Every fixture with usable model strings resolves the same way
|
||||
through `resolve` as through `for_device_by_model` directly."""
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
resolve, for_device_by_model,
|
||||
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_resource_signatures_over_model_strings(self, all_device_fixtures):
|
||||
"""A strong live-resource signature wins over model metadata.
|
||||
|
||||
Across the fixture corpus Qooker is the only intentional disagreement:
|
||||
its OVEN model token says oven while its /oven + MicroWave surface says
|
||||
microwave. Locking the disagreement set keeps resource-first routing
|
||||
from silently becoming greedy as new signatures or fixtures land.
|
||||
"""
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
resolve, for_device_by_model, for_device_by_resources,
|
||||
)
|
||||
disagreements = {}
|
||||
for name, resources in all_device_fixtures.items():
|
||||
info = resources.get('/information/vs/0', {})
|
||||
by_resources = for_device_by_resources(resources)
|
||||
by_model = for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
if by_model is not None:
|
||||
assert resolve(resources) is by_model, name
|
||||
if by_resources is None or by_model is None:
|
||||
continue
|
||||
assert resolve(resources) is by_resources, name
|
||||
if by_resources is not by_model:
|
||||
disagreements[name] = (by_resources.name, by_model.name)
|
||||
|
||||
def test_falls_back_to_resource_signature(self, all_device_fixtures):
|
||||
assert disagreements == {
|
||||
'qooker_mw7500a': ('microwave', 'oven'),
|
||||
}
|
||||
|
||||
def test_qooker_microwave_surface_overrides_generic_oven_oic_type(self):
|
||||
"""MW7500A declares oic.d.oven and carries an OVEN board token, but
|
||||
its verified local API exposes MicroWave mode on an oven cavity. That
|
||||
strong two-resource signature must win ahead of generic OIC metadata."""
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
for_device_by_model,
|
||||
for_device_by_oic_type,
|
||||
for_device_by_resources,
|
||||
resolve,
|
||||
)
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('qooker_mw7500a')
|
||||
info = resources['/information/vs/0']
|
||||
by_resources = for_device_by_resources(resources)
|
||||
by_oic = for_device_by_oic_type(('oic.wk.d', 'oic.d.oven'))
|
||||
by_model = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'],
|
||||
info['x.com.samsung.da.description'],
|
||||
)
|
||||
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven'))
|
||||
|
||||
assert by_resources is not None and by_resources.name == 'microwave'
|
||||
assert by_oic is not None and by_oic.name == 'oven'
|
||||
assert by_model is not None and by_model.name == 'oven'
|
||||
assert reg is by_resources
|
||||
|
||||
def test_resource_signature_types_dumps_without_information(self, all_device_fixtures):
|
||||
"""The three dumps with no /information/vs/0 still type."""
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
for name in ('cooktop', 'range_ne63a6511', 'range_no_info'):
|
||||
@@ -702,3 +893,27 @@ class TestForDeviceByResources:
|
||||
assert reg is not None
|
||||
assert reg.name == 'microwave'
|
||||
|
||||
def test_microwave_mode_without_oven_cavity_is_not_matched(self):
|
||||
"""The mode vocabulary alone is too common to preempt metadata."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
|
||||
resources = {
|
||||
'/mode/vs/0': {
|
||||
'x.com.samsung.da.supportedModes': ['MicroWave'],
|
||||
},
|
||||
}
|
||||
|
||||
assert for_device_by_resources(resources) is None
|
||||
|
||||
def test_scalar_supported_modes_is_not_a_microwave_signature(self):
|
||||
"""Malformed scalar data must not gain resource-first precedence."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
|
||||
resources = {
|
||||
'/mode/vs/0': {
|
||||
'x.com.samsung.da.supportedModes': 'MicroWave',
|
||||
},
|
||||
'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'},
|
||||
}
|
||||
|
||||
assert for_device_by_resources(resources) is None
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for Samsung EHS (Eco Heating System) heat pump support
|
||||
(TP1X_DA_AC_EHS_01001_0000).
|
||||
|
||||
HA-free like the rest of the suite: exercises the registry, discovery/
|
||||
flatten, and the zone/dhw mode and temperature write contracts.
|
||||
"""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import NumberDesc, SelectDesc, WaterHeaterDesc
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _ehs():
|
||||
resources = _load_device('ehs')
|
||||
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 _bound():
|
||||
reg, resources = _ehs()
|
||||
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_model_resolves_to_ehs_registry():
|
||||
reg, _ = _ehs()
|
||||
assert reg is not None and reg.name == 'ehs'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the real TP1X_DA_AC_EHS_01001_0000 dump binds or is
|
||||
covered -- clears the coverage-gap repair a device_type='unknown' entry
|
||||
raises."""
|
||||
reg, resources = _ehs()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_expected_state_keys_present():
|
||||
state = _state()
|
||||
for key in ('zone_power', 'zone_mode', 'zone_temperature', 'zone_target_temperature',
|
||||
'water_heater', 'away_mode', 'mute_once', 'alarm_code', 'energy_kwh'):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_zone_temperature_reads_current_value():
|
||||
state = _state()
|
||||
assert state['zone_temperature'] == 30.0
|
||||
|
||||
|
||||
def test_zone_target_temperature_reads_desired_value():
|
||||
state = _state()
|
||||
assert state['zone_target_temperature'] == 5.0
|
||||
|
||||
|
||||
def test_zone_mode_reads_first_mode():
|
||||
state = _state()
|
||||
assert state['zone_mode'] == 'Cool'
|
||||
|
||||
|
||||
def test_zone_mode_select_options_come_from_live_supported_modes():
|
||||
"""Options are read live from x.com.samsung.da.supportedModes, not a
|
||||
hardcoded tuple -- so a future firmware with a different mode set is
|
||||
handled automatically."""
|
||||
desc = _desc('zone_mode')
|
||||
assert isinstance(desc, SelectDesc)
|
||||
assert desc.options_field == 'x.com.samsung.da.supportedModes'
|
||||
assert desc.options == ()
|
||||
|
||||
|
||||
def test_zone_mode_write_contract():
|
||||
desc = _desc('zone_mode')
|
||||
path, body = desc.write_fn('Heat', {})
|
||||
assert path == ['mode', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.modes': ['Heat']}
|
||||
|
||||
|
||||
def test_zone_power_reads_off():
|
||||
state = _state()
|
||||
assert state['zone_power'] is False
|
||||
|
||||
|
||||
def test_zone_power_write_contract():
|
||||
desc = _desc('zone_power')
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['power', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.power': 'On'}
|
||||
|
||||
|
||||
def test_zone_target_temperature_write_contract():
|
||||
desc = _desc('zone_target_temperature')
|
||||
assert isinstance(desc, NumberDesc)
|
||||
path, body = desc.write_fn('21.5', {})
|
||||
assert path == ['temperatures', 'indoor', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.desired': '21.5'}
|
||||
|
||||
|
||||
def test_zone_target_temperature_bounds_read_live():
|
||||
"""min/max/step come from the device's own resource fields rather than
|
||||
a hardcoded constant -- see the adding-device-support skill's 'never
|
||||
hard-code the one dump's values' section."""
|
||||
desc = _desc('zone_target_temperature')
|
||||
rep = {'x.com.samsung.da.minimum': '5.0', 'x.com.samsung.da.maximum': '25.0'}
|
||||
assert desc.native_min_fn(rep) == 5.0
|
||||
assert desc.native_max_fn(rep) == 25.0
|
||||
assert desc.step_fn({'x.com.samsung.da.increment': '0.5'}) == 0.5
|
||||
# No live field: falls back to a sane default rather than raising.
|
||||
assert desc.native_min_fn({}) == 5.0
|
||||
assert desc.native_max_fn({}) == 30.0
|
||||
assert desc.step_fn({}) == 0.5
|
||||
|
||||
|
||||
def test_zone_target_temperature_bounds_fall_back_together():
|
||||
"""One end without the other is not a usable range: pairing a real
|
||||
device minimum with an invented default maximum looks plausible and is
|
||||
silently wrong, so a half-reported range falls back whole."""
|
||||
desc = _desc('zone_target_temperature')
|
||||
half = {'x.com.samsung.da.minimum': '10.0'}
|
||||
assert desc.native_min_fn(half) == 5.0
|
||||
assert desc.native_max_fn(half) == 30.0
|
||||
|
||||
|
||||
def test_zone_target_temperature_zero_increment_is_not_collapsed():
|
||||
"""`or` would turn a genuine 0 into the 0.5 default (issue #160)."""
|
||||
desc = _desc('zone_target_temperature')
|
||||
assert desc.step_fn({'x.com.samsung.da.increment': '0'}) == 0.0
|
||||
|
||||
|
||||
def test_water_heater_entity_is_bound():
|
||||
"""The composite water_heater entity binds the primary /mode/dhw/vs/0
|
||||
resource -- same primary-plus-siblings shape as the AC's ClimateDesc
|
||||
(see test_airconditioner_capabilities.py's test_climate_entity_is_bound)."""
|
||||
bound, _ = _bound()
|
||||
water_heaters = [b for b in bound if isinstance(b.desc, WaterHeaterDesc)]
|
||||
assert len(water_heaters) == 1
|
||||
assert water_heaters[0].href == '/mode/dhw/vs/0'
|
||||
|
||||
|
||||
def test_water_heater_reads_first_mode():
|
||||
"""The flattened/golden state exposes the same representative scalar
|
||||
the entity's current_operation is derived from -- see climate.py's
|
||||
_first_mode for the identical pattern on the AC side."""
|
||||
state = _state()
|
||||
assert state['water_heater'] == 'Eco'
|
||||
|
||||
|
||||
def test_water_heater_write_targets():
|
||||
"""DHW.entities[0].write_fn maps each (kind, value) command to the right
|
||||
vendor POST target and body -- power, mode and temperature only, no fan/
|
||||
swing/preset (the AC's climate.py has those; the DHW loop doesn't)."""
|
||||
write = _desc('water_heater').write_fn
|
||||
assert write(('power', True), {}) == (
|
||||
['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'On'})
|
||||
assert write(('power', False), {}) == (
|
||||
['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'Off'})
|
||||
assert write(('mode', 'Force'), {}) == (
|
||||
['mode', 'dhw', 'vs', '0'], {'x.com.samsung.da.modes': ['Force']})
|
||||
assert write(('temperature', 45.0), {}) == (
|
||||
['temperatures', 'dhw', 'vs', '0'], {'x.com.samsung.da.desired': '45.0'})
|
||||
assert write(('bogus', 1), {}) is None
|
||||
|
||||
|
||||
def test_dhw_power_and_temperature_declared_as_coverage():
|
||||
"""/power/dhw/vs/0 and /temperatures/dhw/vs/0 are read by the composite
|
||||
water_heater entity (via water_heater.py's sibling reads), not given
|
||||
their own entities -- declared as no-entity coverage caps so discover()
|
||||
reports no gap, same pattern as the AC's CLIMATE_CONSUMED_HREFS."""
|
||||
reg, _ = _ehs()
|
||||
for href in ('/power/dhw/vs/0', '/temperatures/dhw/vs/0'):
|
||||
caps = reg.capabilities.get(href)
|
||||
assert caps, href
|
||||
assert all(c.entities == () for c in caps), href
|
||||
|
||||
|
||||
def test_away_mode_reads_off():
|
||||
state = _state()
|
||||
assert state['away_mode'] is False
|
||||
|
||||
|
||||
def test_away_mode_write_contract():
|
||||
desc = _desc('away_mode')
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['option', 'outgoing', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.away': 'On'}
|
||||
@@ -49,6 +49,18 @@ def test_registry_reproduces_golden_state_keys_for_washer():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_ehs():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('ehs')
|
||||
golden = json.loads((GOLDEN / 'ehs.json').read_text())
|
||||
state_keys = _new_state_keys('ehs', 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_washer_wa8000t():
|
||||
"""Top-load washer (WA8000T, issue #106) reports no oneUiVersion and
|
||||
used the 'WA' consumer-model prefix, previously unmapped in
|
||||
@@ -686,6 +698,22 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_qooker_mw7500a():
|
||||
"""Bespoke Qooker MW7500A uses Samsung's OVEN board/type metadata but
|
||||
proves its microwave semantics through MicroWave mode plus cavity
|
||||
powerLevel. The routing correction must expose microwave state keys
|
||||
without the misleading oven_mode/oven_state entities."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('qooker_mw7500a')
|
||||
golden = json.loads((GOLDEN / 'qooker_mw7500a.json').read_text())
|
||||
state_keys = _new_state_keys('qooker_mw7500a', 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_microwave_me7500d():
|
||||
"""TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues
|
||||
#137/#142) -- the same microwave registry as MW7300B above, but this
|
||||
@@ -810,6 +838,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 +897,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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Unit tests for the microwave-family capabilities (issue #121/#66 split
|
||||
into their own device type instead of being folded into oven.py)."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
for_device_by_model,
|
||||
resolve,
|
||||
)
|
||||
from custom_components.localthings.registry.capabilities import microwave
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
@@ -39,6 +42,27 @@ def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_qooker_fixture_resolves_as_microwave_and_has_no_unbound_hrefs():
|
||||
"""Bespoke Qooker MW7500A is microwave-shaped despite both its OCF type
|
||||
and internal board token saying oven."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('qooker_mw7500a')
|
||||
|
||||
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven'))
|
||||
|
||||
assert reg is not None
|
||||
assert reg.name == 'microwave'
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources, reg.capabilities, reg.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
keys = {entity.desc.key for entity in bound}
|
||||
assert {'cooking_mode', 'power_level', 'setpoint'} <= keys
|
||||
assert 'oven_mode' not in keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Pattern C subdevice discovery: AWM-WW-AID-26-ONEBODY washer+dryer combo
|
||||
(issue #241).
|
||||
|
||||
The fixture is a live capture from the reporting board (see its
|
||||
`seeds_note`): the master tree is the dryer (`oic.d.dryer`,
|
||||
`..._DV80H27H`), and a complete washer tree (`..._WF80H`) answers at
|
||||
`/<uuid>/device/0`, where the UUID appears *only* as the path prefix of the
|
||||
`x.com.samsung.da.multidevice` link in `/oic/res` -- no `subdeviceIdList`
|
||||
(Pattern B's signal), no `/device/<n>` sibling (Pattern A's).
|
||||
"""
|
||||
from tests.conftest import FakeCoapSession, _discover_full, _load_device_full
|
||||
|
||||
FIXTURE = 'washer_dryer_onebody_awm'
|
||||
WASHER_UUID = '58b7d338-15c5-97d3-b562-000000000001'
|
||||
|
||||
|
||||
def _discover():
|
||||
resources, oic_res, seeds = _load_device_full(FIXTURE)
|
||||
return _discover_full(resources, oic_res, seeds)
|
||||
|
||||
|
||||
def test_washer_subdevice_materializes_from_oic_res_uuid_link():
|
||||
bound, materialized, skipped, full_resources, device_type_name = _discover()
|
||||
|
||||
assert [s.key for s in materialized] == [WASHER_UUID]
|
||||
sub = materialized[0]
|
||||
assert sub.kind == 'prefixed'
|
||||
assert sub.seed_path == (WASHER_UUID, 'device', '0')
|
||||
# Collection mode, not the issue-#205 flat fallback.
|
||||
assert sub.flat_hrefs == ()
|
||||
|
||||
|
||||
def test_washer_subdevice_probe_only_fires_for_the_advertised_uuid():
|
||||
"""Pattern C must not invent candidates: exactly one prefixed seed is
|
||||
probed (the UUID from the /oic/res multidevice link), alongside the
|
||||
bounded speculative Pattern A probes this board 4.04s."""
|
||||
from custom_components.localthings.registry.subdevices import (
|
||||
enumerate_subdevices,
|
||||
)
|
||||
|
||||
resources, oic_res, seeds = _load_device_full(FIXTURE)
|
||||
probes: dict[str, bool] = {}
|
||||
sess = FakeCoapSession(seeds)
|
||||
candidates, _extra = enumerate_subdevices(
|
||||
sess, resources, oic_res, probe_log=probes.__setitem__,
|
||||
)
|
||||
|
||||
assert probes[f'/{WASHER_UUID}/device/0'] is True
|
||||
assert probes['/device/1'] is False
|
||||
assert probes['/device/2'] is False
|
||||
# No flat-fallback flood: the seed Collection answered, so no per-href
|
||||
# probes under the prefix beyond the seed itself.
|
||||
prefixed_probes = [h for h in probes if h.startswith(f'/{WASHER_UUID}/')]
|
||||
assert prefixed_probes == [f'/{WASHER_UUID}/device/0']
|
||||
assert [c.key for c in candidates] == [WASHER_UUID]
|
||||
|
||||
|
||||
def test_washer_subdevice_binds_its_own_primary_entities():
|
||||
"""The washer tree carries its own /operational/state and /power -- the
|
||||
liveness gate must see live primary entities, and the bound set must
|
||||
carry washer-side keys under the subdevice key prefix."""
|
||||
bound, materialized, _skipped, _full, _name = _discover()
|
||||
sub = materialized[0]
|
||||
|
||||
sub_keys = {b.desc.key for b in bound if b.subdevice.key == sub.key}
|
||||
assert sub_keys, 'washer subdevice bound no entities at all'
|
||||
# Its own operational state and its own power switch/sensor -- the two
|
||||
# things SmartThings cloud splits this unit over.
|
||||
assert any('machine_state' in k for k in sub_keys), sub_keys
|
||||
assert any('power' in k for k in sub_keys), sub_keys
|
||||
|
||||
|
||||
def test_master_dryer_entities_unchanged_by_washer_discovery():
|
||||
"""Additive only: the master (dryer) side must keep binding the same
|
||||
entity keys with and without the washer candidate materializing."""
|
||||
resources, _oic_res, _seeds = _load_device_full(FIXTURE)
|
||||
|
||||
# Without any subdevices (oic_res withheld, seeds empty -> washer never
|
||||
# discovered).
|
||||
bound_solo, materialized_solo, _s, _f, _n = _discover_full(
|
||||
resources, [], {},
|
||||
)
|
||||
assert materialized_solo == []
|
||||
solo_keys = {(b.href, b.desc.key) for b in bound_solo}
|
||||
|
||||
bound, materialized, _s2, _f2, _n2 = _discover()
|
||||
sub = materialized[0]
|
||||
main_keys = {
|
||||
(b.href, b.desc.key) for b in bound if b.subdevice.key != sub.key
|
||||
}
|
||||
|
||||
assert main_keys == solo_keys
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
+95
-10
@@ -451,6 +451,45 @@ def test_enumerate_both_patterns_checked_independently():
|
||||
]
|
||||
|
||||
|
||||
def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_not_duplicated():
|
||||
"""A board can carry a UUID that is both listed in subdeviceIdList
|
||||
(Pattern B's signal) *and* advertised as an /oic/res link prefix
|
||||
(Pattern C's) -- the TP2X_FAC_BORA_21K reporter's own board does this.
|
||||
The two signals must resolve to one candidate, not two."""
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
|
||||
}
|
||||
oic_res = [{'di': 'a', 'links': [
|
||||
{'href': f'/{_UUID}/multidevice/vs/0'},
|
||||
]}]
|
||||
sess = _FakeSession({
|
||||
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
|
||||
})
|
||||
subdevices, _extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)]
|
||||
|
||||
|
||||
def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated():
|
||||
"""subdeviceIdList and an /oic/res link prefix can name the same UUID
|
||||
with different casing -- the match must be case-insensitive, or the
|
||||
same physical subdevice materializes twice under two different keys."""
|
||||
upper_uuid = _UUID.upper()
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [upper_uuid]},
|
||||
}
|
||||
oic_res = [{'di': 'a', 'links': [
|
||||
{'href': f'/{_UUID}/multidevice/vs/0'},
|
||||
]}]
|
||||
sess = _FakeSession({
|
||||
(upper_uuid, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
|
||||
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
|
||||
})
|
||||
subdevices, _extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
assert len(subdevices) == 1
|
||||
assert subdevices[0].kind == 'prefixed'
|
||||
assert subdevices[0].key.lower() == _UUID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# discover_partitioned
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -476,7 +515,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 +555,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 +573,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 +602,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 +614,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 +671,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 +704,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 +739,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 +777,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 +811,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 +833,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
|
||||
|
||||
@@ -129,7 +129,17 @@ def test_no_catalog_carries_unresolved_core_references():
|
||||
# so it presents as the device itself, and never reads a catalog name. Same
|
||||
# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed
|
||||
# levels, no presets, same _attr_name = None treatment.
|
||||
UNNAMED_DESCRIPTORS = {("fan", "fan"), ("fan", "airflow_fan")}
|
||||
#
|
||||
# The EHS DHW water_heater is deliberately NOT in here: it is one loop of a
|
||||
# two-loop device rather than the device itself, so it carries a catalog
|
||||
# name like everything else (entity.water_heater.dhw, via the descriptor's
|
||||
# translation_key). That is independent of its *states* -- those are all
|
||||
# HA's own standard water_heater states (STATE_ECO/HEAT_PUMP/HIGH_DEMAND/
|
||||
# PERFORMANCE/OFF), which Home Assistant translates itself via the
|
||||
# entity_component fallback, so no per-state entry is needed either way.
|
||||
UNNAMED_DESCRIPTORS = {
|
||||
("fan", "fan"), ("fan", "airflow_fan"),
|
||||
}
|
||||
|
||||
|
||||
def test_every_descriptor_has_an_entity_catalog_entry():
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""HA water_heater-entity mapping tests for the EHS DHW loop."""
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
STATE_HEAT_PUMP,
|
||||
STATE_HIGH_DEMAND,
|
||||
STATE_PERFORMANCE,
|
||||
WaterHeaterEntity,
|
||||
)
|
||||
from homeassistant.const import STATE_OFF, UnitOfTemperature
|
||||
|
||||
from custom_components.localthings.registry.by_type import ehs
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import WaterHeaterDesc
|
||||
from custom_components.localthings.water_heater import LocalThingsWaterHeater
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-EHS-SERIAL'
|
||||
device_info = {}
|
||||
data = {}
|
||||
|
||||
def __init__(self, resources):
|
||||
self.last_resources = resources
|
||||
self.commands = []
|
||||
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN subdevice,
|
||||
# so the canonical view is just the raw snapshot (issue #177 --
|
||||
# see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
def _entity(resources, coordinator=None):
|
||||
bound = discover(
|
||||
resources, ehs.REGISTRY.capabilities, ehs.REGISTRY.pattern_capabilities,
|
||||
)
|
||||
water_heater_bound = next(item for item in bound if isinstance(item.desc, WaterHeaterDesc))
|
||||
return LocalThingsWaterHeater(coordinator or _FakeCoordinator(resources), water_heater_bound)
|
||||
|
||||
|
||||
def test_current_operation_reads_eco_when_on():
|
||||
entity = _entity(_load_device('ehs'))
|
||||
assert entity.current_operation == STATE_ECO
|
||||
|
||||
|
||||
def test_current_operation_is_off_when_powered_off():
|
||||
resources = _load_device('ehs')
|
||||
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
|
||||
entity = _entity(resources)
|
||||
assert entity.current_operation == STATE_OFF
|
||||
|
||||
|
||||
def test_operation_list_includes_off_and_mapped_modes():
|
||||
"""Fixture's supportedModes is Eco/Std/Power/Force -- all four map onto
|
||||
HA's own standard water_heater states (see water_heater.py's module
|
||||
docstring for the SmartThings-cloud precedent this mirrors)."""
|
||||
entity = _entity(_load_device('ehs'))
|
||||
assert entity.operation_list == [
|
||||
STATE_OFF, STATE_ECO, STATE_HEAT_PUMP, STATE_PERFORMANCE, STATE_HIGH_DEMAND,
|
||||
]
|
||||
|
||||
|
||||
def test_temperature_reads_current_and_target():
|
||||
entity = _entity(_load_device('ehs'))
|
||||
assert entity.current_temperature == 38.0
|
||||
assert entity.target_temperature == 40.0
|
||||
assert entity.temperature_unit == UnitOfTemperature.CELSIUS
|
||||
assert entity.min_temp == 40.0
|
||||
assert entity.max_temp == 62.0
|
||||
assert entity.target_temperature_step == 0.5
|
||||
|
||||
|
||||
def test_temperature_bounds_fall_back_together():
|
||||
"""A board reporting only one end of the range must not pair a real
|
||||
device bound with an HA default -- both ends or neither, same rule as
|
||||
climate._range()."""
|
||||
resources = _load_device('ehs')
|
||||
del resources['/temperatures/dhw/vs/0']['x.com.samsung.da.maximum']
|
||||
entity = _entity(resources)
|
||||
|
||||
# Neither end comes from the device: the device minimum (40.0) is
|
||||
# dropped along with the missing maximum, so both fall back to HA's own
|
||||
# water_heater defaults rather than being mixed.
|
||||
assert entity.min_temp != 40.0
|
||||
assert (entity.min_temp, entity.max_temp) == (
|
||||
WaterHeaterEntity.min_temp.fget(entity),
|
||||
WaterHeaterEntity.max_temp.fget(entity),
|
||||
)
|
||||
|
||||
|
||||
def test_zero_increment_is_not_collapsed_into_the_default():
|
||||
"""`or` would turn a genuine 0 into 0.5 (issue #160)."""
|
||||
resources = _load_device('ehs')
|
||||
resources['/temperatures/dhw/vs/0']['x.com.samsung.da.increment'] = '0'
|
||||
entity = _entity(resources)
|
||||
|
||||
assert entity.target_temperature_step == 0.0
|
||||
|
||||
|
||||
def test_current_operation_tolerates_lowercase_device_codes():
|
||||
resources = _load_device('ehs')
|
||||
resources['/mode/dhw/vs/0']['x.com.samsung.da.modes'] = ['eco']
|
||||
entity = _entity(resources)
|
||||
|
||||
assert entity.current_operation == STATE_ECO
|
||||
|
||||
|
||||
def test_entity_is_named_rather_than_taking_the_device_name():
|
||||
"""Unlike climate.py's AC, DHW is one loop of a two-loop device, so it
|
||||
takes a catalog name instead of presenting as the device itself."""
|
||||
entity = _entity(_load_device('ehs'))
|
||||
|
||||
# No _attr_name override (that would present as the bare device name,
|
||||
# and would also beat the catalog); the name comes from the catalog key.
|
||||
assert '_attr_name' not in entity.__dict__
|
||||
assert entity.translation_key == 'dhw'
|
||||
|
||||
|
||||
async def test_set_temperature_writes_dhw_temperature():
|
||||
resources = _load_device('ehs')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_temperature(temperature=45.0)
|
||||
|
||||
assert coordinator.commands == [(entity._bound, ('temperature', 45.0))]
|
||||
|
||||
|
||||
async def test_set_temperature_honours_operation_mode():
|
||||
"""water_heater.set_temperature carries an optional operation_mode
|
||||
(SET_TEMPERATURE_SCHEMA); dropping it would move the setpoint without
|
||||
ever changing mode -- same bug fixed for climate's hvac_mode."""
|
||||
resources = _load_device('ehs')
|
||||
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_temperature(temperature=55.0,
|
||||
operation_mode=STATE_HIGH_DEMAND)
|
||||
|
||||
assert coordinator.commands == [
|
||||
(entity._bound, ('power', True)),
|
||||
(entity._bound, ('mode', 'Force')),
|
||||
(entity._bound, ('temperature', 55.0)),
|
||||
]
|
||||
|
||||
|
||||
async def test_set_temperature_with_off_mode_skips_the_setpoint_write():
|
||||
resources = _load_device('ehs')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_temperature(temperature=55.0, operation_mode=STATE_OFF)
|
||||
|
||||
assert coordinator.commands == [(entity._bound, ('power', False))]
|
||||
|
||||
|
||||
async def test_turn_on_and_off_write_dhw_power():
|
||||
resources = _load_device('ehs')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on()
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands == [
|
||||
(entity._bound, ('power', True)),
|
||||
(entity._bound, ('power', False)),
|
||||
]
|
||||
|
||||
|
||||
async def test_set_operation_mode_off_turns_off_without_writing_mode():
|
||||
resources = _load_device('ehs')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_operation_mode(STATE_OFF)
|
||||
|
||||
assert coordinator.commands == [(entity._bound, ('power', False))]
|
||||
|
||||
|
||||
async def test_set_operation_mode_writes_mapped_device_code():
|
||||
resources = _load_device('ehs')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_operation_mode(STATE_HIGH_DEMAND)
|
||||
|
||||
# DHW power is already on (fixture default) -- no extra power write.
|
||||
assert coordinator.commands == [(entity._bound, ('mode', 'Force'))]
|
||||
|
||||
|
||||
async def test_set_operation_mode_powers_on_first_when_off():
|
||||
resources = _load_device('ehs')
|
||||
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_operation_mode(STATE_PERFORMANCE)
|
||||
|
||||
assert coordinator.commands == [
|
||||
(entity._bound, ('power', True)),
|
||||
(entity._bound, ('mode', 'Power')),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user