Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d73ac8694 | ||
|
|
1cfe126313 | ||
|
|
8d1ecb4f2a | ||
|
|
0c1231794a | ||
|
|
67b28ed10f | ||
|
|
f12f67b2b3 | ||
|
|
2f7170c448 | ||
|
|
b8ef430ed4 | ||
|
|
cd3a47f9a4 | ||
|
|
ac995dcbd4 | ||
|
|
d80bd550fe | ||
|
|
cc16766b9d | ||
|
|
84465aee72 | ||
|
|
711a71876d | ||
|
|
9004125c97 | ||
|
|
0dd8bfb5fc | ||
|
|
9652a14f64 | ||
|
|
bb20eea191 | ||
|
|
0fbac7f14d | ||
|
|
78a545341b | ||
|
|
a4981f40a2 | ||
|
|
17cd78d975 | ||
|
|
863fe32526 | ||
|
|
13d2a38f5d | ||
|
|
d9e7daa203 | ||
|
|
d27bfc6405 | ||
|
|
bee4466b45 | ||
|
|
cef187b7af | ||
|
|
2265c52c77 | ||
|
|
b921bdbb28 | ||
|
|
9d28b088cb | ||
|
|
22b4508f95 | ||
|
|
f45bd6a72c | ||
|
|
1424c2222e | ||
|
|
ef66d1db71 | ||
|
|
16cb01ce5d | ||
|
|
676074b2f8 | ||
|
|
846aefbcba | ||
|
|
895fd87d2c | ||
|
|
248e473abe | ||
|
|
6828d0152b | ||
|
|
d6534c788a | ||
|
|
89fea83c80 | ||
|
|
d2787327fd | ||
|
|
1f7bdc9ac6 | ||
|
|
61a953d39a | ||
|
|
95ce358d55 | ||
|
|
5e2c23a62d | ||
|
|
b6f0bc22cb | ||
|
|
07c20e82aa | ||
|
|
efea9e9888 | ||
|
|
94798b5d9b | ||
|
|
f5e99d71e3 | ||
|
|
8ed3d1467f | ||
|
|
68dee12eb4 | ||
|
|
082a1b3cd4 | ||
|
|
00db7890f5 | ||
|
|
55765fdf9a | ||
|
|
3675d8087b | ||
|
|
4f3bdde6e5 | ||
|
|
d65735ac47 | ||
|
|
867f4b0ae8 | ||
|
|
9da775a8de | ||
|
|
6ee60beae9 | ||
|
|
fffe923afc | ||
|
|
a6d818dfc0 | ||
|
|
cec3dd4a68 | ||
|
|
5dbe990c1d | ||
|
|
9228da1d9c | ||
|
|
ce60b6967b | ||
|
|
a7dc1db8ff | ||
|
|
8551974719 | ||
|
|
e2dcc75ed5 | ||
|
|
c203bd42c5 | ||
|
|
42fd9c2aa4 | ||
|
|
df5b704f3e | ||
|
|
26c9168fb7 | ||
|
|
edf77309ba | ||
|
|
d24c94e303 | ||
|
|
f07ae4020e | ||
|
|
dd953b8150 | ||
|
|
b820a96277 | ||
|
|
eed04faaed | ||
|
|
960eca2d6a | ||
|
|
789aaf9849 | ||
|
|
e3e7f4f43c | ||
|
|
a3cc918343 | ||
|
|
69f93be4dc | ||
|
|
50bb893407 | ||
|
|
77c2d7831e | ||
|
|
252306838d | ||
|
|
455ed5b27c | ||
|
|
26e4c9c167 | ||
|
|
2d772f16a7 | ||
|
|
30bd0fd2af | ||
|
|
4e47a1c3d9 | ||
|
|
bea5206c06 | ||
|
|
75e985d392 | ||
|
|
93f45cb356 | ||
|
|
e6909fb847 | ||
|
|
9ee0329467 | ||
|
|
e9e278726a | ||
|
|
ccdfe7088e | ||
|
|
c423efdd23 | ||
|
|
3918b1e5c8 | ||
|
|
6dd4de8b6b | ||
|
|
7086b134c0 | ||
|
|
7f66d21d73 | ||
|
|
9e12992cb4 | ||
|
|
b59b5ae1b4 | ||
|
|
f8a7a1fa66 | ||
|
|
290a348017 | ||
|
|
54a676f841 | ||
|
|
a38437fca4 | ||
|
|
15279066b5 | ||
|
|
3d0dca20f9 | ||
|
|
96d06369bc | ||
|
|
330b2f344f | ||
|
|
a5484f746a | ||
|
|
412fff9b99 | ||
|
|
69844d9829 | ||
|
|
71f2101d11 | ||
|
|
ba5a3b529d | ||
|
|
f8849df8a6 |
@@ -19,6 +19,40 @@ file covers how changes get committed.
|
||||
assistant, or tool that helped produce the change. The commit is
|
||||
attributed entirely to the accountable human.
|
||||
|
||||
## Code comments
|
||||
|
||||
This codebase reverse-engineers undocumented device APIs, so comments
|
||||
recording *why* a decision was made (a calibration, a rejected write, an
|
||||
issue number a quirk was confirmed against) are genuinely valuable — more
|
||||
valuable than in most codebases. That's exactly why comments here need
|
||||
discipline: it's easy for "explain the reasoning" to slide into "narrate
|
||||
the whole investigation," and a file where every line has a paragraph
|
||||
under it is as hard to read as one with no comments at all. Keep the
|
||||
conclusion; cut the journey.
|
||||
|
||||
- **Comment the "why," never the "what."** If a comment just restates what
|
||||
the next line already says, delete it. Code should read clearly enough
|
||||
on its own that comments are only needed for the non-obvious.
|
||||
- **One or two sentences, not an essay.** State the conclusion and the one
|
||||
piece of evidence that makes it credible (an issue number, a model name,
|
||||
a single confirming observation). Don't reproduce the full
|
||||
investigation — every dump checked, every attempt that failed, every
|
||||
hypothesis considered and discarded. A future reader needs to trust the
|
||||
conclusion and know where to look if they need to redo the work, not
|
||||
relive it.
|
||||
- **A pointer, not a re-derivation.** Cite the issue/model once; don't
|
||||
re-explain a sibling function's already-documented reasoning. Reference
|
||||
it (`same reasoning as X above`) instead of restating it.
|
||||
- **Module/class docstrings are a short orientation, not a design doc.**
|
||||
A few lines on purpose and any cross-cutting invariant is enough.
|
||||
- **Failed-attempt logs don't belong inline.** If an investigation into an
|
||||
unsolved problem produced real negative results worth preserving (e.g. a
|
||||
reset mechanism nobody could find), put them in an issue or docs, not a
|
||||
block comment several times longer than the code it sits above.
|
||||
- **When in doubt, cut.** If deleting a comment wouldn't lose real
|
||||
understanding, it's noise. Prefer trimming an existing comment over
|
||||
adding a new one.
|
||||
|
||||
## For AI coding agents
|
||||
|
||||
See `AGENTS.md`.
|
||||
|
||||
@@ -95,6 +95,73 @@ Each device has its own **Configure** option in Settings > Devices & Services, u
|
||||
|
||||
- **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.
|
||||
- **Remember modes the device reports but doesn't advertise** — some firmware reports a current mode it never lists as supported. Issue #327's air conditioner sits in `Quiet` while offering only `Off/Sleep/Speed/Nano/NanoSleep`, so Home Assistant showed the preset as active but refused to select it. LocalThings remembers any such mode it sees and keeps offering it afterwards, stored on the config entry so it survives a restart — the device only names the mode while it is in it, and you shouldn't have to reach for the physical remote after every reboot. Defaults to on. Turning it off offers only what the device advertises, without discarding what was already learned.
|
||||
|
||||
The same **Configure** menu has a **Forget remembered modes** step, which clears what has been learned for that device. Use it if a mode was learned that turns out not to be selectable — otherwise, by design, it stays forever.
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Reading and writing resources directly
|
||||
|
||||
Two HA actions, `localthings.write_resource` and `localthings.read_resource`, talk to a device's OCF resources directly instead of through this integration's entity model. They exist for two overlapping jobs: pinning down a device-specific write contract (the reverse-engineering work `docs/investigations/` and the provenance comments throughout `registry/capabilities/` are all about), and driving a resource this integration doesn't model as an entity yet, without waiting on a release.
|
||||
|
||||
Both take a `device_id` (a device picker filtered to this integration) and resolve to exactly one appliance — a target that expands to more than one LocalThings device is rejected rather than silently fanned out across all of them. `href` is always canonical (e.g. `/mode/vs/0`); if the device you targeted is a subdevice — an oven's second cavity, an AC's second indoor unit — it's translated to the real on-the-wire href for you (`/mode/vs/1`, say), and the response reports both forms so there's no ambiguity about what was actually sent.
|
||||
|
||||
`write_resource` exists because a single write, one at a time, isn't enough to probe some boards. Issue #300's Samsung wall oven answers `2.04 Changed` to a settings write while idle and then silently reverts it — the write only sticks once a cycle is already running. Finding what actually triggers a cycle needs an *ordered sequence* of writes to different resources, with real delays between them, and a way to check afterward whether anything actually held:
|
||||
|
||||
```yaml
|
||||
action: localthings.write_resource
|
||||
data:
|
||||
device_id: abc123...
|
||||
writes:
|
||||
- href: /mode/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.modes: ["Bake"]
|
||||
settle: 5
|
||||
- href: /operational/state/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.state: "Run"
|
||||
verify_after: 30
|
||||
```
|
||||
|
||||
Mind the shapes: what you write is sent verbatim, so the field names and types have to be the ones that resource actually uses. `/mode/vs/0` takes `modes` as an *array* on this board; a bare string, or the singular `mode`, is a different field the device will simply ignore. `read_resource` (below) with no `href` is the quickest way to see the real shape of everything before you write to any of it.
|
||||
|
||||
Each write in `writes` (1-10 of them) needs `href` and a non-empty `payload`, sent verbatim as a partial-rep POST — this bypasses the remote-control-off block and every `write_fn`/`validate_fn` a normal entity write goes through, and sends exactly the fields you give it, so it can misconfigure your appliance if you get it wrong. `settle` (0-30s, default 0) is how long to wait *after* that write before starting the next one.
|
||||
|
||||
By default the whole sequence holds the device session from the first write to the last, settle delays included, so a routine poll or another entity's write can't land between two steps and blur which write the appliance was reacting to. The cost is that nothing else on that device updates until the sequence ends — up to 10 × 30s if you ask for the maximum of both. Set `hold_session_lock: false` to take the session per write and release it across the waits instead, trading that certainty for a device whose entities keep updating throughout.
|
||||
|
||||
The response has one `results` entry per write, with `before`/`after` reps and a `changed` flag (every key/value in `payload` present and equal in the immediate readback):
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "abc123...",
|
||||
"results": [
|
||||
{"href": "/mode/vs/0", "actual_href": "/mode/vs/0", "code": "2.04", "raw_code": 68,
|
||||
"accepted": true, "before": {...}, "after": {...}, "changed": true},
|
||||
...
|
||||
],
|
||||
"verified": {
|
||||
"/mode/vs/0": {"code": "2.05", "raw_code": 69, "rep": {...}, "held": false}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`verify_after` (0-60s, default 0, omit to skip) is what actually answers the "did it stick" question: after the sequence finishes, it waits that long and then re-reads every distinct href the sequence touched, reporting the result under `verified`, keyed by canonical href. `changed` tells you the write was accepted and reflected immediately; `held` tells you whether it was still there N seconds later, or whether the board quietly put it back — issue #300's exact symptom. Where an href was written more than once in a sequence, `held` compares against the *last* payload sent to it. A `held` of `null` means the re-read itself didn't come back (check `code` next to it) — unknown, deliberately not reported as a revert.
|
||||
|
||||
If the session drops partway through a sequence, the action raises rather than returning, and the error names how many writes completed and which — the appliance is left holding a partial sequence, so knowing where it stopped is the difference between a usable result and starting over blind.
|
||||
|
||||
`read_resource` is the read half, and it's deliberately not just a cache lookup:
|
||||
|
||||
```yaml
|
||||
action: localthings.read_resource
|
||||
data:
|
||||
device_id: abc123...
|
||||
href: /mode/vs/0
|
||||
```
|
||||
|
||||
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
|
||||
|
||||
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
|
||||
|
||||
---
|
||||
|
||||
@@ -135,6 +202,8 @@ custom_components/localthings/
|
||||
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
|
||||
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
services.py write_resource/read_resource actions (device resolution, href translation)
|
||||
services.yaml Selectors/descriptions for the two services above
|
||||
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 / water_heater.py
|
||||
@@ -172,6 +241,11 @@ email, access tokens, device IDs, MAC addresses, serial numbers) before it's gen
|
||||
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
|
||||
support for hardware the maintainers don't have.
|
||||
|
||||
When a diagnostics dump alone isn't enough to pin down how a resource actually behaves — whether a write sticks,
|
||||
what order things need to happen in, whether the device reverts a change on its own — the `localthings.write_resource`
|
||||
and `localthings.read_resource` actions from Part 5 are the tool for probing it directly and reporting back what
|
||||
you found.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new appliance type
|
||||
@@ -194,6 +268,8 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
|
||||
|
||||
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
|
||||
|
||||
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
|
||||
|
||||
### Multi-subdevice ("2-in-1") air conditioner systems
|
||||
|
||||
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
||||
|
||||
@@ -10,38 +10,41 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.identity import resolve_serial
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# Services are process-global, registered once here rather than per
|
||||
# config entry (issue #300) -- see services.async_setup_services.
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
def _serial_from_unique_id(entry: ConfigEntry) -> str:
|
||||
"""The device identity a pre-v2 entry was created with.
|
||||
|
||||
The config flow has always keyed the entry's unique_id on the serial the
|
||||
probe read (`localthings_<serial>`), so that string is the identity the
|
||||
entry's registry entries were minted from -- there is no need to reach the
|
||||
device to recover it. Anything we can't recover one from resolves to the
|
||||
host, which is what the coordinator seeded such an entry with anyway.
|
||||
entry's registry entries were minted from -- no need to reach the device
|
||||
to recover it. Anything unrecoverable resolves to the host, matching
|
||||
what the coordinator seeded such an entry with anyway.
|
||||
|
||||
The recovered string goes back through resolve_serial rather than being
|
||||
taken at face value, because the unique_id records what the flow believed
|
||||
at the time it ran, not what the registry holds now. Entries created
|
||||
before the placeholder rules landed (issues #83/#189) were keyed on the
|
||||
placeholder itself -- `localthings_Nothing(SVC)`, `localthings_FFFF...` --
|
||||
while the coordinator has since been resolving those same boards to the
|
||||
host. Re-keying the registry onto the placeholder to match the unique_id
|
||||
would reintroduce the collision those issues are about: two units of that
|
||||
family report the *same* placeholder, so they'd share entity unique_ids
|
||||
again.
|
||||
|
||||
A later wrinkle, same root cause: for a stretch the two sides disagreed on
|
||||
which fallback to use, the flow writing `host:port` while the coordinator
|
||||
wrote `host`. Collapse that to the coordinator's form too -- the registry
|
||||
is what has to keep working.
|
||||
taken at face value: entries created before the placeholder rules
|
||||
(issues #83/#189) were keyed on the placeholder itself, while the
|
||||
coordinator has since resolved those same boards to the host.
|
||||
Re-keying onto the placeholder would reintroduce the collision those
|
||||
issues are about -- two units of a family sharing the same placeholder
|
||||
would share entity unique_ids again. A later wrinkle, same root cause:
|
||||
for a stretch the flow wrote `host:port` while the coordinator wrote
|
||||
`host`; collapsed here to the coordinator's form too.
|
||||
"""
|
||||
host = entry.data[CONF_HOST]
|
||||
prefix = f"{DOMAIN}_"
|
||||
@@ -59,24 +62,24 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
||||
"""Re-key registry entries this entry minted from the placeholder identity.
|
||||
|
||||
Before the identity moved onto the config entry, the coordinator seeded
|
||||
`device_serial` with the host and only replaced it after the first
|
||||
successful poll. Anything that registered in between -- the connection-mode
|
||||
sensor especially, since it is added unconditionally rather than from
|
||||
`bound` -- was written into the registry keyed on the IP address
|
||||
permanently, and was orphaned the moment the serial-keyed identity
|
||||
appeared (issue #236). Deleting the orphans by hand didn't help: the next
|
||||
restart that lost the same race recreated them.
|
||||
`device_serial` with the host and only replaced it after the first poll.
|
||||
Anything that registered in between -- the connection-mode sensor
|
||||
especially, added unconditionally rather than from `bound` -- was
|
||||
written into the registry keyed on the IP permanently, orphaned the
|
||||
moment the serial-keyed identity appeared (issue #236). Deleting the
|
||||
orphans by hand didn't help: the next restart that lost the same race
|
||||
recreated them.
|
||||
|
||||
Rewriting beats deleting where it's possible -- an entity keeps its
|
||||
entity_id, name, area and every automation that references it. It's only
|
||||
possible when the serial-keyed key is still free, though; where both exist
|
||||
the placeholder-keyed one is the dead duplicate (it has been unavailable
|
||||
since the restart that created it), so it goes.
|
||||
Rewriting beats deleting where possible -- an entity keeps its
|
||||
entity_id, name, area and automations. Only possible when the
|
||||
serial-keyed key is still free; where both exist the placeholder-keyed
|
||||
one is the dead duplicate (unavailable since the restart that created
|
||||
it), so it goes.
|
||||
"""
|
||||
host = entry.data[CONF_HOST]
|
||||
if serial == host:
|
||||
# A board with no usable serial resolves *to* the host, so its keys
|
||||
# were never placeholders -- there is nothing here to re-key.
|
||||
# A board with no usable serial resolves to the host, so its keys
|
||||
# were never placeholders.
|
||||
return
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
@@ -106,11 +109,9 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
||||
existing = dev_reg.async_get_device(identifiers=fresh)
|
||||
if existing is not None and existing.id != device.id:
|
||||
# Removing a device takes its entities with it. Anything still
|
||||
# attached here came through the pass above re-keyed rather than
|
||||
# removed -- i.e. it's the surviving copy, not a duplicate -- so
|
||||
# move it onto the device it now belongs to first. Otherwise the
|
||||
# rewrite that was supposed to preserve an entity_id, name and
|
||||
# area destroys them a few lines later.
|
||||
# attached here was re-keyed rather than removed above -- the
|
||||
# surviving copy, not a duplicate -- so move it onto the device
|
||||
# it now belongs to before the removal destroys it too.
|
||||
for entity in er.async_entries_for_device(
|
||||
ent_reg, device.id, include_disabled_entities=True
|
||||
):
|
||||
@@ -127,13 +128,13 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate an entry to the current version.
|
||||
|
||||
v1 -> v2 stores the device's identity on the entry so the coordinator can
|
||||
key its registry entries before the first poll (issue #236), and repairs
|
||||
whatever the old placeholder-keyed registration already orphaned.
|
||||
v1 -> v2 stores the device's identity on the entry so the coordinator
|
||||
can key its registry entries before the first poll (issue #236), and
|
||||
repairs whatever the old placeholder-keyed registration already
|
||||
orphaned.
|
||||
"""
|
||||
if entry.version > 2:
|
||||
# Downgrade: this release doesn't know the newer entry's shape.
|
||||
return False
|
||||
return False # downgrade: this release doesn't know the newer shape
|
||||
|
||||
if entry.version == 1:
|
||||
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
|
||||
@@ -155,31 +156,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception as err:
|
||||
# `_poll_once` deliberately leaves the session up on a `TimeoutError`
|
||||
# (the transfer may just be slow -- see its docstring), so a refresh
|
||||
# that fails that way ends here with a live, bound UDP socket that
|
||||
# nothing would ever close. HA retries setup on its own backoff with
|
||||
# a *new* coordinator, and each device's source port is fixed by
|
||||
# design (`_local_source_port`), so an abandoned socket squats the
|
||||
# exact port the next attempt binds -- SO_REUSEADDR lets that bind
|
||||
# succeed, leaving two sockets racing for the device's datagrams.
|
||||
# `_poll_once` deliberately leaves the session up on a TimeoutError
|
||||
# (see its docstring), so a refresh failing that way leaves a live,
|
||||
# bound UDP socket nothing would ever close. HA retries setup with a
|
||||
# new coordinator, and the source port is fixed by design
|
||||
# (`_local_source_port`), so an abandoned socket would squat the
|
||||
# exact port the next attempt binds.
|
||||
await coordinator.async_close()
|
||||
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
||||
# #254). `async_close` otherwise only runs via `async_unload_entry`, and
|
||||
# HA does not unload entries on a plain Core restart -- so a restart left
|
||||
# the previous run's association orphaned on the appliance, which is what
|
||||
# makes the *next* run's handshake time out. Complements the fixed source
|
||||
# port, which covers the unclean-exit case this cannot (see
|
||||
# `_local_source_port`).
|
||||
# #254): HA doesn't unload entries on a plain Core restart, so a restart
|
||||
# left the previous run's association orphaned, making the next
|
||||
# handshake time out. Complements the fixed source port, which covers
|
||||
# the unclean-exit case this can't.
|
||||
#
|
||||
# A coroutine listener, not one that spawns its own task: the event bus
|
||||
# runs it as a hass-tracked job, so the close is awaited by the
|
||||
# `async_block_till_done()` inside `hass.async_stop`. A detached task
|
||||
# would likely be cancelled mid-shutdown -- the exact no-close_notify
|
||||
# case this exists to prevent.
|
||||
# runs it as a hass-tracked job, awaited by `async_block_till_done()`
|
||||
# inside `hass.async_stop`. A detached task would likely be cancelled
|
||||
# mid-shutdown -- the exact case this exists to prevent.
|
||||
async def _async_close_on_stop(_event: Event) -> None:
|
||||
await coordinator.async_close()
|
||||
|
||||
@@ -198,31 +194,26 @@ async def async_remove_config_entry_device(
|
||||
) -> bool:
|
||||
"""Allow deleting a device this entry no longer provides (issue #214).
|
||||
|
||||
Defining this at all is what makes Home Assistant offer the "Delete
|
||||
device" action for our devices; without it a device registry entry
|
||||
belonging to a loaded config entry can never be removed from the UI. That
|
||||
matters because a subdevice's HA device outlives the discovery that
|
||||
created it: a candidate that materialized under an older release (issue
|
||||
#214's phantom second air conditioner, born from an unused /device/1 slot
|
||||
reporting the appliance's energy counter -- see
|
||||
registry/subdevices.py's liveness gate) leaves a device entry behind that
|
||||
nothing recreates and nothing cleans up once the gate stops materializing
|
||||
it. Same for a sibling that a firmware update stops exposing.
|
||||
Defining this at all is what makes HA offer the "Delete device" action;
|
||||
without it, a device belonging to a loaded config entry can never be
|
||||
removed from the UI. That matters because a subdevice's HA device
|
||||
outlives the discovery that created it: a candidate materialized under
|
||||
an older release (issue #214's phantom second air conditioner, born
|
||||
from an unused slot reporting the appliance's energy counter -- see
|
||||
registry/subdevices.py's liveness gate) leaves a device entry nothing
|
||||
recreates or cleans up once the gate stops materializing it. Same for a
|
||||
sibling a firmware update stops exposing.
|
||||
|
||||
Removal is refused for devices this entry *does* currently provide --
|
||||
HA would recreate them on the next entity add, so allowing it would look
|
||||
like the delete silently failed. Deliberately no automatic pruning at
|
||||
discovery time: subdevice enumeration is one-shot and a sibling can fail
|
||||
to answer for a poll (issue #205 is exactly that on the reference
|
||||
hardware), so auto-removal would throw away a real subdevice's name,
|
||||
area and automation references on a transient miss. The user gets the
|
||||
button; the integration doesn't guess.
|
||||
Removal is refused for devices this entry does currently provide -- HA
|
||||
would recreate them on the next entity add. Deliberately no automatic
|
||||
pruning at discovery time: a sibling can fail to answer for a single
|
||||
poll (issue #205), so auto-removal would throw away a real subdevice's
|
||||
name/area/automations on a transient miss. The user gets the button;
|
||||
the integration doesn't guess.
|
||||
"""
|
||||
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||
if coordinator is None:
|
||||
# Entry not loaded (or already unloaded) -- nothing is claiming this
|
||||
# device, so there's nothing to protect it from being removed.
|
||||
return True
|
||||
return True # entry not loaded -- nothing claims this device
|
||||
live = set(coordinator.device_info.get("identifiers") or set())
|
||||
for subdevice in coordinator.subdevices:
|
||||
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
"""Climate platform for Local Things.
|
||||
|
||||
The first composite entity in this integration: a single HA climate card that
|
||||
unifies several OCF resources of a Samsung air conditioner. Unlike every other
|
||||
platform here (one descriptor -> one resource field), a climate entity reads
|
||||
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
|
||||
direction) and the convenient-mode preset from *different* resources.
|
||||
The first composite entity in this integration: a single HA climate card
|
||||
that unifies several OCF resources of a Samsung air conditioner. Unlike
|
||||
every other platform here (one descriptor -> one resource field), a climate
|
||||
entity reads power, HVAC mode, current/target temperature, fan (wind)
|
||||
strength, swing (wind direction) and the convenient-mode preset from
|
||||
*different* resources.
|
||||
|
||||
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry
|
||||
still tracks it, and reads the sibling resources straight from the coordinator
|
||||
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
|
||||
`number.py` (live range/unit) and `select.py` (options callable) already do.
|
||||
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the
|
||||
registry still tracks it, and reads the sibling resources straight from the
|
||||
coordinator snapshot via `coordinator.resource(href)`.
|
||||
|
||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
|
||||
CLIMATE capability's `write_fn` 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/vs/0` href -- so one descriptor drives writes to, and gets fresh state
|
||||
back for, power, mode, temperature and wind resources alike.
|
||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
|
||||
the CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the
|
||||
right `(path_segs, body)`, and `async_send_command` applies the optimistic
|
||||
value/settle guard to that resource's own href -- not the bound
|
||||
`/mode/vs/0` href -- so one descriptor drives writes across power, mode,
|
||||
temperature and wind resources alike.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -57,9 +58,6 @@ from .registry.capabilities.airconditioner import (
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_POWER_VS as POWER_VS_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||
)
|
||||
@@ -79,7 +77,12 @@ from .registry.capabilities.airconditioner import (
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
_temperature_step,
|
||||
extend_option_code_bit,
|
||||
has_extend_option_code,
|
||||
has_option_code,
|
||||
is_legacy_board,
|
||||
option_code_bit,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
from .registry.entities import ClimateDesc
|
||||
@@ -95,56 +98,46 @@ _SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||
"Cool": HVACMode.COOL,
|
||||
"Dry": HVACMode.DRY,
|
||||
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
|
||||
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
|
||||
# reverse write can't rely on this map alone (two codes, one HA value) --
|
||||
# _device_code_for_hvac() resolves the code from the unit's own
|
||||
# supportedModes, so this is only a fallback for a unit reporting no
|
||||
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse
|
||||
# comprehension below has 'Wind' win that fallback (last-key-wins),
|
||||
# preserving the original single-spelling behavior rather than silently
|
||||
# flipping it when 'Fan' was added.
|
||||
# Fan-only is spelled 'Wind' on some boards and 'Fan' on others; both map
|
||||
# to FAN_ONLY. _device_code_for_hvac() resolves the write-side code from
|
||||
# the unit's own supportedModes, so this reverse map is only a fallback
|
||||
# for a unit with no supportedModes at all. 'Fan' listed first so the
|
||||
# {v: k} comprehension below has 'Wind' win that fallback (last-key-wins,
|
||||
# preserving the original single-spelling behavior).
|
||||
"Fan": HVACMode.FAN_ONLY,
|
||||
"Wind": HVACMode.FAN_ONLY,
|
||||
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
|
||||
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
|
||||
# and implies a two-setpoint heat+cool range these single-setpoint units
|
||||
# (including cool-only models) don't have.
|
||||
# A single-setpoint "device decides" mode -> HA AUTO, not HEAT_COOL
|
||||
# (which implies a two-setpoint heat+cool range these units don't have).
|
||||
"Auto": HVACMode.AUTO,
|
||||
"Heat": HVACMode.HEAT,
|
||||
}
|
||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
|
||||
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
|
||||
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
|
||||
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
|
||||
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
|
||||
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
|
||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain
|
||||
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the
|
||||
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
|
||||
# entered/left only through the preset, since there's no dedicated HVACMode
|
||||
# value for it to write back to.
|
||||
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON): 'AIComfort'
|
||||
# isn't a distinct thermodynamic operation like Cool/Dry/Heat, it's an AI
|
||||
# overlay on the device's own 'Auto' -- the unit reports both as separate,
|
||||
# mutually-exclusive supportedModes entries. hvac_mode reports AUTO (same as
|
||||
# plain 'Auto') and a dedicated 'ai_comfort' preset carries the distinction.
|
||||
# Not reachable via async_set_hvac_mode -- entered/left only through the
|
||||
# preset, since there's no HVACMode value for it to write back to.
|
||||
_AI_COMFORT_MODE = "AIComfort"
|
||||
PRESET_AI_COMFORT = "ai_comfort"
|
||||
|
||||
# Codes that appear in /mode/vs/0's supportedModes but are option/capability
|
||||
# flags rather than selectable thermodynamic operations -- dropped silently
|
||||
# (no _warn_unmapped call) rather than every owner of an affected unit
|
||||
# tripping the issue #93 warning on every start.
|
||||
# Codes in /mode/vs/0's supportedModes that are option/capability flags, not
|
||||
# selectable thermodynamic operations -- dropped silently rather than
|
||||
# tripping the issue #93 unmapped-code warning on every start.
|
||||
#
|
||||
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K): also appears in
|
||||
# /configuration/vs/0's x.com.samsung.da.airconOptionList alongside
|
||||
# PRODUCT_GLOBAL/AI_3.0/SingleCommand_1 -- clearly a capability flag, not a
|
||||
# mode, on that resource. The unit's own `modes` (current mode) never
|
||||
# reported it as active across the reporter's logs, only ever a real
|
||||
# thermodynamic mode -- consistent with it being echoed into supportedModes
|
||||
# rather than genuinely selectable. Unlike _AI_COMFORT_MODE above, it isn't
|
||||
# modeled as a preset: there's no confirmation it's user-selectable at all,
|
||||
# so silently dropping it (rather than guessing a write contract) is the
|
||||
# 'don't guess' rule applied to a mode code instead of a resource field.
|
||||
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K) also appears in
|
||||
# /configuration/vs/0's airconOptionList alongside other capability flags,
|
||||
# and the unit's own current `modes` never reported it active -- consistent
|
||||
# with an echoed capability flag, not a genuine mode. Unlike _AI_COMFORT_MODE,
|
||||
# not modeled as a preset either: nothing confirms it's user-selectable.
|
||||
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
|
||||
|
||||
# Seconds to let a legacy board settle into Cool before the WindFree token is
|
||||
# written after it -- see _legacy_preset_needs_cool for the measurement.
|
||||
_NANO_AFTER_MODE_DELAY = 3
|
||||
|
||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
||||
_DEVICE_TO_FAN: dict[str, str] = {
|
||||
@@ -166,11 +159,9 @@ _DEVICE_TO_SWING: dict[str, str] = {
|
||||
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
|
||||
|
||||
|
||||
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
|
||||
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
|
||||
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
|
||||
# above (off/vertical/horizontal/both), just read from/written to a pair
|
||||
# of fields rather than a single one.
|
||||
# Swing fallback via /wind/oscillation/vs/0 (issue #126): boards without
|
||||
# WIND_DIRECTION_HREF report two independent Swing|Fix toggles instead of
|
||||
# one combined code. Same HA vocabulary as _DEVICE_TO_SWING above.
|
||||
def _oscillation_swing(rep: dict) -> str | None:
|
||||
vertical = rep.get("vertical")
|
||||
horizontal = rep.get("horizontal")
|
||||
@@ -189,13 +180,12 @@ def _oscillation_swing(rep: dict) -> str | None:
|
||||
|
||||
def _wind_strength_label(code, rep: dict) -> str:
|
||||
"""Human label for a /wind/strength/vs/0 code from the device's own
|
||||
modesName array (parallel-indexed with supportedModes), lowercased for
|
||||
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue
|
||||
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the
|
||||
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving
|
||||
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
|
||||
preset_mode's dynamic code->str resolution. Falls back to the raw code
|
||||
lowercased when modesName is absent or misaligned."""
|
||||
modesName array (parallel-indexed with supportedModes), lowercased --
|
||||
used only for codes _DEVICE_TO_FAN doesn't already cover (issue #155:
|
||||
a board using codes "0"/"31"-"35" instead of the "0"-"4" scale
|
||||
_DEVICE_TO_FAN was built from, with modesName giving the real labels).
|
||||
Falls back to the raw code lowercased when modesName is absent or
|
||||
misaligned."""
|
||||
supported = rep.get("x.com.samsung.da.supportedModes") or []
|
||||
names = rep.get("x.com.samsung.da.modesName") or []
|
||||
if code in supported and len(names) == len(supported):
|
||||
@@ -204,15 +194,10 @@ def _wind_strength_label(code, rep: dict) -> str:
|
||||
|
||||
|
||||
# Preset (convenient mode): resolved dynamically from the device's own
|
||||
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
|
||||
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
|
||||
# exposed as its lowercased self and labelled in translations
|
||||
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>),
|
||||
# so any board's convenient modes surface without code changes, and an
|
||||
# unlabelled code just renders as its raw value until a label is added.
|
||||
# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/
|
||||
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
|
||||
# translation label, not a hard-coded mode.)
|
||||
# /mode/convenient/vs/0 supportedModes -- no per-model table. Device 'Off'
|
||||
# maps to PRESET_NONE; every other code is exposed lowercased and labelled
|
||||
# in translations, so any board's convenient modes surface without code
|
||||
# changes, and an unlabelled code renders as its raw value.
|
||||
def _preset_to_ha(code) -> str:
|
||||
return PRESET_NONE if code == "Off" else str(code).lower()
|
||||
|
||||
@@ -248,12 +233,11 @@ def _num(value):
|
||||
def _temps_vs_item(rep: dict) -> dict:
|
||||
"""First item of the vendor `/temperatures/vs/0` items[] array.
|
||||
|
||||
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
|
||||
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
|
||||
/temperature/current/0 + /temperature/desired/0 pair; it reports current
|
||||
and target under a single `/temperatures/vs/0` resource whose
|
||||
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
|
||||
increment/unit. Returns {} when absent, so callers fall through cleanly.
|
||||
Newer AC firmware (Tizen Lite) doesn't expose the OCF-standard
|
||||
/temperature/current/0 + /temperature/desired/0 pair; it packs current/
|
||||
desired/minimum/maximum/increment/unit into this one resource's
|
||||
items[0] instead. Returns {} when absent, so callers fall through
|
||||
cleanly.
|
||||
"""
|
||||
items = rep.get("x.com.samsung.da.items")
|
||||
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
||||
@@ -264,16 +248,12 @@ def _temps_vs_item(rep: dict) -> dict:
|
||||
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
"""Composite climate entity for a Samsung air conditioner."""
|
||||
|
||||
# translation_key comes from the ClimateDesc (base __init__ sets
|
||||
# _attr_translation_key from bound.desc), resolving the state_attributes
|
||||
# translations under entity.climate.airconditioner.
|
||||
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
|
||||
# Opts out of the deprecated auto-added TURN_ON/OFF backwards compat.
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
# Primary/main entity for the device: no name suffix, just the device name.
|
||||
self._attr_name = None
|
||||
self._attr_name = None # primary entity: no name suffix
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
@@ -283,9 +263,8 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
# (href, raw device code) pairs already logged by _warn_unmapped --
|
||||
# these properties are read on every coordinator refresh, so an
|
||||
# un-deduped warning would spam the log for any device with a
|
||||
# genuinely unrecognized code.
|
||||
# these properties are read on every refresh, so an un-deduped
|
||||
# warning would spam the log for a genuinely unrecognized code.
|
||||
self._warned_unmapped: set[tuple[str, str]] = set()
|
||||
|
||||
# -- resource helpers ---------------------------------------------------
|
||||
@@ -295,100 +274,197 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
|
||||
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
|
||||
|
||||
# Good Sleep occupies the same Comode_ slot as the presets above, so a unit
|
||||
# running it reports Comode_Sleep -- or Comode_NanoSleep, which the board
|
||||
# will produce by itself when nano wind is asked for while the timer runs.
|
||||
# Neither is in the list above, and a preset_mode outside preset_modes is
|
||||
# not a state HA allows, so they are added for boards that have the Sleep_
|
||||
# token these codes come with.
|
||||
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
|
||||
|
||||
# HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode
|
||||
# this transcription has never seen, which is the same "cannot judge" case as
|
||||
# a board that publishes no capability map -- and gets the same fallback.
|
||||
_LEGACY_KNOWN_HVAC = frozenset(
|
||||
{"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE}
|
||||
)
|
||||
|
||||
# Which comfort modes a legacy board offers in which HVAC mode, and which of
|
||||
# them it has at all. Both come from the appliance rather than from a table
|
||||
# per model: the unit publishes its capabilities as two bit maps in
|
||||
# /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates
|
||||
# each item on a bit plus the current mode. _legacy_preset_codes() below is
|
||||
# that logic, transcribed from the app's updateOptionsList() so the two can be
|
||||
# compared line by line, with the bit each rule reads named in the comment.
|
||||
#
|
||||
# Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off
|
||||
# the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model)
|
||||
# Auto but never Heat, and Fast Turbo and Comfort in Heat because oc[12] is
|
||||
# set. d'light Cool is gated the same way on oc[2], which is zero here -- and
|
||||
# the appliance refuses the token locally too.
|
||||
#
|
||||
# Single User is deliberately not modelled, though the app does gate it on
|
||||
# oc[3] / oc[11]: it has no token of its own. The app's own Single User
|
||||
# command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded
|
||||
# 24 desired alongside it, so there is nothing to write that would be
|
||||
# distinguishable from the Smart preset below, and no name to give it that
|
||||
# the appliance would recognise.
|
||||
|
||||
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
|
||||
"""Comfort-mode codes this unit offers in this HVAC mode.
|
||||
|
||||
Derived only for boards that publish *both* capability maps. One map on
|
||||
its own is not enough: every eoc-gated rule would then read None, and
|
||||
None means "this board does not publish the map", never "the feature is
|
||||
absent". The FAC/CAC boards on record carry only the older map, with
|
||||
values small enough that RAC bit positions all read as zeros, so
|
||||
requiring both also keeps these rules inside the family they were
|
||||
documented for.
|
||||
|
||||
Within the derived path, a bit that cannot be read (a malformed or
|
||||
over-wide token) is treated as permission rather than denial for the
|
||||
codes the unconditional list already carried -- losing a working preset
|
||||
to a parsing failure is worse than offering one too many. Codes that were
|
||||
never in that list (d'light) still need their bit to be explicitly set.
|
||||
"""
|
||||
rep = self._rep(MODE_HREF)
|
||||
if (
|
||||
not has_option_code(rep)
|
||||
or not has_extend_option_code(rep)
|
||||
or hvac not in self._LEGACY_KNOWN_HVAC
|
||||
):
|
||||
return list(self._LEGACY_PRESET_CODES)
|
||||
|
||||
cool = hvac in ("Cool", _AI_COMFORT_MODE)
|
||||
heat = hvac in ("Heat", "HeatClean")
|
||||
codes = ["Off"]
|
||||
# WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto
|
||||
# unless this is an 18K model (eoc[30]), where the app switches to Cool for
|
||||
# it instead -- which is what _legacy_preset_needs_cool does.
|
||||
nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE
|
||||
if hvac == "Auto":
|
||||
nano_mode_ok = extend_option_code_bit(rep, 30) is not False
|
||||
if extend_option_code_bit(rep, 31) is not False and nano_mode_ok:
|
||||
codes.append("Nano")
|
||||
if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo
|
||||
codes.append("Speed")
|
||||
if cool:
|
||||
codes.append("2Step")
|
||||
if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set
|
||||
codes.append("DlightCool")
|
||||
# Quiet reads oc[10] with no mode condition in the app, but the owner of
|
||||
# the unit above sees it in Cool and Heat only, on the remote as well as
|
||||
# in the app -- the observation wins over the reading.
|
||||
if option_code_bit(rep, 10) is not False and (cool or heat):
|
||||
codes.append("Quiet")
|
||||
if cool or (heat and option_code_bit(rep, 12) is not False): # Comfort
|
||||
codes.append("Comfort")
|
||||
# Smart Saver has no bit of its own and the app hides it from every single
|
||||
# RAC outright (showSaverOption = false), yet the appliance accepts it and
|
||||
# behaves as Samsung documents -- so absence from the app is not absence
|
||||
# from the hardware. Cool-only, per that documentation.
|
||||
if hvac == "Cool":
|
||||
codes.append("Smart")
|
||||
# Good Sleep, which shares this slot: the app enables it in Cool, Heat and
|
||||
# AIComfort only. Both codes, because the board turns Sleep into NanoSleep
|
||||
# by itself when WindFree is running.
|
||||
if (cool or heat) and any(
|
||||
isinstance(option, str) and option.startswith("Sleep_") for option in options
|
||||
):
|
||||
codes += self._LEGACY_SLEEP_PRESET_CODES
|
||||
return codes
|
||||
|
||||
def _legacy_convenient(self) -> dict:
|
||||
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
|
||||
/mode/vs/0's options, for boards that have no convenient resource."""
|
||||
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
|
||||
for option in options:
|
||||
if isinstance(option, str) and option.startswith("Comode_"):
|
||||
return {
|
||||
_MODES_FIELD: [option.split("_", 1)[1]],
|
||||
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES),
|
||||
}
|
||||
return {}
|
||||
active = next(
|
||||
(o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")),
|
||||
None,
|
||||
)
|
||||
if active is None:
|
||||
return {}
|
||||
|
||||
codes = self._legacy_preset_codes(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options)
|
||||
# Whatever the unit is actually running has to be listed whether the
|
||||
# rules expect it there or not -- a preset_mode outside preset_modes is
|
||||
# not a state HA allows, and the appliance has the last word on what it
|
||||
# is doing (a remote can put it in a mode these rules would not offer).
|
||||
if active not in codes:
|
||||
codes.append(active)
|
||||
return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes}
|
||||
|
||||
def _legacy_airflow(self) -> dict:
|
||||
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to
|
||||
use -- i.e. this board has no /wind/strength/vs/0.
|
||||
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel
|
||||
to use -- i.e. this board has no /wind/strength/vs/0.
|
||||
|
||||
Delegates the board-generation test to is_legacy_board (the same
|
||||
test capabilities/airconditioner.py's token entities are gated on)
|
||||
instead of re-implementing it. Uses self._resources (this unit's own
|
||||
canonical view, issue #177 -- see LocalThingsEntity._resources)
|
||||
rather than a two-key presence dict built from coordinator.resource()'s
|
||||
test the token entities in capabilities/airconditioner.py use)
|
||||
instead of re-implementing it, using self._resources (issue #177)
|
||||
rather than a presence dict built from coordinator.resource()'s
|
||||
truthiness -- resource() collapses "href absent" and "href present
|
||||
with an empty {} rep" to the same falsy value, while is_legacy_board
|
||||
(and discover()'s own binding) test key membership, not truthiness. A
|
||||
presence dict built from truthiness alone would disagree with the
|
||||
token entities on a board reporting a genuinely empty /airflow/vs/0,
|
||||
silently reintroducing the drift this delegation exists to prevent.
|
||||
|
||||
Reads the actual href through self._rep rather than
|
||||
coordinator.resource() directly -- on a subdevice (a legacy-board
|
||||
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
|
||||
canonical AIRFLOW_HREF must be translated through this bound
|
||||
entity's own subdevice first, exactly like every other sibling read
|
||||
below.
|
||||
but empty" to the same falsy value, while is_legacy_board tests key
|
||||
membership. Reads through self._rep, not coordinator.resource()
|
||||
directly, so a subdevice's own /airflow/vs/1 gets translated first,
|
||||
like every other sibling read below.
|
||||
"""
|
||||
if not is_legacy_board(self._resources):
|
||||
return {}
|
||||
return self._rep(AIRFLOW_HREF)
|
||||
|
||||
def _legacy_preset(self) -> bool:
|
||||
"""Whether presets come from the Comode_* token rather than a resource.
|
||||
"""Whether presets come from the Comode_* token rather than a
|
||||
resource. Gated on the same board test as _legacy_airflow, not on
|
||||
the convenient rep being empty alone: newer boards carry Comode
|
||||
tokens too, so a momentarily empty /mode/convenient/vs/0 must not
|
||||
silently switch the preset path over.
|
||||
|
||||
Gated on the same board test as _legacy_airflow, not on the convenient
|
||||
rep being empty alone: newer boards carry Comode tokens too, so a
|
||||
momentarily empty /mode/convenient/vs/0 there must not silently switch
|
||||
the preset read (and write) over to the token path.
|
||||
|
||||
Deliberately reads the *raw* href (translated through this bound
|
||||
entity's own subdevice, not through self._rep) rather than going
|
||||
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
|
||||
is exactly the legacy_convenient() rep this method is deciding
|
||||
whether to use, so routing through it here would make the resource
|
||||
never look empty and this always resolve to the wrong side.
|
||||
Reads the raw href directly rather than through self._rep's own
|
||||
CONVENIENT_HREF fallback -- that fallback IS the legacy_convenient()
|
||||
rep this method is deciding whether to use, so routing through it
|
||||
would make the resource never look empty.
|
||||
"""
|
||||
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
|
||||
return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())
|
||||
|
||||
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) to
|
||||
the real, on-the-wire href before the single-href cache lookup
|
||||
(identity for MAIN, so a device with no subdevices reads exactly the
|
||||
href it always did)."""
|
||||
"""`href` is one of this module's canonical HREF_* constants,
|
||||
translated through this bound entity's own subdevice (issue #177)
|
||||
to the real on-the-wire href -- identity for MAIN."""
|
||||
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
||||
return self._legacy_convenient()
|
||||
return rep
|
||||
|
||||
def _is_on(self) -> bool:
|
||||
# Prefer the vendor /power/vs/0 (present on every observed board and
|
||||
# the resource writes target -- see airconditioner._climate_write).
|
||||
# The OCF /power/0 is absent on many boards and a stale mirror on
|
||||
# some, so reading it first showed pre-write state after a power
|
||||
# toggle (issue #53: "can turn on but not off").
|
||||
# Prefer the vendor /power/vs/0 -- the OCF /power/0 is absent on many
|
||||
# boards and a stale mirror on some, so reading it first showed
|
||||
# pre-write state after a power toggle (issue #53).
|
||||
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
|
||||
if power is not None:
|
||||
return str(power).lower() == "on"
|
||||
return bool(self._rep(POWER_HREF).get("value"))
|
||||
|
||||
def _supported(self, href: str) -> list[str]:
|
||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
"""The resource's own supportedModes, plus any code this unit has
|
||||
been seen in but never advertised (issue #327, learned.py).
|
||||
|
||||
Both the option lists (preset_modes, fan_modes, ...) and the write
|
||||
paths resolve codes through here, so a learned code is selectable
|
||||
and writable by virtue of appearing in one list.
|
||||
"""
|
||||
supported = list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
learned = self.coordinator.learned_modes(self._bound.subdevice.to_actual(href))
|
||||
return supported + [code for code in learned if code not in supported]
|
||||
|
||||
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||
"""Log once per (href, code) when a device-reported mode has no
|
||||
entry in the relevant device<->HA map, so a real device gap surfaces
|
||||
in the log instead of silently vanishing (issue #93).
|
||||
entry in the relevant device<->HA map, so a real gap surfaces in
|
||||
the log instead of silently vanishing (issue #93).
|
||||
|
||||
Falls back to `unique_id` when `entity_id` is unset (issue #235):
|
||||
this fires during setup's first discovery pass, before the entity is
|
||||
added to hass, so `entity_id` is always None at that point --
|
||||
indistinguishable across multiple same-type devices in the log.
|
||||
`unique_id` is set eagerly in `__init__` (see entity.py), so it's
|
||||
always available here even though `entity_id` isn't.
|
||||
"""
|
||||
this can fire during setup's first discovery pass, before the
|
||||
entity is added to hass, when entity_id is still None."""
|
||||
key = (href, code)
|
||||
if key in self._warned_unmapped:
|
||||
return
|
||||
@@ -420,11 +496,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
def _ocf_temp_authoritative(self) -> bool:
|
||||
"""True when the OCF /temperature/{current,desired}/0 pair is the
|
||||
authoritative temperature channel -- signalled by
|
||||
/temperature/current/0 being present. Those boards honour reads/
|
||||
writes on /temperature/desired/0 and ignore the vendor
|
||||
/temperatures/vs/0; boards without the pair (only a desired stub, or
|
||||
nothing) are the reverse. Confirmed on live units of both kinds."""
|
||||
authoritative channel, signalled by /temperature/current/0 being
|
||||
present. Those boards honor reads/writes on /temperature/desired/0
|
||||
and ignore the vendor /temperatures/vs/0; boards without the pair
|
||||
are the reverse. Confirmed on live units of both kinds."""
|
||||
return bool(self._rep(TEMP_CURRENT_HREF))
|
||||
|
||||
def _temps_vs(self) -> dict:
|
||||
@@ -481,12 +556,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float:
|
||||
return (
|
||||
_num(self._rep(TEMP_CONTROL_HREF).get("increment"))
|
||||
or _num(self._rep(TEMP_CONTROL_HREF).get("x.com.samsung.da.increment"))
|
||||
or _num(self._temps_vs().get("x.com.samsung.da.increment"))
|
||||
or 1.0
|
||||
)
|
||||
# Shared with the write path (airconditioner._climate_write) so a
|
||||
# step read here always matches the step a write is quantized to --
|
||||
# self._resources is this entity's own subdevice's canonical view
|
||||
# (issue #177), the same shape _temperature_step expects.
|
||||
return _temperature_step(self._resources) or 1.0
|
||||
|
||||
# -- hvac mode ----------------------------------------------------------
|
||||
|
||||
@@ -597,11 +671,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
return _HVAC_TO_DEVICE.get(hvac_mode)
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
# HA's set_temperature service forwards an optional hvac_mode here; honour
|
||||
# it (set the mode first -- that also powers the unit on when it was off),
|
||||
# matching the climate contract other integrations follow. Without this a
|
||||
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
|
||||
# Auto 24" button) set the setpoint but never changed mode or powered on.
|
||||
# HA's set_temperature service can carry an optional hvac_mode;
|
||||
# honor it (setting the mode also powers the unit on) so a dashboard
|
||||
# "turn on to Auto 24" button doesn't set the setpoint alone.
|
||||
hvac_mode = kwargs.get("hvac_mode")
|
||||
if hvac_mode is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
@@ -647,12 +719,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
supported = self._supported(WIND_STRENGTH_HREF)
|
||||
device = _FAN_TO_DEVICE.get(fan_mode)
|
||||
# A static hit is only trustworthy if this unit's own supportedModes
|
||||
# actually includes that code -- a board can use non-standard codes
|
||||
# (issue #155's "31"-"35") while still spelling a standard label
|
||||
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
|
||||
# return a plausible-looking code ('1'/'3') the device never
|
||||
# advertised at all. Fall through to the live scan whenever the
|
||||
# static guess isn't actually one of this unit's own codes.
|
||||
# includes that code -- a board can use non-standard codes (issue
|
||||
# #155) while still spelling a standard label in modesName, so the
|
||||
# static guess could be a plausible code the device never
|
||||
# advertised. Fall through to the live scan when it isn't one of
|
||||
# this unit's own codes.
|
||||
if device is None or (supported and device not in supported):
|
||||
rep = self._rep(WIND_STRENGTH_HREF)
|
||||
for code in supported:
|
||||
@@ -674,6 +745,38 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
if self._rep(WIND_OSCILLATION_HREF):
|
||||
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
|
||||
|
||||
async def _legacy_preset_needs_cool(self, code: str) -> None:
|
||||
"""Switch a legacy board to Cool first when the preset needs it.
|
||||
|
||||
WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit
|
||||
is in Auto is answered 2.04 Changed and dropped (measured, still
|
||||
Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST
|
||||
does not help -- the mode moves and the token is still dropped, so the
|
||||
board judges the option against the mode it was in. Sent as its own write
|
||||
first, it holds. The appliance's own app pairs `modes: Cool` with its nano
|
||||
command for the same reason.
|
||||
|
||||
Auto only. The app's builder also covers AIComfort, but its
|
||||
`updateOptionsList()` disables the WindFree button there outright, so that
|
||||
pairing can never fire -- and `_legacy_preset_codes()` likewise does not
|
||||
offer `Nano` in AIComfort, which would leave such a branch unreachable.
|
||||
|
||||
The pause is measured, not padding: back to back (same session, no gap at
|
||||
all) the token was dropped again, two seconds apart it held. Three is that
|
||||
with a little margin, and it only ever runs for this one preset in this
|
||||
one HVAC mode.
|
||||
"""
|
||||
if not self._legacy_preset() or code != "Nano":
|
||||
return
|
||||
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto":
|
||||
return
|
||||
# Same resolver the rest of the platform uses -- the device code for an HA
|
||||
# mode is read off the unit's own supportedModes rather than assumed.
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ("mode", self._device_code_for_hvac(HVACMode.COOL))
|
||||
)
|
||||
await asyncio.sleep(_NANO_AFTER_MODE_DELAY)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode == PRESET_AI_COMFORT:
|
||||
# Writes the primary mode resource, not the convenient one --
|
||||
@@ -685,6 +788,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
|
||||
for code in self._supported(CONVENIENT_HREF):
|
||||
if _preset_to_ha(code) == preset_mode:
|
||||
await self._legacy_preset_needs_cool(code)
|
||||
kind = "preset_legacy" if self._legacy_preset() else "preset"
|
||||
await self.coordinator.async_send_command(self._bound, (kind, code))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Cloud "Download" programs on a laundry device (issue #342).
|
||||
|
||||
Some course tables carry a course whose recipe isn't fixed in firmware --
|
||||
selecting it runs whichever program was last pushed down from the
|
||||
SmartThings cloud ("Download" / "Downloaded" in the course catalog). Three
|
||||
tokens on ``/course/vs/0``'s ``x.com.samsung.da.options`` array drive it:
|
||||
|
||||
``CloudExtraCourse_<slot><slot>...`` the device's own list of downloaded
|
||||
program slots, one byte each -- the cloud counterpart of
|
||||
``EditCourseList_`` for local courses.
|
||||
``CloudCourse_<blob>`` the persisted default program.
|
||||
``OneTimeCloudCourse_<blob>`` a this-run-only override.
|
||||
|
||||
A ``<blob>`` is an opaque fixed-width payload whose byte 2 is the slot id
|
||||
it belongs to. Confirmed on two independent DA_WM_TP1_21_COMMON washers:
|
||||
the issue #342 reporter's, whose ``CloudExtraCourse_0A5C286B2D0C55301A``
|
||||
enumerates nine slots matching byte 2 of all nine of its programs exactly,
|
||||
and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot
|
||||
``CloudExtraCourse_5958`` likewise matches its ``CloudCourse`` blob's byte
|
||||
2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one
|
||||
reason nothing here ever synthesizes one.
|
||||
|
||||
``CloudExtraCourse_`` does not mean the same thing on every family, so
|
||||
nothing keys off it directly -- see ``cloud_slots``, which is what the rest
|
||||
of this module and its callers gate on. Even then, a device can advertise a
|
||||
slot whose payload has never been observed, so ``cloud_slots`` answers
|
||||
"which exist" while the store answers "which are usable"; the two are
|
||||
deliberately allowed to disagree.
|
||||
|
||||
What this module does and deliberately does not do
|
||||
--------------------------------------------------
|
||||
The device advertises *which* slots exist but never what any of them is
|
||||
called, and never the full blob for a slot other than the one currently
|
||||
loaded. A blob is only observable while the device happens to be sitting on
|
||||
that program, so the full payload is *learned by observation* and persisted
|
||||
(same rationale as learned.py's mode store), and the human-readable name is
|
||||
supplied by the user in the options flow. Nothing is hardcoded: no catalog
|
||||
of program ids, no table of blobs, no assumed Download course code. A
|
||||
hardcoded catalog was considered and rejected -- a blob is cloud-assigned
|
||||
per account/region, so one user's captured payload is not evidence about
|
||||
anyone else's device.
|
||||
|
||||
Blobs are replayed byte-for-byte, exactly as captured, and never
|
||||
decomposed or rebuilt. (Bytes 5/7/9 of the reporter's blobs do decode
|
||||
cleanly to that program's temperature/rinse/spin, but the same offsets
|
||||
produce nonsense against the WA55A7700AV blob, so that decode is recorded
|
||||
in docs/investigations/download-cycle.md rather than shipped.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from .const import CONF_CLOUD_COURSES
|
||||
from .registry.capabilities.common import hex_pairs, option_value
|
||||
|
||||
COURSE_HREF = "/course/vs/0"
|
||||
|
||||
EXTRA_PREFIX = "CloudExtraCourse"
|
||||
DEFAULT_PREFIX = "CloudCourse"
|
||||
ONESHOT_PREFIX = "OneTimeCloudCourse"
|
||||
COURSE_PREFIX = "Course"
|
||||
|
||||
# Synthetic, integration-owned field the coordinator merges onto
|
||||
# /course/vs/0's rep so the registry's exists_fn/rep_fn/options/write_fn all
|
||||
# reach this store through their existing signatures -- rep_fn in particular
|
||||
# receives only its own href's rep, never the resource snapshot, so a
|
||||
# sibling-resource lookup isn't available to it. Namespaced away from
|
||||
# Samsung's own 'x.com.samsung.da.' fields so it can never collide with one,
|
||||
# and merged at read time only: it is never written to the state cache, never
|
||||
# sent to the device, and never part of a diagnostics dump.
|
||||
FIELD = "x.localthings.cloudCourses"
|
||||
|
||||
# Raw-value namespace for a cloud program in the cycle select. A slot id is
|
||||
# itself two hex chars, exactly like a local course code, so the two would be
|
||||
# indistinguishable (and could collide outright) as bare select values.
|
||||
RAW_PREFIX = "cloud:"
|
||||
|
||||
# A blob whose first two bytes are FFFF means "no program loaded" rather than
|
||||
# naming one -- WA55A7700AV reports
|
||||
# OneTimeCloudCourse_FFFF010049004D004A804C0037F0AC00 while sitting on a
|
||||
# perfectly ordinary local course, and its byte 2 (01) is not one of the slots
|
||||
# its own CloudExtraCourse_ advertises.
|
||||
_SENTINEL_PREFIX = "FFFF"
|
||||
|
||||
# Distinct from None, which is a real observation that carried no payload.
|
||||
# Only "never observed" suppresses a candidate; absent-then-loaded is a
|
||||
# genuine transition and should count.
|
||||
_UNOBSERVED = object()
|
||||
|
||||
# Byte offset within a blob that carries its slot id.
|
||||
_SLOT_BYTE = 2
|
||||
_MIN_BLOB_BYTES = 4
|
||||
|
||||
|
||||
def _hex_bytes(blob):
|
||||
if not isinstance(blob, str) or len(blob) % 2 or len(blob) < _MIN_BLOB_BYTES * 2:
|
||||
return []
|
||||
try:
|
||||
int(blob, 16)
|
||||
except ValueError:
|
||||
return []
|
||||
return hex_pairs(blob.upper())
|
||||
|
||||
|
||||
def is_loaded(blob) -> bool:
|
||||
"""True when `blob` names an actual program rather than 'none'."""
|
||||
return _slot_and_loaded(blob)[1]
|
||||
|
||||
|
||||
def slot_of(blob) -> str | None:
|
||||
"""The slot id `blob` belongs to, or None if it names no program."""
|
||||
slot, loaded = _slot_and_loaded(blob)
|
||||
return slot if loaded else None
|
||||
|
||||
|
||||
def _slot_and_loaded(blob) -> tuple[str | None, bool]:
|
||||
"""Both answers off one parse -- the public pair above needs the same
|
||||
byte split, and observe() asks for both about the same payload."""
|
||||
parts = _hex_bytes(blob)
|
||||
if not parts:
|
||||
return None, False
|
||||
if "".join(parts[:2]) == _SENTINEL_PREFIX:
|
||||
return None, False
|
||||
return parts[_SLOT_BYTE], True
|
||||
|
||||
|
||||
def advertised_slots(rep) -> list[str]:
|
||||
"""Slot ids this device says it has downloaded programs in, from its own
|
||||
CloudExtraCourse_ token. The authority on *which* programs exist -- this
|
||||
is never inferred from what has been learned so far, so "3 of 9
|
||||
discovered" is answerable."""
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX)
|
||||
if not isinstance(raw, str) or len(raw) % 2:
|
||||
return []
|
||||
# Preserve the device's own order (first-seen wins) while dropping any
|
||||
# repeat, so the flow lists slots the way the appliance does.
|
||||
return list(dict.fromkeys(hex_pairs(raw.upper())))
|
||||
|
||||
|
||||
def cloud_slots(rep, courses) -> list[str]:
|
||||
"""Advertised slots that are not already selectable courses.
|
||||
|
||||
`CloudExtraCourse_` does not mean the same thing on every family. On the
|
||||
washers its bytes are opaque payload slots sharing nothing with the
|
||||
device's own course list, and selecting one needs the full payload. On
|
||||
the DW5000C dishwasher all four of its bytes *are* course codes in that
|
||||
device's own list (8E/8D/8F/02 -- Plastic, Pots and pans, Baby Care, and
|
||||
one untranslated), so there it is tagging which of its ordinary courses
|
||||
came from the cloud. Those are already selectable as plain `Course_`
|
||||
writes and need nothing from this module.
|
||||
|
||||
Subtracting the course list tells the two apart without having to guess
|
||||
the family: what remains is slots that cannot be selected any other way,
|
||||
which is exactly the set this module exists for.
|
||||
"""
|
||||
known = {c.upper() for c in courses or ()}
|
||||
return [slot for slot in advertised_slots(rep) if slot not in known]
|
||||
|
||||
|
||||
def supports_cloud_courses(rep, courses) -> bool:
|
||||
"""True for a device with downloaded programs it cannot otherwise run."""
|
||||
return bool(cloud_slots(rep, courses))
|
||||
|
||||
|
||||
def loaded_slot(rep) -> str | None:
|
||||
"""The slot whose payload the appliance currently holds -- the one-time
|
||||
override when one is set, else the saved default.
|
||||
|
||||
Deliberately not gated on the course being Download: the guided setup
|
||||
flow watches this before the Download course has been confirmed, and
|
||||
during that walk a change here *is* the signal that the user selected a
|
||||
different program. A stale token can't produce a false positive because
|
||||
the flow waits for a change from its own baseline, not for a value.
|
||||
"""
|
||||
options = rep.get("x.com.samsung.da.options")
|
||||
return slot_of(option_value(options, ONESHOT_PREFIX)) or slot_of(
|
||||
option_value(options, DEFAULT_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
|
||||
"""Restore the persisted record, dropping anything not the shape this
|
||||
module writes -- it round-trips through the config entry as plain JSON
|
||||
and a hand-edited .storage file must not be able to crash setup (same
|
||||
posture as learned._coerce)."""
|
||||
if not isinstance(stored, dict):
|
||||
return None, {}
|
||||
download = stored.get("download_course")
|
||||
if not isinstance(download, str) or not download:
|
||||
download = None
|
||||
slots: dict[str, dict[str, str]] = {}
|
||||
raw_slots = stored.get("slots")
|
||||
if isinstance(raw_slots, dict):
|
||||
for slot, record in raw_slots.items():
|
||||
if not isinstance(slot, str) or not isinstance(record, dict):
|
||||
continue
|
||||
blob = record.get("blob")
|
||||
if not is_loaded(blob) or slot_of(blob) != slot.upper():
|
||||
continue
|
||||
name = record.get("name")
|
||||
slots[slot.upper()] = {
|
||||
"blob": blob.upper(),
|
||||
"name": name if isinstance(name, str) and name.strip() else "",
|
||||
}
|
||||
return download, slots
|
||||
|
||||
|
||||
def persist(hass, entry, record: dict) -> None:
|
||||
"""Write `record` onto the entry. Runs on the event loop, which
|
||||
async_update_entry requires."""
|
||||
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_CLOUD_COURSES: record})
|
||||
|
||||
|
||||
class CloudCourses:
|
||||
"""Per-device store of discovered cloud programs.
|
||||
|
||||
Mutated from whichever thread applied the update (the DTLS reader for an
|
||||
OBSERVE notify, an executor thread for a poll -- see ObserveManager.apply),
|
||||
so every access takes the lock; persistence is the caller's job, on the
|
||||
event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, stored_record=None) -> None:
|
||||
self._lock = threading.Lock()
|
||||
download, slots = _coerce(stored_record)
|
||||
self._download_course = download
|
||||
self._slots = slots
|
||||
# Course codes seen at the moment a one-time override was *loaded* --
|
||||
# candidates for "which course means Download on this board", pending
|
||||
# user confirmation (see download_candidates).
|
||||
self._candidates: dict[str, int] = {}
|
||||
# Last one-time payload seen, so a load can be told from a poll that
|
||||
# merely re-reports one. _UNOBSERVED until the first rep arrives.
|
||||
self._last_oneshot: object = _UNOBSERVED
|
||||
|
||||
# -- learning ---------------------------------------------------------
|
||||
|
||||
def observe(self, rep: dict) -> bool:
|
||||
"""Learn from one applied /course/vs/0 rep; True if anything changed.
|
||||
|
||||
Two facts are learnable here. A blob is recorded against the slot its
|
||||
own byte 2 names, so a program only has to be sitting loaded once --
|
||||
on either token -- to be replayable forever after.
|
||||
|
||||
The Download course code is only ever taken as a *candidate*, and
|
||||
only at the moment the one-time payload actually *changes* to a
|
||||
loaded value. That instant is the one the device is known to accept a
|
||||
program on, so the course selected then is real evidence. Counting
|
||||
every poll instead would rank by dwell time: tokens in this array are
|
||||
replaced by prefix and never evicted, so a stale OneTimeCloudCourse_
|
||||
outlives its run and sits there through however many polls the
|
||||
appliance spends on some ordinary course afterwards -- which is
|
||||
exactly the course that would then be suggested. A candidate is still
|
||||
never applied without confirmation in the options flow, but a
|
||||
confident wrong suggestion is most of the way to a wrong write, and a
|
||||
wrong write here starts a real wash cycle.
|
||||
"""
|
||||
options = rep.get("x.com.samsung.da.options")
|
||||
if not options:
|
||||
return False
|
||||
known_slots = advertised_slots(rep)
|
||||
oneshot = option_value(options, ONESHOT_PREFIX)
|
||||
with self._lock:
|
||||
# Compared once, at the end, against where this pass started --
|
||||
# not set per assignment. The two tokens can name the same slot
|
||||
# with different payloads (a downloaded program with its settings
|
||||
# tweaked for one run is exactly that shape), and a per-assignment
|
||||
# flag would then report a change on every single poll forever:
|
||||
# each pass writes the default's payload and then the one-shot's
|
||||
# over it, so neither is ever "already stored". Every one of those
|
||||
# reports rewrites the config entry, which on the SD-card installs
|
||||
# this integration runs on is the one cost here that really bites.
|
||||
# The end state is stable (the one-shot is written last and wins),
|
||||
# so comparing start to end settles after the first pass.
|
||||
before = {slot: record["blob"] for slot, record in self._slots.items()}
|
||||
for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX):
|
||||
blob = option_value(options, prefix)
|
||||
slot = slot_of(blob)
|
||||
# A blob whose slot the device doesn't advertise is not a
|
||||
# program this appliance offers -- don't record it.
|
||||
if slot is None or (known_slots and slot not in known_slots):
|
||||
continue
|
||||
record = self._slots.get(slot)
|
||||
if record is None:
|
||||
self._slots[slot] = {"blob": blob.upper(), "name": ""}
|
||||
else:
|
||||
record["blob"] = blob.upper()
|
||||
changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()}
|
||||
|
||||
course = option_value(options, COURSE_PREFIX)
|
||||
# Only a transition we actually watched happen counts. On the
|
||||
# first observation there is nothing to compare against, so a
|
||||
# payload sitting there is equally consistent with "just loaded"
|
||||
# and "left over from last week" -- and on a board that doesn't
|
||||
# clear the token when leaving Download, believing the former
|
||||
# proposes whatever ordinary course the appliance happens to be
|
||||
# on. Accepting that prefill would start a real wash cycle.
|
||||
# (The two dumps in the corpus taken off the Download course both
|
||||
# show the appliance clearing it to the FFFF sentinel, so this
|
||||
# may never fire in practice -- which is not a reason to rely on
|
||||
# it.) Restores don't persist _last_oneshot, so every restart
|
||||
# re-enters this first-observation state deliberately.
|
||||
first_ever = self._last_oneshot is _UNOBSERVED
|
||||
if course and is_loaded(oneshot) and not first_ever and oneshot != self._last_oneshot:
|
||||
self._candidates[course] = self._candidates.get(course, 0) + 1
|
||||
self._last_oneshot = oneshot
|
||||
return changed
|
||||
|
||||
# -- reads ------------------------------------------------------------
|
||||
|
||||
def download_candidates(self) -> list[str]:
|
||||
"""Course codes seen at the moment a one-time program was loaded,
|
||||
most-observed first -- what the options flow offers as the likely
|
||||
Download course. Never used for a write on its own."""
|
||||
with self._lock:
|
||||
ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
return [code for code, _ in ranked]
|
||||
|
||||
def named(self) -> dict[str, str]:
|
||||
"""Slots that are both learned and named -- the only ones offerable
|
||||
as a cycle option. An unnamed slot has no label that isn't either
|
||||
invented or an opaque hex id, so it stays out of the UI until the
|
||||
user supplies one."""
|
||||
with self._lock:
|
||||
return {slot: record["name"] for slot, record in self._slots.items() if record["name"]}
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"download_course": self._download_course,
|
||||
"slots": {slot: dict(record) for slot, record in self._slots.items()},
|
||||
}
|
||||
|
||||
def view(self) -> dict:
|
||||
"""What the registry sees under FIELD: only what a write or a label
|
||||
can actually be built from, so a descriptor never has to re-apply
|
||||
this module's rules."""
|
||||
with self._lock:
|
||||
if not self._download_course:
|
||||
return {}
|
||||
return {
|
||||
"download_course": self._download_course,
|
||||
"programs": {
|
||||
slot: {"blob": record["blob"], "name": record["name"]}
|
||||
for slot, record in self._slots.items()
|
||||
if self._is_usable(record)
|
||||
},
|
||||
}
|
||||
|
||||
# -- writes -----------------------------------------------------------
|
||||
|
||||
def set_download_course(self, code: str | None) -> None:
|
||||
with self._lock:
|
||||
self._download_course = code or None
|
||||
|
||||
def set_name(self, slot: str, name: str) -> None:
|
||||
with self._lock:
|
||||
record = self._slots.get(slot.upper())
|
||||
if record is not None:
|
||||
record["name"] = name.strip()
|
||||
|
||||
@staticmethod
|
||||
def _is_usable(record) -> bool:
|
||||
"""A slot is offerable once it has a name. The device supplies the
|
||||
payload; only the user can supply the label, so this is the whole
|
||||
rule and it is stated once."""
|
||||
return bool(record["name"])
|
||||
|
||||
|
||||
def undiscovered(rep: dict, record: dict, courses) -> list[str]:
|
||||
"""Cloud slots that aren't yet usable -- unlearned or unnamed. What the
|
||||
Repairs issue counts, and what the options flow asks the user to walk the
|
||||
appliance through. Counts against cloud_slots, not every advertised byte:
|
||||
a slot that is already a selectable course is nothing to set up."""
|
||||
programs = record.get("slots") or {}
|
||||
return [s for s in cloud_slots(rep, courses) if not (programs.get(s) or {}).get("name")]
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import errno
|
||||
@@ -20,6 +21,7 @@ import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
@@ -33,6 +35,7 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import cloudcourse
|
||||
from .const import (
|
||||
CLIENTHELLO_PROBE_RETRIES,
|
||||
CLIENTHELLO_PROBE_TIMEOUT_S,
|
||||
@@ -44,18 +47,25 @@ from .const import (
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_LEARN_MODES,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DEFAULT_LEARN_MODES,
|
||||
DOMAIN,
|
||||
LIVENESS_PROBE_TIMEOUT_S,
|
||||
PREFERRED_PROBE_PORTS,
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
PROBE_MAX_WORKERS,
|
||||
PROBE_PORT_RANGE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
from .learned import persist as learned_persist
|
||||
from .learned import stored as learned_stored
|
||||
from .registry.capabilities.laundry import cycle_options, personal_course_labels
|
||||
from .registry.subdevices import MAIN
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
|
||||
@@ -68,6 +78,14 @@ _HYSTERESIS_MINUTES = NumberSelector(
|
||||
)
|
||||
)
|
||||
|
||||
# Guided download-cycle setup: how long a round waits for the user to
|
||||
# select a program, and how often it live-reads /course/vs/0 while doing
|
||||
# so. The read takes the session lock, so the interval is a few seconds
|
||||
# rather than sub-second -- fast enough to feel immediate to someone
|
||||
# standing at the appliance, slow enough not to starve polling.
|
||||
_CLOUD_WAIT_TIMEOUT_S = 180.0
|
||||
_CLOUD_PROBE_INTERVAL_S = 3.0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com"
|
||||
@@ -77,15 +95,10 @@ class CannotConnect(Exception):
|
||||
"""Base for every probe failure.
|
||||
|
||||
`error_key` selects which message the user sees. The subclasses below
|
||||
exist because "cannot connect" covered wildly different situations -- an
|
||||
IP with nothing on it, an appliance on cloud-only firmware, a device
|
||||
that's simply still holding a session from the last attempt, and a device
|
||||
that answered and rejected our certificate all told the user the same
|
||||
thing ("check the IP and the CA credentials"), which is only actionable
|
||||
advice for one of them.
|
||||
|
||||
Raising this base class directly is still valid for a failure we can't
|
||||
narrow down; it maps to that same generic message.
|
||||
exist because "cannot connect" used to cover wildly different situations
|
||||
(nothing at that IP, cloud-only firmware, a stale held session, a
|
||||
rejected certificate) all under one unhelpful message. Raising this base
|
||||
class directly is still valid for a failure that can't be narrowed down.
|
||||
"""
|
||||
|
||||
error_key = "cannot_connect"
|
||||
@@ -144,11 +157,9 @@ class InvalidCA(Exception):
|
||||
|
||||
|
||||
def _fetch_samsung_uuid() -> str:
|
||||
"""Connect to Samsung's cloud gateway and extract the UUID from its TLS cert.
|
||||
|
||||
Verification is disabled because Samsung's chain contains a self-signed cert.
|
||||
We only need to read the UUID from the cert subject, not verify its trust.
|
||||
"""
|
||||
"""Connect to Samsung's cloud gateway and extract the UUID from its TLS
|
||||
cert. Verification is disabled: Samsung's chain has a self-signed cert,
|
||||
and we only need to read the UUID from the subject, not verify trust."""
|
||||
from cryptography import x509 as _x509
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
@@ -170,6 +181,17 @@ def _fetch_samsung_uuid() -> str:
|
||||
raise RuntimeError(f"UUID not found in {_SAMSUNG_CLOUD_HOST} certificate subject")
|
||||
|
||||
|
||||
def _normalize_pem(text: str) -> str:
|
||||
"""Strip a pasted PEM's BOM, CRLF endings, and blank lines before
|
||||
`cryptography` sees it -- a text editor's copy carries all three and
|
||||
fails with an opaque InvalidHeader, while the same file dumped via
|
||||
`type` doesn't (issue #291)."""
|
||||
text = text.lstrip("\ufeff")
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [line for line in text.split("\n") if line.strip()]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str, str]:
|
||||
"""Mint a fresh RSA-2048 leaf cert signed by the CA.
|
||||
|
||||
@@ -251,13 +273,11 @@ def _order_candidates(ports: list[int]) -> list[int]:
|
||||
return preferred + rest
|
||||
|
||||
|
||||
# The kernel's way of saying the datagram never had anywhere to go: no route
|
||||
# to the network, or the host never answered ARP on our own LAN. Distinct from
|
||||
# ECONNREFUSED, which is a *response* -- the host is there and told us the port
|
||||
# is closed. Both leave a port "not live", but they mean opposite things about
|
||||
# whether anything exists at that address, which is the difference between
|
||||
# telling a user to check the IP and telling them their appliance is on
|
||||
# cloud-only firmware.
|
||||
# The kernel's way of saying the datagram never had anywhere to go: no route,
|
||||
# or the host never answered ARP. Distinct from ECONNREFUSED, which is a
|
||||
# response -- the host is there and told us the port is closed. Both leave a
|
||||
# port "not live", but mean opposite things about whether anything exists at
|
||||
# that address.
|
||||
_UNREACHABLE_ERRNOS = frozenset({errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN})
|
||||
|
||||
|
||||
@@ -273,24 +293,17 @@ class _SweepResult:
|
||||
def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResult:
|
||||
"""Fast UDP liveness sweep -- the sweep's own verdict, nothing added.
|
||||
|
||||
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP
|
||||
port-unreachable that a closed port returns as ECONNREFUSED on its next
|
||||
recv. So we send one probe datagram per port and watch for that error:
|
||||
UDP is connectionless, but a connected UDP socket surfaces the ICMP
|
||||
port-unreachable a closed port returns as ECONNREFUSED on its next recv.
|
||||
So we send one probe datagram per port and watch for that error:
|
||||
ECONNREFUSED means closed; silence/data means possibly live. The
|
||||
in-process equivalent of ``nmap -sU``: takes a nine-port range down to
|
||||
the one or two worth a full DTLS handshake, bounded to ``timeout``.
|
||||
|
||||
* ECONNREFUSED -> port is closed (device actively rejected it)
|
||||
* silence / any data -> port may be live (open|filtered); a candidate
|
||||
|
||||
This is the in-process equivalent of ``nmap -sU``: it lets us take a
|
||||
nine-port range down to the one or two ports actually worth a full DTLS
|
||||
handshake + /device/0 GET, and bounds the total wait to ``timeout``
|
||||
instead of stalling on every dead port when a firewall swallows the ICMP
|
||||
replies.
|
||||
|
||||
The result is deliberately the raw verdict, with no preferred-port rescue
|
||||
folded in (that's `_sweep_ports`): its *shape* is evidence about the host,
|
||||
and mixing a rescue into it would destroy that. Which is also why a
|
||||
refusal and an unreachable are counted apart rather than both just being
|
||||
"not live" -- see _SweepResult.
|
||||
Deliberately the raw verdict, with no preferred-port rescue folded in
|
||||
(that's `_sweep_ports`) -- its shape is evidence about the host, and a
|
||||
refusal vs. an unreachable are counted apart rather than both "not
|
||||
live" for that reason (see _SweepResult).
|
||||
"""
|
||||
sockets: dict[int, socket.socket] = {}
|
||||
sel = selectors.DefaultSelector()
|
||||
@@ -312,7 +325,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul
|
||||
sock.send(probe)
|
||||
except OSError as exc:
|
||||
# Failing on the way out means the kernel already knows the
|
||||
# datagram can't get there (no route, ARP never resolved).
|
||||
# datagram can't get there.
|
||||
_rule_out(port, exc)
|
||||
sock.close()
|
||||
continue
|
||||
@@ -329,8 +342,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul
|
||||
for key, _ in sel.select(timeout=remaining):
|
||||
sock = sockets[key.data]
|
||||
try:
|
||||
# Data back means live; an error means the port is
|
||||
# closed or the host isn't there — either way, rule it out.
|
||||
# Data back means live; an error rules the port out.
|
||||
sock.recv(1)
|
||||
except OSError as exc:
|
||||
_rule_out(key.data, exc)
|
||||
@@ -349,18 +361,17 @@ def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepRes
|
||||
"""`(sweep, candidates)` -- what the host said, and what to actually try.
|
||||
|
||||
The sweep's ICMP-based verdict isn't reliable on every network path --
|
||||
issue #192 captured a segregated-VLAN device where it called three ports
|
||||
live that a concurrent nmap scan showed as closed, while the port nmap
|
||||
found genuinely open|filtered (49154, one of our historically confirmed
|
||||
ports) never showed up as live at all. Rather than trust a wrong "not
|
||||
live" verdict on a port we already have strong prior evidence for, always
|
||||
give the historically-confirmed ports a real handshake attempt too.
|
||||
Bounded cost: at most len(PREFERRED_PROBE_PORTS) extra handshakes, only
|
||||
when the sweep disagrees with the prior.
|
||||
issue #192 captured a segregated-VLAN device where it called live ports
|
||||
that nmap showed closed, while the port nmap found genuinely open never
|
||||
showed up as live at all. Rather than trust a wrong "not live" verdict
|
||||
on a port with strong prior evidence, the historically-confirmed ports
|
||||
always get a real handshake attempt too (bounded cost: at most
|
||||
len(PREFERRED_PROBE_PORTS) extra handshakes, only when the sweep
|
||||
disagrees with the prior).
|
||||
|
||||
Both halves are returned rather than just the union because they answer
|
||||
different questions: `candidates` is what to hand a handshake, `sweep` is
|
||||
what the host actually told us about itself.
|
||||
Both halves are returned, not just the union, since they answer
|
||||
different questions: `candidates` is what to hand a handshake, `sweep`
|
||||
is what the host actually told us about itself.
|
||||
"""
|
||||
sweep = _find_live_ports(host, ports, timeout)
|
||||
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in sweep.live]
|
||||
@@ -371,10 +382,10 @@ def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepRes
|
||||
class _PortScan:
|
||||
"""What port detection learned about a host.
|
||||
|
||||
`candidates` is what gets a full DTLS handshake. The other two are kept
|
||||
because they're the evidence behind a failure message: `confirmed` names
|
||||
ports a DTLS server was *proven* on, and `swept` is the UDP sweep's own
|
||||
verdict (None when the sweep never had to run).
|
||||
`candidates` is what gets a full DTLS handshake. The other two are the
|
||||
evidence behind a failure message: `confirmed` names ports a DTLS
|
||||
server was proven on, `swept` is the UDP sweep's own verdict (None
|
||||
when the sweep never had to run).
|
||||
"""
|
||||
|
||||
candidates: list[int]
|
||||
@@ -383,12 +394,10 @@ class _PortScan:
|
||||
|
||||
|
||||
def _clienthello_probe(host: str, port: int):
|
||||
"""One stateless DTLS ClientHello against `host:port`.
|
||||
|
||||
Imported lazily so an install whose smartthings-local predates the probe
|
||||
(< 0.1.2) degrades to the UDP sweep at scan time rather than failing to
|
||||
load the config flow at all.
|
||||
"""
|
||||
"""One stateless DTLS ClientHello against `host:port`. Imported lazily
|
||||
so an install whose smartthings-local predates the probe (< 0.1.2)
|
||||
degrades to the UDP sweep at scan time rather than failing to load the
|
||||
config flow at all."""
|
||||
from smartthings_local.protocol.dtls_probe import probe
|
||||
|
||||
return probe(
|
||||
@@ -406,17 +415,14 @@ def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
|
||||
|
||||
smartthings-local's stateless probe sends one ClientHello and stops the
|
||||
moment the server proves itself with a HelloVerifyRequest, which per RFC
|
||||
6347 §4.2.1 the server answers *without* allocating association state. So
|
||||
this identifies the device's real port in ~1 RTT, leaves nothing behind on
|
||||
the appliance, and costs it far less than the alternative of throwing N
|
||||
full certificate handshakes at it to find out.
|
||||
6347 §4.2.1 the server answers without allocating association state --
|
||||
identifies the device's real port in ~1 RTT, far cheaper than throwing N
|
||||
full certificate handshakes at it.
|
||||
|
||||
The whole range goes out at once. That's safe in a way racing real
|
||||
handshakes is not: each probe is bounded by CLIENTHELLO_PROBE_TIMEOUT_S
|
||||
rather than DtlsCoapSession's 12s handshake timeout, so the pool's
|
||||
shutdown-and-wait on exit costs one probe's budget, not the sum of the
|
||||
range -- no `shutdown(wait=False)` and no losing threads left running
|
||||
behind us.
|
||||
The whole range goes out at once, safely: each probe is bounded by
|
||||
CLIENTHELLO_PROBE_TIMEOUT_S rather than DtlsCoapSession's 12s handshake
|
||||
timeout, so the pool's shutdown-and-wait on exit costs one probe's
|
||||
budget, not the sum of the range.
|
||||
"""
|
||||
with ThreadPoolExecutor(max_workers=min(len(ports), PROBE_MAX_WORKERS)) as ex:
|
||||
results = list(ex.map(lambda port: _clienthello_probe(host, port), ports))
|
||||
@@ -432,19 +438,16 @@ def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
|
||||
def _scan_ports(host: str) -> _PortScan:
|
||||
"""Find the device's DTLS port, preferring proof over absence of evidence.
|
||||
|
||||
The ClientHello probe is authoritative when it finds something: a port
|
||||
that answered one is running a DTLS server, so exactly one port gets the
|
||||
expensive certificate handshake instead of every port the old UDP sweep
|
||||
couldn't rule out (each of which cost a full 12s handshake timeout --
|
||||
issue #211's 30-40s adds).
|
||||
The ClientHello probe is authoritative when it finds something: exactly
|
||||
one port gets the expensive certificate handshake instead of every port
|
||||
the old UDP sweep couldn't rule out (issue #211's 30-40s of 12s handshake
|
||||
timeouts).
|
||||
|
||||
It stays a *gate*, not a replacement: when it confirms nothing we fall
|
||||
back to the ICMP-based sweep, which is wrong in the opposite direction
|
||||
(it reports everything it can't rule out) and so still surfaces a device
|
||||
the probe couldn't reach -- e.g. a network path that drops our
|
||||
ClientHello outright, or an install still on smartthings-local < 0.1.2.
|
||||
Issue #192's segregated-VLAN device is the reason that fallback keeps its
|
||||
own preferred-port rescue.
|
||||
It's a gate, not a replacement: when it confirms nothing, we fall back
|
||||
to the ICMP-based sweep, which still surfaces a device the probe
|
||||
couldn't reach (a network path dropping the ClientHello, or an install
|
||||
on smartthings-local < 0.1.2). Issue #192's segregated-VLAN device is
|
||||
why that fallback keeps its own preferred-port rescue.
|
||||
"""
|
||||
try:
|
||||
confirmed = _clienthello_scan(host, PROBE_PORT_RANGE)
|
||||
@@ -472,12 +475,10 @@ def _scan_ports(host: str) -> _PortScan:
|
||||
return _PortScan(candidates, [], sweep)
|
||||
|
||||
|
||||
# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and said
|
||||
# no", as opposed to a protocol/cipher disagreement. decrypt_error belongs
|
||||
# here: it's what a peer sends when CertificateVerify fails. These are the
|
||||
# alerts an appliance sends when the CA behind the leaf isn't one it trusts --
|
||||
# the single most common real setup mistake, and the one the old blanket
|
||||
# "check the IP and the CA credentials" message could never call out.
|
||||
# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and
|
||||
# said no", as opposed to a protocol/cipher disagreement -- what an
|
||||
# appliance sends when the CA behind the leaf isn't one it trusts, the
|
||||
# single most common real setup mistake.
|
||||
_CERT_ALERTS = frozenset(
|
||||
{
|
||||
"bad_certificate",
|
||||
@@ -493,15 +494,13 @@ _CERT_ALERTS = frozenset(
|
||||
)
|
||||
|
||||
# OpenSSL renders a received fatal alert into its error text as e.g.
|
||||
# "tlsv1 alert unknown ca" / "sslv3 alert bad certificate", which
|
||||
# DtlsCoapSession.connect() wraps in a ConnectionError. Reading it back out
|
||||
# tells us what the appliance actually objected to.
|
||||
# "tlsv1 alert unknown ca", which DtlsCoapSession.connect() wraps in a
|
||||
# ConnectionError. Reading it back tells us what the appliance objected to.
|
||||
#
|
||||
# Deliberately not the library's diagnostic probe (stateless=False), which
|
||||
# would report the alert authoritatively: that mode drives the handshake far
|
||||
# enough to commit association state on the device, and an orphaned
|
||||
# association is exactly what makes the *next* attempt time out (RFC 6347
|
||||
# §4.2.8) -- a bad trade on a path the user is about to retry.
|
||||
# Deliberately not the library's diagnostic probe (stateless=False): that
|
||||
# mode commits association state on the device, and an orphaned association
|
||||
# makes the next attempt time out (RFC 6347 §4.2.8) -- a bad trade on a
|
||||
# path the user is about to retry.
|
||||
_ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)")
|
||||
|
||||
|
||||
@@ -516,16 +515,12 @@ def _classify_handshake_failure(
|
||||
scan: _PortScan,
|
||||
failures: list[tuple[int, Exception]],
|
||||
) -> CannotConnect:
|
||||
"""Turn "no port worked" into the most specific thing we can honestly say.
|
||||
|
||||
In rough order of how much the evidence tells us:
|
||||
|
||||
* An alert means the appliance is there, speaks DTLS, and refused us on
|
||||
purpose -- and the alert says whether it was about our certificate.
|
||||
* A confirmed DTLS port that then timed out is a device that is present
|
||||
and healthy but wouldn't finish. Usually it's still holding the session
|
||||
from a previous attempt, which clears on its own.
|
||||
* Otherwise the sweep's own shape is the evidence -- see the rules below.
|
||||
"""Turn "no port worked" into the most specific thing we can honestly
|
||||
say, in rough order of how much the evidence tells us: an alert means
|
||||
the appliance refused us on purpose (and says whether it was our
|
||||
certificate); a confirmed DTLS port that then timed out is likely still
|
||||
holding a session from a previous attempt; otherwise the sweep's own
|
||||
shape is the evidence.
|
||||
"""
|
||||
alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name]
|
||||
cert_alerts = [name for name in alerts if name in _CERT_ALERTS]
|
||||
@@ -542,18 +537,18 @@ def _classify_handshake_failure(
|
||||
if sweep is None:
|
||||
return CannotConnect(f"no port on {host} completed a handshake")
|
||||
if sweep.unreachable and not sweep.refused:
|
||||
# The kernel never got the datagrams off the host, so nothing was
|
||||
# ever asked. Reporting "ports closed" here would be exactly wrong.
|
||||
# Nothing was ever asked -- the kernel never got the datagrams off
|
||||
# the host, so "ports closed" would be exactly wrong.
|
||||
return NoResponse(f"{host} is unreachable (ports {sweep.unreachable})")
|
||||
if not sweep.live:
|
||||
# Every port answered ICMP port-unreachable: something is at that
|
||||
# address and it is not exposing the local API.
|
||||
# Every port answered ICMP port-unreachable: something is there and
|
||||
# not exposing the local API.
|
||||
return PortsClosed(
|
||||
f"{host} refused every port in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
|
||||
)
|
||||
if len(sweep.live) == len(PROBE_PORT_RANGE):
|
||||
# Not one refusal came back across a nine-port ephemeral range. A host
|
||||
# that is actually there answers for at least some of it.
|
||||
# Not one refusal across a nine-port range -- a host that's
|
||||
# actually there answers for at least some of it.
|
||||
return NoResponse(f"nothing at {host} responded on any probed port")
|
||||
return NoDtlsServer(f"ports on {host} are reachable but none answered a DTLS handshake")
|
||||
|
||||
@@ -586,15 +581,15 @@ def _read_device(sess, host: str, port: int) -> dict:
|
||||
|
||||
/oic/d before /device/0, deliberately: the device's own OCF device-type
|
||||
declaration is the primary detection signal when a board populates it
|
||||
(see registry/by_type's resolve()), and read_identity's three small GETs
|
||||
settle it long before the blockwise /device/0 dump lands. read_identity is
|
||||
defensive on every GET it makes, so a device that answers neither /oic/p
|
||||
nor /oic/d just yields an empty device_types tuple and detection falls
|
||||
through to the model-string/resource-signature path.
|
||||
(see registry/by_type's resolve()), and read_identity's three small
|
||||
GETs settle it long before the blockwise /device/0 dump lands.
|
||||
read_identity is defensive on every GET, so a device answering neither
|
||||
/oic/p nor /oic/d falls through to the model-string/resource-signature
|
||||
path.
|
||||
|
||||
Everything the entry needs to name and key the device comes from here --
|
||||
resolved serial, model, manufacturer, device type -- so the coordinator
|
||||
never has to mint a registry key from a placeholder (issue #236).
|
||||
Everything the entry needs to name and key the device comes from here,
|
||||
so the coordinator never has to mint a registry key from a placeholder
|
||||
(issue #236).
|
||||
"""
|
||||
import cbor2
|
||||
|
||||
@@ -665,17 +660,15 @@ def _probe_and_validate(
|
||||
) -> dict:
|
||||
"""Find the device's port, authenticate to it, and resolve its identity.
|
||||
|
||||
Port detection runs first and needs no credentials at all, so an
|
||||
unreachable host fails here rather than after a round trip to Samsung's
|
||||
cloud.
|
||||
Port detection runs first and needs no credentials, so an unreachable
|
||||
host fails here rather than after a round trip to Samsung's cloud.
|
||||
|
||||
`existing_leaf` is another entry's already-minted leaf (issue #211).
|
||||
Every appliance accepts the same leaf -- CA `AC14K_M` plus the UUID from
|
||||
Samsung's cloud cert -- so adding a second device can skip the fetch and
|
||||
mint entirely, which makes it independent of Samsung-cloud reachability
|
||||
rather than merely faster. If that reused leaf turns out to be stale (the
|
||||
UUID does rotate), a confirmed-live device rejecting it is unambiguous
|
||||
enough to re-mint and try once more, so the reuse stays self-correcting.
|
||||
Every appliance accepts the same leaf, so adding a second device can
|
||||
skip the fetch and mint entirely -- independent of Samsung-cloud
|
||||
reachability, not merely faster. If that reused leaf turns out to be
|
||||
stale (the UUID does rotate), a confirmed-live device rejecting it
|
||||
re-mints and retries once, so the reuse stays self-correcting.
|
||||
"""
|
||||
scan = _scan_ports(host)
|
||||
|
||||
@@ -718,11 +711,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
def _create_entry(self, info: dict) -> ConfigFlowResult:
|
||||
"""Persist everything the probe resolved, identity included.
|
||||
|
||||
The identity fields are not decoration: the coordinator seeds
|
||||
`device_serial` and its DeviceInfo from them at construction time, so
|
||||
entity unique_ids and device identifiers are correct from the very
|
||||
first entity that registers -- even if the first poll is slow, or
|
||||
fails outright (issue #236).
|
||||
The identity fields aren't decoration: the coordinator seeds
|
||||
`device_serial` and its DeviceInfo from them at construction time,
|
||||
so entity unique_ids are correct from the first entity that
|
||||
registers, even if the first poll is slow or fails (issue #236).
|
||||
"""
|
||||
from .registry.identity import device_display_name
|
||||
|
||||
@@ -759,8 +751,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if leaf_cert and leaf_key:
|
||||
existing_leaf = (leaf_cert, leaf_key)
|
||||
else:
|
||||
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
|
||||
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
|
||||
# Normalized here, not just before minting: this is also
|
||||
# what gets stored and reused to re-mint the leaf later.
|
||||
self._ca_cert_pem = _normalize_pem(user_input[CONF_CA_CERT_PEM])
|
||||
self._ca_key_pem = _normalize_pem(user_input[CONF_CA_KEY_PEM])
|
||||
|
||||
try:
|
||||
info = await self.hass.async_add_executor_job(
|
||||
@@ -772,8 +766,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
except (CannotConnect, InvalidCA) as exc:
|
||||
# Every probe failure carries the message that fits it (see
|
||||
# CannotConnect); the log line is where the specifics live,
|
||||
# since the messages point users at it.
|
||||
# CannotConnect); the log line is where the specifics live.
|
||||
_LOGGER.warning("Probe of %s failed [%s]: %s", self._host, exc.error_key, exc)
|
||||
errors["base"] = exc.error_key
|
||||
except Exception:
|
||||
@@ -802,7 +795,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -836,28 +829,37 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
arbitrary resource href, so a user can pin down device-specific write
|
||||
behavior without waiting on a new release.
|
||||
|
||||
The remote-control override exists because most devices reject writes
|
||||
outright while remote control is off and a clear error beats a silent
|
||||
device-side rejection -- but not every model actually enforces that,
|
||||
so this lets a user who's confirmed their device accepts writes anyway
|
||||
turn the block off for just that device rather than it being
|
||||
hardcoded on for everyone. The debug panel goes further: it bypasses
|
||||
that block (and every write_fn/validate_fn) entirely, sending exactly
|
||||
the body the user types to whatever href they pick.
|
||||
The remote-control override exists because not every model actually
|
||||
enforces the block most devices do, so a user who's confirmed their
|
||||
device accepts writes anyway can turn it off for just that device. The
|
||||
debug panel goes further, bypassing that block (and every write_fn/
|
||||
validate_fn) entirely.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._debug_href: str = ""
|
||||
self._debug_result: tuple[int, dict] | None = None
|
||||
# Guided download-cycle setup. `_cloud_task` is created once per
|
||||
# round and reused across re-entries (Home Assistant re-enters a
|
||||
# progress step while its spinner is up).
|
||||
self._cloud_task: asyncio.Task[str | None] | None = None
|
||||
self._cloud_slot: str | None = None
|
||||
self._cloud_baseline: str | None = None
|
||||
|
||||
def _coordinator(self):
|
||||
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=["settings", "debug_write"],
|
||||
)
|
||||
menu = ["settings", "forget_learned_modes", "debug_write"]
|
||||
# Only offered on an appliance that actually advertises downloaded
|
||||
# programs (issue #342) -- every other device would get a menu entry
|
||||
# leading to an empty screen.
|
||||
coord = self._coordinator()
|
||||
if coord is not None and cloudcourse.supports_cloud_courses(
|
||||
coord.cloud_course_rep(), cycle_options(coord.canonical_resources(MAIN))
|
||||
):
|
||||
menu.insert(1, "cloud_courses")
|
||||
return self.async_show_menu(step_id="init", menu_options=menu)
|
||||
|
||||
async def async_step_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -880,10 +882,428 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
),
|
||||
): _HYSTERESIS_MINUTES,
|
||||
vol.Required(
|
||||
CONF_LEARN_MODES,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_LEARN_MODES, DEFAULT_LEARN_MODES
|
||||
),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_forget_learned_modes(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm-and-clear for the learned-mode store (issue #327).
|
||||
|
||||
The point of learning is that it's permanent, so a code learned
|
||||
from a one-off firmware hiccup would otherwise sit in an option
|
||||
list forever. An empty schema renders as a plain confirmation
|
||||
form; the description lists what's about to be forgotten.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
# learned.py owns the entry key and the persisted shape, so this
|
||||
# step never parses or writes it itself -- including on an unloaded
|
||||
# entry, where a malformed record would otherwise abort the one
|
||||
# screen that can clear it.
|
||||
learned = (
|
||||
coord.learned_snapshot() if coord is not None else learned_stored(self.config_entry)
|
||||
)
|
||||
codes = sorted({code for codes in learned.values() for code in codes})
|
||||
|
||||
if user_input is not None:
|
||||
if coord is not None:
|
||||
coord.forget_learned_modes()
|
||||
else:
|
||||
learned_persist(self.hass, self.config_entry, {})
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="forget_learned_modes",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={"codes": ", ".join(codes) if codes else "(none)"},
|
||||
)
|
||||
|
||||
async def async_step_cloud_courses(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Entry point for download-cycle setup (issue #342).
|
||||
|
||||
Guided setup is offered first because it is the only version of this
|
||||
that a first-time user can complete confidently: it asks about a
|
||||
program in the moment they select it, rather than about a list of hex
|
||||
ids some time later. The bulk form stays for renaming afterwards,
|
||||
which guided setup is bad at.
|
||||
"""
|
||||
return self.async_show_menu(
|
||||
step_id="cloud_courses",
|
||||
menu_options=["cloud_guided", "cloud_manual"],
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_remove(self) -> None:
|
||||
"""Stop probing when the flow goes away.
|
||||
|
||||
Closing the dialog is the documented way to leave guided setup, so it
|
||||
has to actually stop: an abandoned round would otherwise go on
|
||||
live-reading /course/vs/0 every few seconds until its timeout, taking
|
||||
the session lock each time, for a user who has walked away.
|
||||
"""
|
||||
if self._cloud_task is not None and not self._cloud_task.done():
|
||||
self._cloud_task.cancel()
|
||||
self._cloud_task = None
|
||||
|
||||
async def async_step_cloud_guided(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Start (or restart) a guided discovery round."""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
self._cloud_task = None
|
||||
self._cloud_slot = None
|
||||
# Baseline: whatever is loaded right now. The round completes when
|
||||
# the appliance moves off it, so the program the user has *already*
|
||||
# selected can't immediately re-trigger and loop the flow.
|
||||
self._cloud_baseline = cloudcourse.loaded_slot(coord.cloud_course_rep())
|
||||
return await self.async_step_cloud_wait()
|
||||
|
||||
async def async_step_cloud_wait(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Wait for the user to select a different downloaded program.
|
||||
|
||||
The task is created once and reused across re-entries -- Home
|
||||
Assistant polls this step while the spinner is up, and building a
|
||||
fresh task each time would restart the wait forever.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
if self._cloud_task is None:
|
||||
self._cloud_task = self.hass.async_create_task(
|
||||
self._await_cloud_selection(coord), eager_start=False
|
||||
)
|
||||
if not self._cloud_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="cloud_wait",
|
||||
progress_action="cloud_wait",
|
||||
progress_task=self._cloud_task,
|
||||
description_placeholders=self._cloud_progress_placeholders(coord),
|
||||
)
|
||||
|
||||
self._cloud_slot = self._cloud_task.result()
|
||||
self._cloud_task = None
|
||||
if self._cloud_slot is None:
|
||||
return self.async_show_progress_done(next_step_id="cloud_timeout")
|
||||
return self.async_show_progress_done(next_step_id="cloud_name")
|
||||
|
||||
async def _await_cloud_selection(self, coord) -> str | None:
|
||||
"""Poll until the loaded program changes; None on timeout."""
|
||||
deadline = time.monotonic() + _CLOUD_WAIT_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
slot = await coord.async_probe_cloud_courses()
|
||||
# Only a slot the store actually recorded. The probe reports
|
||||
# whatever payload is loaded, while observe() declines one whose
|
||||
# slot the device doesn't advertise -- offering to name that would
|
||||
# take a name and silently discard it, since there is no record to
|
||||
# hang it on and no payload to replay.
|
||||
if (
|
||||
slot is not None
|
||||
and slot != self._cloud_baseline
|
||||
and coord.cloud_courses.snapshot()["slots"].get(slot)
|
||||
):
|
||||
return slot
|
||||
await asyncio.sleep(_CLOUD_PROBE_INTERVAL_S)
|
||||
return None
|
||||
|
||||
def _cloud_progress_placeholders(self, coord) -> dict[str, str]:
|
||||
"""Counts plus the names assigned so far.
|
||||
|
||||
Listing them is what makes a nine-program walk followable -- it is
|
||||
the only orientation available, since the programs still to do are
|
||||
unnamed by definition. Shown while naming too, where it doubles as
|
||||
duplicate avoidance: the form rejects a repeated name, so seeing the
|
||||
others first beats being bounced.
|
||||
|
||||
In the appliance's own advertised order, which is at least a stable
|
||||
order, without numbering them -- whether that order matches the dial
|
||||
is plausible but unverified, and implying it would be worse than
|
||||
saying nothing.
|
||||
"""
|
||||
rep = coord.cloud_course_rep()
|
||||
courses = cycle_options(coord.canonical_resources(MAIN))
|
||||
record = coord.cloud_courses.snapshot()
|
||||
slots = cloudcourse.cloud_slots(rep, courses)
|
||||
names = [n for s in slots if (n := (record["slots"].get(s) or {}).get("name"))]
|
||||
return {
|
||||
"named": str(len(names)),
|
||||
"total": str(len(slots)),
|
||||
"named_list": ", ".join(names) if names else "none yet",
|
||||
}
|
||||
|
||||
async def async_step_cloud_name(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Name the program the user just selected.
|
||||
|
||||
Persists immediately rather than batching to the end of the flow, so
|
||||
closing the dialog at any point is a clean "save and exit" -- there is
|
||||
no pending work to lose, and reopening resumes from the store.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None or self._cloud_slot is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
slot = self._cloud_slot
|
||||
existing = (coord.cloud_courses.snapshot()["slots"].get(slot) or {}).get("name", "")
|
||||
|
||||
if user_input is not None:
|
||||
# Rebuilt into the shared validator's shape. `download_course`
|
||||
# is forwarded only when this form actually carried it, so its
|
||||
# absence still means "not asked about" rather than "clear it".
|
||||
payload: dict[str, Any] = {f"name_{slot}": str(user_input.get("name", "")).strip()}
|
||||
if "download_course" in user_input:
|
||||
payload["download_course"] = user_input["download_course"]
|
||||
errors = self._apply_cloud_course_names(coord, [slot], payload)
|
||||
if errors:
|
||||
return self._cloud_name_form(coord, slot, existing, errors=errors)
|
||||
# Straight back to waiting: the appliance is still sitting on this
|
||||
# program, and the next round baselines on it, so there is nothing
|
||||
# to click through.
|
||||
self._cloud_baseline = slot
|
||||
return await self.async_step_cloud_wait()
|
||||
|
||||
return self._cloud_name_form(coord, slot, existing)
|
||||
|
||||
def _cloud_name_form(
|
||||
self, coord, slot: str, existing: str, errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""One text field, with the copy switched on whether this program is
|
||||
already set up. Re-selecting one is not an error -- it is how someone
|
||||
checks their work -- so it gets an edit form rather than a rejection,
|
||||
and the counter deliberately does not move.
|
||||
|
||||
The Download course joins the form the first time round, and only
|
||||
until it is confirmed. Guided setup would otherwise finish having
|
||||
collected names but no course, and a program needs both before it can
|
||||
be offered -- so the whole walk would produce nothing selectable. It
|
||||
is asked here rather than up front because this is the first moment
|
||||
there is evidence to prefill: the user has just loaded a program, so
|
||||
the course showing alongside it is the Download one.
|
||||
"""
|
||||
placeholders = self._cloud_progress_placeholders(coord)
|
||||
placeholders["slot"] = slot
|
||||
placeholders["remaining"] = (
|
||||
coord.resource("/operational/state/vs/0").get("x.com.samsung.da.remainingTime") or "--"
|
||||
)
|
||||
fields: dict[Any, Any] = {vol.Optional("name", default=existing): _TEXT}
|
||||
if not coord.cloud_courses.snapshot()["download_course"]:
|
||||
fields[
|
||||
vol.Optional("download_course", description={"suggested_value": self._cloud_course})
|
||||
] = self._cloud_course_selector(coord)
|
||||
return self.async_show_form(
|
||||
step_id="cloud_name",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors or {},
|
||||
description_placeholders=placeholders,
|
||||
last_step=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def _cloud_course(self) -> str | None:
|
||||
"""The best observed candidate, narrowed to what the selector offers.
|
||||
|
||||
Unfiltered, a candidate the appliance's own course list no longer
|
||||
contains would prefill a dropdown that rejects it, and the form would
|
||||
fail validation on a value the user never chose.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return None
|
||||
available = cycle_options(coord.canonical_resources(MAIN))
|
||||
return next((c for c in coord.cloud_courses.download_candidates() if c in available), None)
|
||||
|
||||
def _cloud_course_selector(self, coord):
|
||||
"""The appliance's own course codes, observed candidates first.
|
||||
|
||||
custom_value stays off deliberately: whatever lands here becomes the
|
||||
Course_ token of a real write, and a typed-in code the appliance
|
||||
doesn't offer would start something nobody chose.
|
||||
"""
|
||||
available = cycle_options(coord.canonical_resources(MAIN))
|
||||
candidates = [c for c in coord.cloud_courses.download_candidates() if c in available]
|
||||
ordered = candidates + [c for c in available if c not in candidates]
|
||||
return SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=ordered,
|
||||
custom_value=False,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
|
||||
async def async_step_cloud_timeout(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Nothing was selected in time. Offer another round rather than
|
||||
dropping the user out of the flow -- and an explicit finish, for
|
||||
anyone who doesn't think to close the dialog."""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
return self.async_show_menu(
|
||||
step_id="cloud_timeout",
|
||||
menu_options=["cloud_guided", "cloud_finish"],
|
||||
description_placeholders=self._cloud_progress_placeholders(coord),
|
||||
)
|
||||
|
||||
async def async_step_cloud_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
async def async_step_cloud_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Name the cloud "Download" programs this appliance has (issue #342).
|
||||
|
||||
The device advertises how many downloaded programs it holds but never
|
||||
what any of them is called, and only ever exposes the replay payload
|
||||
for the one currently loaded. So this screen can only offer the ones
|
||||
already seen loaded, and asks the user for the names -- the appliance
|
||||
has no name to give and inventing one is not an option (the same rule
|
||||
that governs unrecognized local course codes).
|
||||
|
||||
Also confirms which course code means Download. It is auto-detected
|
||||
by observation, but never used for a write until confirmed here: the
|
||||
options array replaces tokens by prefix and never evicts them, so a
|
||||
stale program token can be reported alongside an unrelated course and
|
||||
make an ordinary wash cycle look like the Download one. Writing the
|
||||
wrong code would start the wrong cycle.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
store = coord.cloud_courses
|
||||
rep = coord.cloud_course_rep()
|
||||
record = store.snapshot()
|
||||
slots = record["slots"]
|
||||
advertised = cloudcourse.cloud_slots(rep, cycle_options(coord.canonical_resources(MAIN)))
|
||||
# Learned slots keep the appliance's own ordering; anything learned
|
||||
# but no longer advertised still gets a row so a name isn't stranded.
|
||||
known = [s for s in advertised if s in slots] + [s for s in slots if s not in advertised]
|
||||
|
||||
if user_input is not None:
|
||||
errors = self._apply_cloud_course_names(coord, known, user_input)
|
||||
if not errors:
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
return self._cloud_courses_form(coord, known, advertised, errors=errors)
|
||||
|
||||
return self._cloud_courses_form(coord, known, advertised)
|
||||
|
||||
def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]:
|
||||
"""Validate and store the submitted names + Download course code.
|
||||
|
||||
The select maps a chosen label back to a raw value by matching display
|
||||
text, so two options sharing a label resolve to whichever comes first.
|
||||
Two sources of collision are checkable here and both are rejected:
|
||||
the user's own names against each other, and against the appliance's
|
||||
personal-course labels, which the device reports verbatim and the
|
||||
select renders as-is.
|
||||
|
||||
A collision with a *translated* local course name is deliberately not
|
||||
checked. The catalog this process can read is English (catalog.py),
|
||||
while what the user actually sees is localized in the frontend -- so
|
||||
checking it would reject "Cotton" for a German user whose dropdown
|
||||
says "Baumwolle", and still miss the real collision when they type
|
||||
"Baumwolle". Wrong in both directions outside one locale, against an
|
||||
outcome the option ordering already makes deterministic (local
|
||||
courses come first, so a shared label resolves to the real cycle).
|
||||
"""
|
||||
names = {slot: str(user_input.get(f"name_{slot}", "")).strip() for slot in known}
|
||||
stored_slots = coord.cloud_courses.snapshot()["slots"]
|
||||
taken = {name.casefold() for name in self._device_course_names(coord)}
|
||||
# Programs this form isn't editing. The bulk form edits every slot at
|
||||
# once so this adds nothing there, but guided setup submits one at a
|
||||
# time -- without it, naming two programs the same was accepted, and
|
||||
# the select resolves a shared label to whichever option comes first,
|
||||
# so picking the second would run the first one's payload.
|
||||
taken |= {
|
||||
record["name"].casefold()
|
||||
for slot, record in stored_slots.items()
|
||||
if record["name"] and slot not in known
|
||||
}
|
||||
for name in names.values():
|
||||
if not name:
|
||||
continue
|
||||
if name.casefold() in taken:
|
||||
return {"base": "cloud_course_name_duplicate"}
|
||||
taken.add(name.casefold())
|
||||
|
||||
# Absent means "this form didn't ask" -- the guided name form drops
|
||||
# the field once the course is confirmed -- which must leave the
|
||||
# stored value alone rather than clearing it. A program is only
|
||||
# offerable when both a name and the course are set, so clearing it
|
||||
# here would make naming things remove them from the cycle list.
|
||||
if "download_course" not in user_input:
|
||||
coord.apply_cloud_courses(names)
|
||||
return {}
|
||||
|
||||
# Belt and braces over the selector's own custom_value=False: this
|
||||
# value becomes the Course_ token of a real write, so it is checked
|
||||
# against the appliance's own course list here too, where the store
|
||||
# is actually updated.
|
||||
course = user_input.get("download_course") or None
|
||||
if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)):
|
||||
return {"base": "cloud_course_unknown_course"}
|
||||
|
||||
coord.apply_cloud_courses(names, course)
|
||||
return {}
|
||||
|
||||
def _device_course_names(self, coord) -> set[str]:
|
||||
"""Course names this appliance reports itself.
|
||||
|
||||
Only the personal-course labels: the device sends these as text and
|
||||
the select renders them unchanged, so they are the same string in
|
||||
every locale and can be compared against safely. See the caller for
|
||||
why translated course names are not included.
|
||||
"""
|
||||
resources = coord.canonical_resources(MAIN)
|
||||
personal = personal_course_labels(resources)
|
||||
return {name for code in cycle_options(resources) if (name := personal.get(code.upper()))}
|
||||
|
||||
def _cloud_courses_form(
|
||||
self, coord, known, advertised, errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
store = coord.cloud_courses
|
||||
record = store.snapshot()
|
||||
slots = record["slots"]
|
||||
|
||||
fields: dict[Any, Any] = {}
|
||||
for slot in known:
|
||||
fields[vol.Optional(f"name_{slot}", default=slots.get(slot, {}).get("name", ""))] = (
|
||||
_TEXT
|
||||
)
|
||||
|
||||
suggested = record["download_course"] or self._cloud_course
|
||||
fields[vol.Optional("download_course", description={"suggested_value": suggested})] = (
|
||||
self._cloud_course_selector(coord)
|
||||
)
|
||||
|
||||
pending = [s for s in advertised if s not in slots]
|
||||
return self.async_show_form(
|
||||
step_id="cloud_manual",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors or {},
|
||||
description_placeholders={
|
||||
"found": str(len(known)),
|
||||
"total": str(len(advertised)) if advertised else str(len(known)),
|
||||
"pending": ", ".join(pending) if pending else "(none)",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_debug_write(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -953,8 +1373,33 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"payload": "empty_payload"}, payload
|
||||
)
|
||||
# Goes through the write_resource service (issue #300), not
|
||||
# coord.async_raw_write directly, so there is exactly one code
|
||||
# path that performs a raw write. MAIN's own device -- the
|
||||
# panel's href dropdown already lists actual hrefs off
|
||||
# coord.last_resources, and MAIN.to_actual is identity, so
|
||||
# this preserves the panel's existing behavior byte for byte.
|
||||
dev = dr.async_get(self.hass).async_get_device(
|
||||
identifiers=coord.device_info["identifiers"]
|
||||
)
|
||||
if dev is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
try:
|
||||
code, new_rep = await coord.async_raw_write(href, payload)
|
||||
response = await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": href, "payload": payload}]},
|
||||
target={"device_id": dev.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
results = (response or {}).get("results")
|
||||
first = results[0] if isinstance(results, list) and results else None
|
||||
raw_code = first.get("raw_code") if isinstance(first, dict) else None
|
||||
after = first.get("after") if isinstance(first, dict) else None
|
||||
if not isinstance(raw_code, int) or not isinstance(after, dict):
|
||||
raise RuntimeError("write_resource service returned an unexpected shape")
|
||||
code, new_rep = raw_code, after
|
||||
except Exception:
|
||||
_LOGGER.exception("debug raw write failed for %s", href)
|
||||
return self._show_debug_edit_form(href, current, {"base": "write_failed"}, payload)
|
||||
|
||||
@@ -20,86 +20,98 @@ CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
|
||||
# Device identity, resolved once by the config flow's probe and persisted on
|
||||
# the entry (issue #236). These are what the coordinator mints registry keys
|
||||
# from at __init__ time, before any poll has happened -- see
|
||||
# LocalThingsCoordinator.__init__. Without them the coordinator had to seed
|
||||
# `device_serial` with the host and rebuild its DeviceInfo after the first
|
||||
# successful poll, so anything that registered in between (the connection-mode
|
||||
# sensor, which is added unconditionally rather than from `bound`) was written
|
||||
# into the entity/device registry keyed on the IP address permanently.
|
||||
# Device identity, resolved once by the config flow's probe and persisted
|
||||
# on the entry (issue #236) -- what the coordinator mints registry keys
|
||||
# from at __init__ time, before any poll has happened. Without them,
|
||||
# anything registering before the first poll (e.g. the connection-mode
|
||||
# sensor) got keyed on the IP address permanently.
|
||||
#
|
||||
# CONF_SERIAL is the *resolved* serial -- registry.identity.resolve_serial's
|
||||
# output, i.e. the host itself for a board that reports a placeholder serial
|
||||
# (issues #83/#189) -- so it matches what _run_discovery computes on the first
|
||||
# poll exactly, and the device identity never changes underneath the registry.
|
||||
# CONF_SERIAL is the resolved serial (registry.identity.resolve_serial's
|
||||
# output, the host itself for a placeholder-serial board -- issues
|
||||
# #83/#189), so it matches what _run_discovery computes on the first poll.
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_MODEL = "model"
|
||||
CONF_MANUFACTURER = "manufacturer"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
|
||||
# Options-flow key (entry.options, not entry.data): lets a user override the
|
||||
# device-wide remote-control-off write block for a specific device (issue
|
||||
# #54). Some devices report remote control off yet still accept certain
|
||||
# writes (e.g. default detergent/softener dosing on a washer, applied even
|
||||
# to the built-in programs) -- the block exists to give a clear error
|
||||
# instead of a silent device-side rejection, but that assumption doesn't
|
||||
# hold for every model. Defaults to False (block stays on) everywhere it's
|
||||
# read, so devices this doesn't apply to see no behavior change.
|
||||
# entry.data key: modes this device reported itself in but never advertised
|
||||
# in the same resource's supportedModes (issue #327). Stored on the entry
|
||||
# rather than kept in memory so a mode the device only names while it is
|
||||
# active survives a restart -- see learned.py. Shape:
|
||||
# {actual_href: [code, ...]}.
|
||||
CONF_LEARNED_MODES = "learned_modes"
|
||||
|
||||
# Options-flow key: whether learned modes are remembered and offered.
|
||||
# Defaults to on; turning it off stops both halves at once (nothing new is
|
||||
# learned, nothing already learned is offered) without discarding what was
|
||||
# already remembered -- the options flow's reset step does that.
|
||||
CONF_LEARN_MODES = "learn_device_modes"
|
||||
DEFAULT_LEARN_MODES = True
|
||||
|
||||
# entry.data key: cloud "Download" programs discovered on a laundry device
|
||||
# (issue #342). Same rationale as CONF_LEARNED_MODES -- a program's full
|
||||
# replay payload is only ever visible while the device happens to be sitting
|
||||
# on it, so it has to survive a restart -- but a richer shape, because a
|
||||
# cloud program also needs a user-supplied name and the device's own
|
||||
# Download course code. See cloudcourse.py, which owns the shape. Shape:
|
||||
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
|
||||
CONF_CLOUD_COURSES = "cloud_courses"
|
||||
|
||||
# Options-flow key (entry.options, not entry.data): lets a user override
|
||||
# the device-wide remote-control-off write block for a specific device
|
||||
# (issue #54). Some devices accept certain writes even while reporting
|
||||
# remote control off (e.g. a washer's default detergent dosing), so the
|
||||
# blanket-block assumption doesn't hold everywhere. Defaults to False
|
||||
# (block stays on).
|
||||
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).
|
||||
# hysteresis-gated timestamp sensor (currently just finish_time) reports a
|
||||
# new value. Devices commonly revise their remaining-time estimate by a
|
||||
# minute or two throughout a cycle, and finish_time = now() + remaining
|
||||
# drifts with the poll interval between revisions -- both push a fresh
|
||||
# state far more often than the estimate is meaningfully different. 0
|
||||
# disables the gate.
|
||||
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
|
||||
# before attempting the (expensive) DTLS handshake.
|
||||
# The DTLS/CoAP local API binds somewhere in this ephemeral range,
|
||||
# depending on firmware (newer builds answer on 49154/49155, older ones as
|
||||
# low as 49153) -- swept for a live UDP port before the expensive DTLS
|
||||
# handshake.
|
||||
PROBE_PORT_RANGE = list(range(49152, 49161))
|
||||
|
||||
# Ports we've historically seen complete a DTLS handshake. When more than one
|
||||
# port in the range looks live, these are tried first.
|
||||
# Ports we've historically seen complete a DTLS handshake; tried first when
|
||||
# more than one port in the range looks live.
|
||||
PREFERRED_PROBE_PORTS = [49154, 49155]
|
||||
|
||||
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
|
||||
# ICMP port-unreachable almost immediately; a live-but-silent port is only
|
||||
# detected by this timeout elapsing, so keep it short. Only reached now as the
|
||||
# detected by this timeout elapsing, so keep it short. Only reached as the
|
||||
# fallback for when the ClientHello probe below confirms nothing.
|
||||
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
||||
|
||||
# Per-port budget for the DTLS ClientHello probe (smartthings-local >= 0.1.2),
|
||||
# the primary port-detection gate. A real DTLS server answers with a
|
||||
# HelloVerifyRequest in ~1 RTT, so a live port resolves well inside this; the
|
||||
# budget only bounds how long a *silent* port takes to give up, since the
|
||||
# probe services OpenSSL's retransmit timer rather than reading one dropped
|
||||
# ClientHello as dead. 3s covers two retransmits on a slow LAN.
|
||||
# Per-port budget for the DTLS ClientHello probe (smartthings-local >=
|
||||
# 0.1.2), the primary port-detection gate. A real server answers with a
|
||||
# HelloVerifyRequest in ~1 RTT; the budget only bounds how long a silent
|
||||
# port takes to give up. 3s covers two retransmits on a slow LAN.
|
||||
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
|
||||
CLIENTHELLO_PROBE_RETRIES = 2
|
||||
|
||||
# The whole port range is probed at once: each stateless probe is bounded by
|
||||
# CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the sweep
|
||||
# costs one probe's wall clock rather than the sum of the range. Capped so a
|
||||
# The whole port range is probed at once: each stateless probe is bounded
|
||||
# by CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the
|
||||
# sweep costs one probe's wall clock, not the sum of the range. Capped so a
|
||||
# widened PROBE_PORT_RANGE can't spawn an unbounded thread pool.
|
||||
PROBE_MAX_WORKERS = 12
|
||||
|
||||
# Deadline for the blockwise /device/0 GET during the config-flow probe. The
|
||||
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
|
||||
# without stalling setup; it matches the per-resource read timeout elsewhere.
|
||||
# Deadline for the blockwise /device/0 GET during the config-flow probe.
|
||||
# The slowest device observed returns a full dump in ~8s.
|
||||
PROBE_GET_TIMEOUT_S = 10.0
|
||||
|
||||
# Base for the local (client-side) DTLS source port, distinct from the
|
||||
# destination probe ports above. See coordinator._local_source_port for why a
|
||||
# fixed per-device source port matters and how the per-device offset is
|
||||
# derived. Base mirrors the upstream smartthings-local reference bridge.
|
||||
# Requires smartthings-local >= 0.1.1.
|
||||
# destination probe ports above -- see coordinator._local_source_port for
|
||||
# why a fixed per-device source port matters. Mirrors the upstream
|
||||
# smartthings-local reference bridge. Requires smartthings-local >= 0.1.1.
|
||||
DTLS_LOCAL_PORT_BASE = 49700
|
||||
|
||||
SUMMARY_INTERVAL_S = 30.0
|
||||
@@ -107,3 +119,9 @@ SUMMARY_INTERVAL_S = 30.0
|
||||
DEVICE_SUPPORT_ISSUE_URL = (
|
||||
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
|
||||
)
|
||||
|
||||
# Service names (services.py), shared with config_flow.py so the
|
||||
# options-flow debug panel calls the exact same service a user could call
|
||||
# from an automation (issue #300) -- one code path performs a raw write.
|
||||
SERVICE_WRITE_RESOURCE = "write_resource"
|
||||
SERVICE_READ_RESOURCE = "read_resource"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from . import cloudcourse
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.capabilities.laundry import cycle_options
|
||||
from .registry.redact import redact_resources
|
||||
from .registry.subdevices import MAIN
|
||||
|
||||
@@ -32,36 +34,30 @@ async def async_get_config_entry_diagnostics(
|
||||
# disk (listdir + open + read_text), which trips HA's event-loop blocking
|
||||
# detector when called inline here. Offload it to the executor.
|
||||
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
|
||||
cloud_courses = coordinator.cloud_courses.snapshot()
|
||||
|
||||
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
|
||||
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
|
||||
# is OCF's standard device-type declaration; /oic/res is OCF's
|
||||
# discovery endpoint, listing every href/Collection the connection
|
||||
# hosts -- relevant to the "Composite Device" model (issue #177) where
|
||||
# a single physical device exposes more than one logical subdevice. See
|
||||
# is OCF's device-type declaration; /oic/res is OCF's discovery
|
||||
# endpoint, relevant to the "Composite Device" model (issue #177). See
|
||||
# registry/identity.py.
|
||||
identity = coordinator._identity
|
||||
|
||||
def _seed_diag(su) -> dict:
|
||||
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
|
||||
# Collection, so its state comes from individually-polled hrefs
|
||||
# instead) has no meaningful seed_path; report the flat_hrefs list
|
||||
# in its place rather than the misleading bare "/" a joined empty
|
||||
# tuple would otherwise produce.
|
||||
# A flat-mode subdevice (issue #205: no working /<uuid>/device/0
|
||||
# Collection, state comes from individually-polled hrefs instead)
|
||||
# has no meaningful seed_path; report flat_hrefs in its place.
|
||||
return {
|
||||
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
|
||||
"flat_hrefs": list(su.flat_hrefs),
|
||||
}
|
||||
|
||||
def _subdevice_diag(su) -> dict:
|
||||
# One pass over coordinator.bound for both fields below (count and
|
||||
# the distinct hrefs), and one redaction of this subdevice's canonical
|
||||
# view -- `model` reads modelNum off the already-redacted `resources`
|
||||
# rather than redacting /information/vs/0 a second time. modelNum
|
||||
# itself never matches redact.py's substring rules, so which side of
|
||||
# redact_resources it's read from doesn't change the value.
|
||||
# `model` reads modelNum off the already-redacted `resources` rather
|
||||
# than redacting /information/vs/0 again -- modelNum never matches
|
||||
# redact.py's substring rules, so the value is the same either way.
|
||||
matching = [b for b in coordinator.bound if b.subdevice == su]
|
||||
res = redact_resources(coordinator.canonical_resources(su))
|
||||
res = redact_resources(coordinator.device_resources(su))
|
||||
return {
|
||||
"kind": su.kind,
|
||||
"key": su.key,
|
||||
@@ -69,13 +65,10 @@ async def async_get_config_entry_diagnostics(
|
||||
"bound_entity_count": len(matching),
|
||||
"hrefs": sorted({b.href for b in matching}),
|
||||
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
|
||||
# Keyed by this subdevice's *canonical* hrefs, not the real ones
|
||||
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
|
||||
# '/<uuid>/mode/vs/0'. That's the form the registry and every
|
||||
# capability are written against, so a sibling's block can be
|
||||
# read (or pasted into the skill's standalone-discovery
|
||||
# recipe) exactly like the master's `resources` above,
|
||||
# instead of having to be de-indexed by hand first.
|
||||
# Keyed by this subdevice's canonical hrefs ('/mode/vs/0'), not
|
||||
# the real ones it answers on ('/mode/vs/1', '/<uuid>/mode/vs/0')
|
||||
# -- the form the registry is written against, so a sibling's
|
||||
# block reads exactly like the master's `resources` below.
|
||||
"resources": res,
|
||||
}
|
||||
|
||||
@@ -91,49 +84,34 @@ async def async_get_config_entry_diagnostics(
|
||||
if identity is not None
|
||||
else None,
|
||||
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
||||
# This subdevice's own resources, and only this subdevice's -- what
|
||||
# the module docstring and the adding-device-support skill have
|
||||
# always described it as ("the parsed /device/0 snapshot"). On a
|
||||
# composite device (issue #177) `last_resources` is the union across
|
||||
# every live subdevice keyed by real hrefs, so reporting it raw here
|
||||
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
|
||||
# under no attribution at all. Each sibling reports its own
|
||||
# resources in its own `subdevices` entry below instead. For a
|
||||
# device with no subdevices -- almost every device -- this is
|
||||
# byte-identical to `last_resources`.
|
||||
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
|
||||
# This subdevice's own resources, and only this subdevice's. On a
|
||||
# composite device (issue #177) `last_resources` is the union
|
||||
# across every live subdevice keyed by real hrefs, so reporting it
|
||||
# raw here would mix a sibling's /mode/vs/1 with the master's
|
||||
# /mode/vs/0 under no attribution. Each sibling reports its own
|
||||
# resources in `subdevices` below instead. For a device with no
|
||||
# subdevices, this is byte-identical to `last_resources`.
|
||||
"resources": redact_resources(coordinator.device_resources(MAIN)),
|
||||
# Sibling indoor subdevices discovered on this connection (issue
|
||||
# #177) -- per-subdevice kind/key/seed path plus what actually bound
|
||||
# to it, so a report shows whether a composite device's subdevice
|
||||
# was found at all and what it resolved to. subdeviceIdList (the
|
||||
# UUID a prefixed subdevice's key comes from) is deliberately NOT
|
||||
# redacted here even
|
||||
# though the field matches redact.py's 'deviceid' substring rule
|
||||
# elsewhere in `resources` above -- it's an appliance-internal
|
||||
# pairing id, not account data, and reporting the key is what makes
|
||||
# this block actionable.
|
||||
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
|
||||
# from) is deliberately NOT redacted here, unlike elsewhere in
|
||||
# `resources` -- it's an appliance-internal pairing id, not account
|
||||
# data, and reporting it is what makes this block actionable.
|
||||
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
|
||||
# Candidates that answered their seed but that discover_partitioned's
|
||||
# entity-level liveness gate rejected -- an unused SmartThings slot
|
||||
# (the issue #177 reporter's /device/2) that still answers a
|
||||
# same-shaped batch, not a real second subdevice. Reported alongside
|
||||
# subdevices above so a report shows what was found *and* why it
|
||||
# didn't become an entity, not just silence where a third climate
|
||||
# card might otherwise be expected.
|
||||
# liveness gate rejected -- an unused SmartThings slot, not a real
|
||||
# second subdevice. Reported alongside subdevices above so a report
|
||||
# shows what was found and why it didn't become an entity.
|
||||
"subdevices_skipped": [
|
||||
{
|
||||
"kind": skip.subdevice.kind,
|
||||
"key": skip.subdevice.key,
|
||||
**_seed_diag(skip.subdevice),
|
||||
"hrefs": list(skip.hrefs),
|
||||
# The reps the liveness gate actually judged, canonicalized
|
||||
# like the materialized subdevices above. These are the one
|
||||
# thing a reader needs to second-guess a skip ("is my second
|
||||
# subdevice really absent, or did the gate get it wrong?"),
|
||||
# and they exist nowhere else in this dump: a rejected
|
||||
# candidate is never polled again and never enters the state
|
||||
# cache, so `resources` above cannot contain them by
|
||||
# construction.
|
||||
# The reps the liveness gate actually judged -- the one
|
||||
# thing a reader needs to second-guess a skip, and they
|
||||
# exist nowhere else in this dump: a rejected candidate is
|
||||
# never polled again or entered into the state cache.
|
||||
"resources": redact_resources(
|
||||
{
|
||||
canon: rep
|
||||
@@ -146,18 +124,41 @@ async def async_get_config_entry_diagnostics(
|
||||
],
|
||||
# What each enumeration probe returned ({} vs a batch), keyed by the
|
||||
# seed href attempted -- lets a report distinguish "checked, nothing
|
||||
# there" from "never checked", the same posture the speculative
|
||||
# /device/1 //device/2 probe this replaced used to document directly
|
||||
# in identity.py before it moved to registry/subdevices.py.
|
||||
# there" from "never checked".
|
||||
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
|
||||
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
|
||||
# Reported on its own rather than inside `resources` because it is
|
||||
# metadata about the connection rather than state of any one
|
||||
# subdevice -- and because nothing polls it after discovery, so it
|
||||
# would go stale in there. Its numofsubdevice count is what
|
||||
# independently corroborates the subdevices/subdevices_skipped split
|
||||
# above.
|
||||
# Reported on its own, not inside `resources`, since it's metadata
|
||||
# about the connection rather than one subdevice's state, and
|
||||
# nothing polls it after discovery so it would go stale in there.
|
||||
"multidevice": redact_resources(coordinator._multidevice),
|
||||
# Modes this unit reported itself in but never advertised (issue
|
||||
# #327). Reported separately from `resources` on purpose: the dump
|
||||
# above stays exactly what the device said, so a triager can still
|
||||
# see the gap these codes were inferred from. Keyed by actual href,
|
||||
# like the store itself.
|
||||
"learned_modes": {
|
||||
"enabled": coordinator.learning_enabled,
|
||||
"codes": coordinator.learned_snapshot(),
|
||||
},
|
||||
# Cloud "Download" programs discovered on this device (issue #342),
|
||||
# reported separately from `resources` for the same reason as
|
||||
# learned_modes above -- the dump there stays exactly what the device
|
||||
# said, and this is what the integration made of it.
|
||||
#
|
||||
# Reported in full, names included. Half of what can go wrong with
|
||||
# this feature is a configuration question -- which programs got
|
||||
# named, which Download course was confirmed, whether a payload was
|
||||
# ever captured for a slot the device advertises -- and none of that
|
||||
# is answerable from the payloads alone. The names are the user's own
|
||||
# words, so this is the one place they appear; they reach a dump only
|
||||
# because its owner chose to download and share it.
|
||||
"cloud_courses": {
|
||||
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()),
|
||||
"cloud_slots": cloudcourse.cloud_slots(
|
||||
coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN))
|
||||
),
|
||||
**cloud_courses,
|
||||
},
|
||||
"integration_version": integration.version,
|
||||
"smartthings_local_version": stl_version,
|
||||
"observe_mode": coordinator.observe_mode,
|
||||
|
||||
@@ -18,28 +18,23 @@ from .registry.discovery import BoundEntity, _snake_to_title
|
||||
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
|
||||
"""Return False if the entity should not be registered for this device.
|
||||
|
||||
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
|
||||
require that field to be present in the resource rep so that optional
|
||||
fields on shared resources don't create phantom entities.
|
||||
Explicit exists_fn takes priority. Otherwise, if the entity has a
|
||||
field, require that field to be present in the resource rep so that
|
||||
optional fields on shared resources don't create phantom entities.
|
||||
|
||||
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
|
||||
yet" marker) is included anyway so it can be populated by sub-polls. A
|
||||
genuinely empty {} rep is included too by this default gate -- whether
|
||||
empty means "not populated yet" or "permanently unsupported" needs
|
||||
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
|
||||
{} is fridge.py's documented *normal* no-alarm state (see
|
||||
_active_alarm_codes), not an absence signal, and it's far from the only
|
||||
resource like that. Only a capability whose author has actually verified
|
||||
a field is genuinely never populated on unsupported hardware opts into
|
||||
stricter gating with its own is_stub_rep-based exists_fn (see
|
||||
common.ENERGY_METER, issue #127) -- this default stays permissive.
|
||||
A stub rep (is_stub_rep) is included anyway so it can be populated by
|
||||
sub-polls. A genuinely empty {} rep is included too by this default
|
||||
gate: whether empty means "not populated yet" or "permanently
|
||||
unsupported" needs per-field domain knowledge this generic gate
|
||||
doesn't have (e.g. /alarms/vs/0's {} is fridge.py's documented normal
|
||||
no-alarm state, not an absence signal). Only a capability whose author
|
||||
has verified a field is genuinely never populated opts into stricter
|
||||
gating with its own exists_fn (see common.ENERGY_METER, issue #127).
|
||||
|
||||
`bound.href` is already the *actual* href (issue #177 -- see
|
||||
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
|
||||
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
|
||||
raw snapshot, same rule as everywhere else a whole-resources-dict scan
|
||||
happens (coordinator.canonical_resources) -- this is a free function, not
|
||||
an LocalThingsEntity method, so it can't use self._resources.
|
||||
`bound.href` is already the actual href (issue #177); `exists_fn` gets
|
||||
`bound`'s own subdevice's canonical view instead of the raw snapshot,
|
||||
same rule as everywhere else a whole-resources-dict scan happens --
|
||||
this is a free function, so it can't use self._resources.
|
||||
"""
|
||||
rep = coordinator.last_resources.get(bound.href)
|
||||
if rep is None:
|
||||
@@ -97,12 +92,10 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
"instance_name": _instance_display_name(bound, self._state_key)
|
||||
}
|
||||
|
||||
# _attr_name is deliberately left unset: Home Assistant gives an
|
||||
# explicitly-set name precedence over the translation catalog, so
|
||||
# setting it here would make every entity untranslatable. Every
|
||||
# descriptor resolves to a catalog entry (see translation_key below);
|
||||
# a platform that wants the bare device name instead sets
|
||||
# _attr_name = None itself, as fan.py does for the hood's main entity.
|
||||
# _attr_name is deliberately left unset: HA gives an explicitly-set
|
||||
# name precedence over the translation catalog, so setting it here
|
||||
# would make every entity untranslatable. A platform that wants the
|
||||
# bare device name sets _attr_name = None itself (see fan.py).
|
||||
self._attr_icon = bound.desc.icon
|
||||
raw_cat = bound.desc.entity_category
|
||||
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
||||
@@ -112,18 +105,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
def translation_key(self) -> str | None:
|
||||
"""The descriptor's catalog key, defaulting to its own `key`.
|
||||
|
||||
Overrides Entity.translation_key (a property upstream, not a plain
|
||||
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
|
||||
table-id-gated resolver -- is re-evaluated against live coordinator
|
||||
data on every access, not resolved once at construction time.
|
||||
|
||||
Discovery runs on the first /device/0 poll, which the entity
|
||||
registry already documents can hand a sibling resource an empty
|
||||
stub rep before it's actually been fetched (see _is_included's
|
||||
docstring) -- a static one-time resolution here would risk baking
|
||||
in a permanent None (no translation) for the entity's whole
|
||||
lifetime if that stub hadn't populated yet, even once the real
|
||||
value arrives on a later poll.
|
||||
Overrides Entity.translation_key so a callable descriptor (e.g.
|
||||
laundry.cycle_select's table-id-gated resolver) is re-evaluated
|
||||
against live coordinator data on every access, not resolved once
|
||||
at construction time -- a static resolution would risk baking in
|
||||
a permanent None if the first poll handed a sibling an empty stub
|
||||
rep (see _is_included's docstring) before it populated.
|
||||
"""
|
||||
tk = self._bound.desc.translation_key
|
||||
if callable(tk):
|
||||
@@ -133,12 +120,11 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
@property
|
||||
def _resources(self) -> dict:
|
||||
"""This entity's own subdevice's canonical resources view (issue
|
||||
#177) -- see coordinator.canonical_resources. Every platform
|
||||
property that needs the *whole* resources dict, as opposed to one
|
||||
href via `coordinator.resource(href)`, must read through this
|
||||
instead of `coordinator.last_resources`, or a sibling subdevice's own
|
||||
actual hrefs would leak into (or be missing from) this entity's
|
||||
view. For MAIN (every device with no subdevices) this is exactly
|
||||
#177) -- see coordinator.canonical_resources. Any platform property
|
||||
needing the whole resources dict, not one href via
|
||||
`coordinator.resource(href)`, must read through this instead of
|
||||
`coordinator.last_resources`, or a sibling subdevice's own hrefs
|
||||
could leak into this entity's view. For MAIN this is exactly
|
||||
`coordinator.last_resources`."""
|
||||
return self.coordinator.canonical_resources(self._bound.subdevice)
|
||||
|
||||
|
||||
@@ -4,15 +4,14 @@ Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
|
||||
below since each needs different HA fan semantics: the range hood's fan
|
||||
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
|
||||
Medium/High (issue #56) are both an ordered set of numeric levels
|
||||
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
|
||||
air_purifier.py's module docstring, with no named-mode list to preserve
|
||||
since this board never self-reports one. The TP1X air-purifier family's
|
||||
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
|
||||
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
|
||||
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
|
||||
both hrefs, the only difference being whether the label comes straight
|
||||
from supportedModes or from a parallel modesName array (see
|
||||
_label_for_code)."""
|
||||
(SET_SPEED), confirmed monotonic in capabilities/air_purifier.py's module
|
||||
docstring, with no named-mode list since this board never self-reports one.
|
||||
The TP1X air-purifier family's modes (Smart/Max/Mid/WindFree/Sleep, issue
|
||||
#130) and the A-VTWW-TP2-21 family's /wind/strength/vs/0 modes (issue #151)
|
||||
are both named behaviors with no linear order (PRESET_MODE) --
|
||||
LocalThingsAirPurifierFan handles both hrefs, the only difference being
|
||||
whether the label comes from supportedModes or a parallel modesName array
|
||||
(see _label_for_code)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -76,19 +75,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
Some boards that reuse this capability (built-in microwave vent fans,
|
||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
||||
resource at all -- fan speed 0 is itself the off state there, with no
|
||||
separate power toggle to write. `_speed_zero_is_off` detects that
|
||||
shape from the hood resource's own settableMinFanSpeed/
|
||||
supportedFanSpeed fields and switches every method below to drive
|
||||
off/on purely through the fanSpeed field, including '0' in the
|
||||
ordered speed codes as the off step instead of assuming every
|
||||
advertised code is an active speed.
|
||||
resource at all -- fan speed 0 is itself the off state there.
|
||||
`_speed_zero_is_off` detects that shape and switches every method
|
||||
below to drive off/on purely through the fanSpeed field.
|
||||
|
||||
This is deliberately not the same question as `_has_separate_power`,
|
||||
which only proves *some* power resource exists on the device -- on a
|
||||
combi appliance (e.g. an over-the-range microwave) that resource can
|
||||
belong to the cavity, not the vent fan, and toggling it from here
|
||||
would turn off the whole appliance instead of just the fan.
|
||||
Deliberately not the same question as `_has_separate_power`, which
|
||||
only proves some power resource exists on the device -- on a combi
|
||||
appliance that resource can belong to the cavity, not the vent fan,
|
||||
and toggling it from here would turn off the whole appliance.
|
||||
"""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
@@ -112,10 +106,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
def _speed_zero_is_off(self) -> bool:
|
||||
"""Whether fan speed '0' is itself this hood's off step, with no
|
||||
separate power resource to toggle. The board says so directly:
|
||||
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
|
||||
standalone hood's codes start at 14 and it carries a real /power
|
||||
resource instead, so this is False there."""
|
||||
separate power resource to toggle -- settableMinFanSpeed '0', or
|
||||
'0' inside supportedFanSpeed. False for the standalone hood, whose
|
||||
codes start at 14 and which carries a real /power resource."""
|
||||
rep = self._rep(self._bound.href)
|
||||
return (
|
||||
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
|
||||
@@ -140,12 +133,10 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
codes = self._all_speed_codes()
|
||||
if self._speed_zero_is_off():
|
||||
# No separate power resource: '0' is the off step, not a speed.
|
||||
return [code for code in codes if code != _OFF_SPEED_CODE]
|
||||
# Power is carried by the separate /power resource. fanSpeed
|
||||
# retains the selected setting while power is off (as the
|
||||
# lamp's `current` field does), so every advertised code is an
|
||||
# active ordered speed.
|
||||
# Power is carried by the separate /power resource; fanSpeed
|
||||
# retains the selected setting while power is off, so every
|
||||
# advertised code is an active ordered speed.
|
||||
return codes
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
@@ -268,15 +259,12 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
def _label_for_code(self, code) -> str:
|
||||
"""Lowercased HA preset label for a device mode code.
|
||||
|
||||
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
|
||||
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
|
||||
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
|
||||
wind-strength codes ('87'/'89'/...) with a separate modesName array
|
||||
(parallel-indexed with supportedModes) giving the actual names --
|
||||
same shape as climate.py's _wind_strength_label, and coincidentally
|
||||
the same word set (Smart/Max/WindFree/Sleep), so both board
|
||||
generations land on identical HA preset values without needing
|
||||
their own translation catalog entry."""
|
||||
The TP1X_DA-AC-AIR board (issue #130) reports named modes directly
|
||||
as supportedModes, so the code IS the label. The A-VTWW-TP2-21
|
||||
board (issue #151) instead reports numeric wind-strength codes with
|
||||
a separate modesName array giving the real names -- same shape as
|
||||
climate.py's _wind_strength_label, and coincidentally the same word
|
||||
set, so both generations land on identical HA preset values."""
|
||||
rep = self._mode_rep()
|
||||
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
|
||||
names = rep.get(_MODES_NAME_FIELD)
|
||||
@@ -327,11 +315,8 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
_AIRFLOW_SPEED_FIELD = "speed"
|
||||
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
|
||||
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
|
||||
# as plain strings, same as _all_speed_codes above, so
|
||||
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
|
||||
# it exactly like the range hood's numeric levels -- no named-preset table
|
||||
# needed since this board never reports mode names to hang one off of.
|
||||
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Treated
|
||||
# as plain ordered strings, same as the range hood's numeric levels.
|
||||
_AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
|
||||
|
||||
|
||||
@@ -354,14 +339,11 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
||||
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
||||
harmless for the TP1X board because it never reports /power/0 at
|
||||
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
|
||||
(the power_switch entity) is unconditionally bound to /power/0
|
||||
whenever it's present, so writing here to /power/vs/0 first would
|
||||
leave power_switch and this fan reading/writing two different
|
||||
resources -- disagreeing until the next poll refreshes the other
|
||||
one (the same optimistic-apply lag coordinator.py's own comments
|
||||
warn about)."""
|
||||
harmless for the TP1X board because it never reports /power/0.
|
||||
This family's dumps carry both hrefs, and common.POWER_GENERIC is
|
||||
unconditionally bound to /power/0 when present, so writing to
|
||||
/power/vs/0 first would leave power_switch and this fan
|
||||
disagreeing until the next poll."""
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return "power", enabled, target
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Modes a device reports itself in but never advertises as supported.
|
||||
|
||||
Some firmwares report a current mode that is missing from the same
|
||||
resource's own supported list (issue #327: an ARTIK051 air conditioner
|
||||
sitting in 'Quiet' with supportedModes [Off, Sleep, Speed, Nano,
|
||||
NanoSleep]). The mode is real -- the remote and the SmartThings app select
|
||||
it, and the unit accepts it written back -- so once the device has been
|
||||
seen in it, it is remembered and offered alongside the advertised ones.
|
||||
|
||||
Learning is deliberately not global. A current value that isn't a
|
||||
selectable option is common across this corpus -- an oven idling in
|
||||
'NoOperation', a fridge's /mode/vs/0 carrying capability tokens like
|
||||
'WATERFILTER_DISABLE' -- and remembering one of those permanently would
|
||||
put an option in the UI that the device can only reject. LEARNABLE names
|
||||
the canonical hrefs where a reported mode is known to be genuinely
|
||||
selectable, and the coordinator narrows it further to the hrefs this
|
||||
device actually binds a climate entity to (see _refresh_learnable_hrefs):
|
||||
the same href is declared explicitly unmodeled on a dehumidifier and
|
||||
empty on an air purifier, and learning for those would persist a code
|
||||
nothing ever offers.
|
||||
|
||||
This module also owns the entry key the store persists under, so the
|
||||
shape lives in exactly one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from .const import CONF_LEARNED_MODES
|
||||
from .registry.capabilities.airconditioner import HREF_CONVENIENT
|
||||
|
||||
MODES_FIELD = "x.com.samsung.da.modes"
|
||||
SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||
|
||||
# Convenient (preset) mode: firmware omits an active preset (e.g. Quiet)
|
||||
# from its own supportedModes -- a reporting gap, not a capability
|
||||
# difference (issue #327).
|
||||
LEARNABLE: frozenset[str] = frozenset({HREF_CONVENIENT})
|
||||
|
||||
|
||||
def _codes(value) -> list[str]:
|
||||
"""Mode codes from a `modes`-style field, which some firmwares send as
|
||||
a bare string rather than an array."""
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value if isinstance(v, str)]
|
||||
return []
|
||||
|
||||
|
||||
def _coerce(stored) -> dict[str, list[str]]:
|
||||
"""Restore the persisted map, dropping anything that isn't the shape
|
||||
this module writes. It round-trips through the config entry as plain
|
||||
JSON, and a hand-edited .storage file shouldn't be able to crash
|
||||
setup."""
|
||||
if not isinstance(stored, dict):
|
||||
return {}
|
||||
restored = {}
|
||||
for href, codes in stored.items():
|
||||
if isinstance(href, str) and (valid := [c for c in _codes(codes) if c]):
|
||||
restored[href] = valid
|
||||
return restored
|
||||
|
||||
|
||||
def stored(entry) -> dict[str, list[str]]:
|
||||
"""What `entry` has persisted, coerced -- for a reader that can't go
|
||||
through a coordinator (the options flow, on an unloaded entry)."""
|
||||
return _coerce(entry.data.get(CONF_LEARNED_MODES))
|
||||
|
||||
|
||||
def persist(hass, entry, codes: dict[str, list[str]]) -> None:
|
||||
"""Write `codes` onto the entry. Runs on the event loop, which
|
||||
async_update_entry requires."""
|
||||
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_LEARNED_MODES: codes})
|
||||
|
||||
|
||||
class LearnedModes:
|
||||
"""Per-device store of learned codes, keyed by actual (on-the-wire)
|
||||
href so two subdevices of one composite appliance learn separately.
|
||||
|
||||
Mutated from whichever thread applied the update (the DTLS reader for
|
||||
an OBSERVE notify, an executor thread for a poll -- see
|
||||
ObserveManager.apply), so every access takes the lock; persistence is
|
||||
the caller's job, on the event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, stored=None) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._learned = _coerce(stored)
|
||||
|
||||
def observe(self, actual_href: str, rep: dict) -> list[str]:
|
||||
"""Learn from one applied rep; returns the codes newly learned, so
|
||||
an empty list means there is nothing to persist.
|
||||
|
||||
A rep that carries no supported list teaches nothing: "missing from
|
||||
the list" is only meaningful against a list that exists, and
|
||||
inventing one for a device that publishes none would offer options
|
||||
nothing ever said were selectable. `rep` is the merged rep
|
||||
ObserveManager.apply stores, so a partial notify carrying `modes`
|
||||
alone (issue #27) still sees the supported list from the last full
|
||||
poll.
|
||||
"""
|
||||
supported = _codes(rep.get(SUPPORTED_FIELD))
|
||||
if not supported:
|
||||
return []
|
||||
with self._lock:
|
||||
known = self._learned.get(actual_href, [])
|
||||
new = [
|
||||
code
|
||||
for code in _codes(rep.get(MODES_FIELD))
|
||||
if code and code not in supported and code not in known
|
||||
]
|
||||
if new:
|
||||
self._learned[actual_href] = [*known, *new]
|
||||
return new
|
||||
|
||||
def codes(self, actual_href: str) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._learned.get(actual_href, ()))
|
||||
|
||||
def snapshot(self) -> dict[str, list[str]]:
|
||||
with self._lock:
|
||||
return {href: list(codes) for href, codes in self._learned.items()}
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._learned = {}
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.2"
|
||||
],
|
||||
"version": "0.19.0"
|
||||
"version": "0.21.1"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import cbor2
|
||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||
@@ -55,6 +56,26 @@ SUCCESS_FRACTION = 0.8
|
||||
PUSH_HEALTH_WINDOW_S = 60.0
|
||||
|
||||
|
||||
def _is_alarms_href(href: str) -> bool:
|
||||
"""True for /alarms/vs/<index> in any subdevice-translated shape --
|
||||
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
|
||||
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
|
||||
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
|
||||
(registry/subdevices.py) only ever rewrites the trailing index
|
||||
segment or prepends a prefix -- it never touches the 'alarms/vs'
|
||||
stem -- so matching that fixed segment plus a wildcard tail catches
|
||||
every shape without this module needing to be subdevice-aware.
|
||||
|
||||
See `ObserveManager.apply`'s use of this for why the href matters:
|
||||
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
|
||||
is a complete snapshot of every currently-active alarm, not a
|
||||
possibly-partial field update -- so it must never be merged onto a
|
||||
stale prior rep (issue #348).
|
||||
"""
|
||||
head, _, _ = href.rpartition("/")
|
||||
return head.endswith("/alarms/vs")
|
||||
|
||||
|
||||
class ObserveManager:
|
||||
"""Per-device observe-mode state: mode, write-settle guard, and (later)
|
||||
subscription/staleness tracking. Pure sync logic — safe to call from
|
||||
@@ -77,10 +98,21 @@ class ObserveManager:
|
||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||
self._notify_cond = threading.Condition()
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
self._on_applied: Callable[[str, dict, str], None] | None = None
|
||||
self._refresh_task: ObserveRefreshTask | None = None
|
||||
self._refresh_stop: threading.Event | None = None
|
||||
self._refresh_thread: threading.Thread | None = None
|
||||
|
||||
def set_on_applied(self, callback: Callable[[str, dict, str], None]) -> None:
|
||||
"""Hook run after every accepted rep, on the applying thread.
|
||||
|
||||
Unlike StateCache.set_on_change it carries the href and rep, and
|
||||
fires even when the rep is unchanged -- which learned.py needs, a
|
||||
device sitting in an unadvertised mode re-sending the same rep
|
||||
every poll.
|
||||
"""
|
||||
self._on_applied = callback
|
||||
|
||||
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
|
||||
with self._settle_lock:
|
||||
self._settle_until[href] = time.monotonic() + settle_s
|
||||
@@ -110,6 +142,20 @@ class ObserveManager:
|
||||
comes through, even though nothing about the device's actual
|
||||
supported options changed.
|
||||
|
||||
`_is_alarms_href` is the one exception to that merge (issue #348):
|
||||
/alarms/vs/0's `items` array is always sent as a complete
|
||||
snapshot of every currently-active alarm, never a partial delta
|
||||
-- confirmed by a live `read_resource` GET returning `{}` (no
|
||||
`items` key at all) the moment a washer's board actually clears
|
||||
an alarm, which entity.py already documents as this resource's
|
||||
normal no-alarm shape. Merging that `{}` onto the prior rep the
|
||||
same way as everywhere else silently kept the stale `items`
|
||||
entry forever: an absent key merges as "unchanged" everywhere
|
||||
else, but on this href absent specifically means "cleared".
|
||||
Every family that exposes an alarm sensor shares this href
|
||||
(common.ALARMS, range_hood's own copy), so this is a full
|
||||
replace for all of them, not a washer-specific carve-out.
|
||||
|
||||
`apply()` is the sole path StateCache mutations flow through in
|
||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||
so `_cache_lock` serializes the read-then-write across those
|
||||
@@ -137,8 +183,15 @@ class ObserveManager:
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
with self._cache_lock:
|
||||
merged = {**(self.cache.get(href) or {}), **rep}
|
||||
return self.cache.apply_rep(href, merged, source=source)
|
||||
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
|
||||
changed = self.cache.apply_rep(href, merged, source=source)
|
||||
# Outside the cache lock -- the hook takes locks of its own and
|
||||
# never reads the cache back. `source` is passed along rather than
|
||||
# filtered here: which sources are worth acting on is the hook's
|
||||
# policy, not this manager's.
|
||||
if self._on_applied is not None:
|
||||
self._on_applied(href, merged, source)
|
||||
return changed
|
||||
|
||||
def on_notification(self, href: str, payload: bytes) -> None:
|
||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||
@@ -171,17 +224,16 @@ class ObserveManager:
|
||||
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
|
||||
)
|
||||
|
||||
def try_enter_observe_mode(
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""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."""
|
||||
def subscribe_hrefs(self, session, hrefs: list[str]) -> set[str]:
|
||||
"""Register OBSERVE on every href; returns the ones that took.
|
||||
Blocking — run in an executor.
|
||||
|
||||
Split from the grace wait below (issue #294) so the coordinator can
|
||||
hold its session lock for just these sends -- each is a fire-and-
|
||||
forget UDP datagram (DtlsCoapSession.subscribe doesn't wait for the
|
||||
device's ack), unlike the wait, which can block for the whole grace
|
||||
period and must not hold a lock a command write is also waiting on.
|
||||
"""
|
||||
with self._notify_cond:
|
||||
self._notified.clear()
|
||||
subscribed: set[str] = set()
|
||||
@@ -192,30 +244,65 @@ class ObserveManager:
|
||||
subscribed.add(href)
|
||||
except Exception as e:
|
||||
self.log.warning("subscribe %s failed: %s", href, e)
|
||||
return subscribed
|
||||
|
||||
def await_observe_notifies(
|
||||
self,
|
||||
subscribed: set[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — waits up to `grace_period_s`, returning early once
|
||||
`success_fraction` of `subscribed` have notified. Touches no
|
||||
session; safe to run without holding a session lock."""
|
||||
if not subscribed:
|
||||
self._stop_refresh_task()
|
||||
self._set_mode(MODE_POLL)
|
||||
self.subscribed_hrefs = set()
|
||||
return False
|
||||
|
||||
def _fraction_reached() -> bool:
|
||||
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
|
||||
|
||||
with self._notify_cond:
|
||||
reached = self._notify_cond.wait_for(
|
||||
_fraction_reached,
|
||||
timeout=grace_period_s,
|
||||
)
|
||||
return 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)
|
||||
return True
|
||||
def enter_observe_mode(self, session, subscribed: set[str]) -> None:
|
||||
"""Commit a successful attempt. Caller must have re-confirmed
|
||||
`session` is still the live one under its session lock (issue
|
||||
#294) -- committing against a session a reconnect already replaced
|
||||
would claim observe mode with nothing left to notice it's dead."""
|
||||
self.subscribed_hrefs = set(subscribed)
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
|
||||
def abandon_observe_attempt(self) -> None:
|
||||
"""Drop a failed or stale attempt: no subscriptions worth keeping."""
|
||||
self._stop_refresh_task()
|
||||
self.subscribed_hrefs = set()
|
||||
self._set_mode(MODE_POLL)
|
||||
|
||||
def try_enter_observe_mode(
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""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.
|
||||
|
||||
Single-threaded convenience wrapper around the phase split above
|
||||
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
|
||||
abandon_observe_attempt) for callers -- direct and most existing
|
||||
tests -- that don't need the lock-scoping those phases exist for."""
|
||||
subscribed = self.subscribe_hrefs(session, hrefs)
|
||||
if not subscribed:
|
||||
self.abandon_observe_attempt()
|
||||
return False
|
||||
if self.await_observe_notifies(subscribed, grace_period_s, success_fraction):
|
||||
self.enter_observe_mode(session, subscribed)
|
||||
return True
|
||||
self.abandon_observe_attempt()
|
||||
return False
|
||||
|
||||
def _set_mode(self, mode: str) -> None:
|
||||
|
||||
@@ -18,12 +18,17 @@ def is_stub_rep(rep: dict) -> bool:
|
||||
def parse_device0_batch(device0: list) -> dict[str, dict]:
|
||||
"""Extract {href: rep} from a /device/0 CBOR list response.
|
||||
|
||||
Most devices put a collection representation without an ``href`` at
|
||||
index 0, while some firmware starts directly with resource entries.
|
||||
Iterate the whole list and let the existing href check ignore collection
|
||||
metadata so the first real resource is preserved in either shape.
|
||||
|
||||
A stub rep is passed through unchanged rather than collapsed to {} --
|
||||
downstream code (entity._is_included, capability exists_fns) uses
|
||||
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
|
||||
"""
|
||||
out = {}
|
||||
for entry in device0[1:]: # skip [0] (device-level rep)
|
||||
for entry in device0:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
href = entry.get("href")
|
||||
|
||||
@@ -63,9 +63,9 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
|
||||
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
||||
# `description` right before any '/board-info' suffix) -> registry key.
|
||||
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
|
||||
# internal board-family prefix there, and dishwasher's modelNum contains
|
||||
# the substring 'WW', so a modelNum-only rule misroutes both.
|
||||
# NOT derived from `modelNum`: washer and dryer share the same 'DA_WM_'
|
||||
# board-family prefix there, and dishwasher's modelNum contains the
|
||||
# substring 'WW', so a modelNum-only rule misroutes both.
|
||||
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||
"WW": "washer",
|
||||
"WD": "washer",
|
||||
@@ -79,48 +79,39 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||
# Board-family token -> registry key, matched against whole tokens of
|
||||
# `modelNum`/`description` (see `_board_tokens`).
|
||||
#
|
||||
# Tokenizing instead of substring-matching is what keeps this a table rather
|
||||
# than a ladder of hand-written rules. Samsung spells the same board family
|
||||
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
|
||||
# same RAC family -- so a substring rule has to be written once per spelling
|
||||
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
|
||||
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
|
||||
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
|
||||
# every one of those as a single entry.
|
||||
# Tokenizing instead of substring-matching keeps this a table rather than a
|
||||
# ladder of hand-written rules: Samsung spells the same board family with
|
||||
# either delimiter ('TP1X_DA-AC-RAC-01001' vs 'TP2X_RAC_20K', both RAC), so
|
||||
# a substring rule would need writing once per spelling, and a token with
|
||||
# no trailing delimiter ('ARTIK051_DONGLE_REF') would match neither.
|
||||
#
|
||||
# Entries must name the *specific* device type, never the board family that
|
||||
# Entries must name the specific device type, never the board family that
|
||||
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
|
||||
# would swallow the dehumidifier and the air purifier. Where two families
|
||||
# genuinely share a resource surface they share a registry (all the
|
||||
# air-conditioner spellings below), which is a statement about the hardware,
|
||||
# not a shortcut.
|
||||
# genuinely share a resource surface they share a registry (the
|
||||
# air-conditioner spellings below), which is a statement about the
|
||||
# hardware, not a shortcut.
|
||||
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||
"REF": "refrigerator",
|
||||
# Air conditioners. Every one of these is a distinct board family with
|
||||
# the same resource surface: room (issues #37, #91), package, Korean
|
||||
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
|
||||
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
|
||||
# Air conditioners: distinct board families sharing one resource
|
||||
# surface -- room, package, Korean (#136), window (#87), 2-in-1
|
||||
# floor+wall (#150/#153), system/commercial (#52), cassette (#191), and
|
||||
# ARA-WW wall-mount (#115-120).
|
||||
"RAC": "airconditioner",
|
||||
"PRAC": "airconditioner",
|
||||
"KRAC": "airconditioner",
|
||||
"WAC": "airconditioner",
|
||||
"FAC": "airconditioner",
|
||||
"CAWW": "airconditioner",
|
||||
"CAC": "airconditioner", # issue #191 -- TP1X_DA-AC-CAC-01001_0000
|
||||
"CAC": "airconditioner", # issue #191
|
||||
"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
|
||||
"EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water
|
||||
"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
|
||||
# next-gen BESPOKE Cube Air board; same
|
||||
# lineage as VTWW above but the '-WW-'
|
||||
# delimiter now falls one letter to the
|
||||
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
|
||||
# into a token the existing entry can't see
|
||||
# issue #190: same lineage as VTWW, but the '-WW-' delimiter falls one
|
||||
# letter left ('A-VTWW-' -> 'AVT-WW-'), splitting into a different token.
|
||||
"AVT": "air_purifier",
|
||||
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
|
||||
"WATERPURIFIER": "water_purifier", # issue #90
|
||||
"ADW": "dishwasher",
|
||||
@@ -129,13 +120,12 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||
"OVEN": "oven", # issue #55 -- wall oven, no burners
|
||||
"MICROWAVE": "microwave", # issues #66, #121
|
||||
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
|
||||
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
|
||||
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
|
||||
# token, and so the loosest entry in this table -- it is only ever
|
||||
# reached by a device that matched nothing more specific, and its
|
||||
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
||||
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
|
||||
# for the field ordering that makes that resolve correctly.
|
||||
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'): burner state
|
||||
# lives in /mode/vs/0's options array. Deliberately the loosest entry
|
||||
# here -- reached only when nothing more specific matched, since its
|
||||
# description ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
||||
# induction cooktop via COOKTOP above (see for_device_by_model's field
|
||||
# ordering).
|
||||
"CT": "cooktop",
|
||||
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
|
||||
"DF": "air_dresser", # issue #162
|
||||
@@ -161,20 +151,16 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
|
||||
def _board_family_key(value: str, cut_at: str) -> str | None:
|
||||
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
|
||||
|
||||
No known modelNum or description yields two *conflicting* board keys, so
|
||||
which token is found first doesn't matter within one field -- the table is
|
||||
a flat lookup, not a priority list. Adding an entry that could co-occur
|
||||
with another (a family token, or one short enough to collide by accident)
|
||||
would break that property; see this table's comment.
|
||||
No known modelNum or description yields two conflicting board keys, so
|
||||
which token is found first doesn't matter within one field -- the
|
||||
table is a flat lookup, not a priority list.
|
||||
|
||||
One documented exception (issue #196): AILITE water-purifier boards
|
||||
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
|
||||
shared cooling-subsystem board, not the refrigerator device type --
|
||||
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
|
||||
or rename either entry (both are correct on their own for the model
|
||||
strings that exist today), this one known co-occurrence resolves to
|
||||
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
|
||||
matching carve-out for this exact pair.
|
||||
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names
|
||||
the shared cooling-subsystem board, not the refrigerator type --
|
||||
'WATERPURIFIER' is the actual, more specific type. This one known
|
||||
co-occurrence resolves to 'water_purifier'; TestBoardTokenAmbiguity
|
||||
carries a matching carve-out for this exact pair.
|
||||
"""
|
||||
tokens = _board_tokens(value, cut_at)
|
||||
if "REF" in tokens and "WATERPURIFIER" in tokens:
|
||||
@@ -191,25 +177,19 @@ def _consumer_model_key(description: str) -> str | None:
|
||||
|
||||
Usually that token is the last '_'-delimited segment before any
|
||||
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
|
||||
But issue #79's dryer pairs two model numbers in one description --
|
||||
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token
|
||||
('DVE50A8800') sits one segment *before* the actual last segment
|
||||
('8600', a bare second model number with no recognizable prefix). Scan
|
||||
segments from the end and take the first one that resolves, rather
|
||||
than assuming the last segment is always it.
|
||||
But issue #79's dryer pairs two model numbers in one description, so
|
||||
the true consumer token sits one segment before the actual last
|
||||
segment -- scan from the end and take the first segment that resolves.
|
||||
|
||||
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
|
||||
prefixes matched against the *start* of a segment, so widening the split
|
||||
to '-' as well would start reading board-family segments as consumer
|
||||
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
|
||||
'WW' segment and route to washer.
|
||||
Splits on '_' only, unlike `_board_tokens` above: widening the split to
|
||||
'-' would start reading board-family segments as consumer models (the
|
||||
dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare 'WW' and
|
||||
route to washer).
|
||||
|
||||
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
|
||||
board-family token, issue #87) also starts with 'WA' (the top-load-washer
|
||||
prefix, issue #106) at this granularity. for_device_by_model() consults
|
||||
the board-family table first and this function only as a fallback, so
|
||||
that ambiguity resolves correctly without this function needing to know
|
||||
about unrelated device families.
|
||||
Only a 2-letter prefix match, so e.g. 'WAC' (Window AC, issue #87) also
|
||||
matches 'WA' (top-load washer, issue #106) at this granularity --
|
||||
for_device_by_model() consults the board-family table first and this
|
||||
only as a fallback, so that ambiguity resolves correctly.
|
||||
"""
|
||||
segments = (description or "").split("/", 1)[0].split("_")
|
||||
for segment in reversed(segments):
|
||||
@@ -220,61 +200,50 @@ def _consumer_model_key(description: str) -> str | 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.
|
||||
# -> registry key. The device naming its own type, no board-part guessing --
|
||||
# consulted before modelNum/description.
|
||||
#
|
||||
# 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.
|
||||
# `test_every_oic_type_resolves_to_a_real_registry`) -- this deliberately
|
||||
# stops short of the full OCF/SmartThings vocabulary, since most of it (lights,
|
||||
# locks, cameras, TVs, ...) has no registry here to point at, and
|
||||
# 'oic.d.robotcleaner' names an actual robot vacuum, a different product from
|
||||
# the clean/auto-empty *station* `vacuum_station` covers.
|
||||
#
|
||||
# `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.
|
||||
# device-type vocabulary, for categories with no `oic.d.*` equivalent.
|
||||
#
|
||||
# `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.d.cooktop` is deliberately absent: a TP1X_DA-KS-COOKTOP induction
|
||||
# reports it, but `cooktop` and `induction_cooktop` are unrelated registries
|
||||
# sharing the English word (see by_type/cooktop.py's docstring) -- the OCF
|
||||
# type doesn't distinguish them, and as the primary signal it would override
|
||||
# a correct `COOKTOP`/`CT` board token. 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.range": "range", # issue #324 -- oven+cooktop combo, no /information/vs/0
|
||||
"oic.d.refrigerator": "refrigerator",
|
||||
"oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator
|
||||
"oic.d.washer": "washer",
|
||||
"x.com.st.d.airqualitysensor": "air_monitor",
|
||||
"x.com.st.d.dehumidifier": "dehumidifier",
|
||||
"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",
|
||||
"x.com.st.d.winecellar": "refrigerator", # issue #328 -- same TP1X_REF_21K board
|
||||
}
|
||||
|
||||
|
||||
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
|
||||
"""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.
|
||||
declaration. The primary path when a dump carries it, since the device
|
||||
names its own type. Most hardware still doesn't populate `/oic/d`
|
||||
usefully, so `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)
|
||||
@@ -323,15 +292,15 @@ def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | No
|
||||
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
|
||||
"""Detect a device family from a distinctive local-resource signature.
|
||||
|
||||
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.
|
||||
Runs first as an override path for non-standard devices -- not because
|
||||
resource signatures are more trustworthy than OIC/model metadata, but
|
||||
because it also types boards with no ``/information/vs/0`` at all.
|
||||
Some newer cooktops were the original case: their mode resource still
|
||||
identifies them via a DeviceType option and multiple per-burner
|
||||
OperationState options.
|
||||
|
||||
Require two independent shapes for every signature here, never one, so
|
||||
putting this ahead of OIC/model metadata cannot let a common resource
|
||||
Every signature here requires two independent shapes, never one, so
|
||||
running this ahead of OIC/model metadata can't let a common resource
|
||||
misclassify an unrelated family.
|
||||
"""
|
||||
mode = resources.get("/mode/vs/0", {})
|
||||
@@ -347,12 +316,10 @@ def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None
|
||||
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
|
||||
return _REGISTRY_BY_KEY["range_hood"]
|
||||
# Oven/range/microwave boards that report no /information/vs/0 at all
|
||||
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
|
||||
# absent from the dump, not just empty) can't be matched via
|
||||
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
|
||||
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
|
||||
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
|
||||
# PR #225) when resource detection runs before metadata.
|
||||
# (issues #74, #172) can't be matched via modelNum tokens either. Mode
|
||||
# vocabulary alongside the oven cavity resource (/oven/vs/0) is a safe
|
||||
# two-resource signature; it also corrects Qooker's generic oic.d.oven
|
||||
# metadata (PR #225) since resource detection runs before it.
|
||||
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
|
||||
if not isinstance(supported_modes, (list, tuple)):
|
||||
supported_modes = ()
|
||||
@@ -379,20 +346,18 @@ def resolve(
|
||||
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.
|
||||
|
||||
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.
|
||||
Distinctive resource signatures run first, since they describe the
|
||||
live capability surface a registry must bind; `for_device_by_resources`
|
||||
is deliberately strict (multiple independent details required) so this
|
||||
can correct misleading metadata without a common href overriding an
|
||||
unrelated family. When no signature matches, `/oic/d`'s `rt` 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.
|
||||
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
|
||||
device that does is already typed by its modelNum board token, and no
|
||||
device-support issue has ever been fixed by adding a mapping for it. It
|
||||
is still reported in diagnostics as a firmware-generation marker.
|
||||
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted:
|
||||
only a minority of hardware populates it, every device that does is
|
||||
already typed by its modelNum board token, and no device-support issue
|
||||
has ever needed it. Still reported in diagnostics as a firmware
|
||||
marker.
|
||||
"""
|
||||
info = resources.get("/information/vs/0", {})
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,12 @@ match_fn discriminators that keep them from colliding):
|
||||
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
|
||||
needed.
|
||||
|
||||
AIR_LEVEL_CHECK ("AI Purify" -- the periodic air-quality sensing engine on
|
||||
/airlevelcheck/vs/0) is shared by the last three of those: their dumps all
|
||||
carry the resource with the same field names, and only the TVTL family has no
|
||||
such href. It was covered as opaque plumbing until two AVT-WW-TP1 dumps
|
||||
(issues #84 and #190) showed it drives a real user-facing feature.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
|
||||
contract).
|
||||
"""
|
||||
@@ -38,6 +44,7 @@ REGISTRY = DeviceRegistry(
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.AIR_LEVEL_CHECK,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
|
||||
@@ -17,7 +17,7 @@ uses unconditionally.
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
"""
|
||||
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -46,6 +46,28 @@ REGISTRY = DeviceRegistry(
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
# TP1X_DA-AC-FAC-class (issue #319): shares its /display/vs/0,
|
||||
# /settings/sound/output/vs/0 and /settings/sound/volume/vs/0
|
||||
# shape with the sibling TP1X_DA-AC-AIR board in air_purifier.py.
|
||||
air_purifier.DISPLAY,
|
||||
air_purifier.SOUND_OUTPUT,
|
||||
air_purifier.SOUND_VOLUME,
|
||||
airconditioner.SOUND_MODE,
|
||||
airconditioner.ABSENCE_CLEAN,
|
||||
airconditioner.MDS_ABSENCE_CLEAN,
|
||||
airconditioner.ENERGY_SAVING,
|
||||
airconditioner.EDGE_LIGHTING,
|
||||
airconditioner.LIGHT_STATEFUL,
|
||||
# System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000):
|
||||
# WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/
|
||||
# DEVICE_ACTIVE reuse air_purifier.py's identical shapes.
|
||||
# AIR_LEVEL_CHECK is not this-device-specific -- see its
|
||||
# removal from _AC_IGNORED above.
|
||||
airconditioner.WINDFREE,
|
||||
airconditioner.WINDSLEEP,
|
||||
air_purifier.HEPA_FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIR_LEVEL_CHECK,
|
||||
*airconditioner.COVERAGE,
|
||||
]
|
||||
),
|
||||
|
||||
@@ -22,6 +22,12 @@ REGISTRY = DeviceRegistry(
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
# issue #314: /alarms/vs/0 and /kidslock/vs/0 are the same
|
||||
# generic shapes common.UNIVERSAL already models elsewhere --
|
||||
# picked individually rather than pulling in all of UNIVERSAL,
|
||||
# matching this registry's existing hand-picked-common style.
|
||||
common.ALARMS,
|
||||
common.KIDS_LOCK_VS_FALLBACK,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Oven device registry."""
|
||||
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ..capabilities import common, dishwasher, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -18,6 +18,9 @@ REGISTRY = DeviceRegistry(
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
# issue #300: /diagnosis/vs/0 is the same diagnosisStart shape
|
||||
# dishwasher.py and airconditioner.py already reuse.
|
||||
dishwasher.DIAGNOSIS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,11 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.WATER_FILTER,
|
||||
fridge.AIR_FILTER,
|
||||
fridge.DEODOR_FILTER,
|
||||
fridge.AUTO_DOOR_TIMER,
|
||||
fridge.WINECELLAR_PANTRY_ZONE,
|
||||
fridge.WINECELLAR_INFO,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
@@ -43,5 +48,6 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.DOOR_GENERIC,
|
||||
fridge.KIMCHI_ZONE,
|
||||
fridge.KIMCHI_DOOR_GENERIC,
|
||||
fridge.AUTO_DOOR_VARIANT,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -12,24 +12,16 @@ 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.
|
||||
board confirms what its scale means -- left unbound rather than guessed;
|
||||
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).
|
||||
(pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate
|
||||
values: Samsung's own two-tier Korean convention 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 fits. A wrong guess would silently mislabel every
|
||||
reading forever, so they're plain `measurement` sensors named after the
|
||||
device's own field instead, matching air_purifier.AIR_QUALITY's precedent.
|
||||
"""
|
||||
|
||||
from datetime import time as dt_time
|
||||
@@ -39,6 +31,12 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
from .air_purifier import _AIR_QUALITY_SENSORS
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
# _AIR_QUALITY_SENSORS' fourth column (state_class) is deliberately discarded
|
||||
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
|
||||
# graded indices on that family, while this board has stamped all five as
|
||||
# `measurement` since it was added (issue #210). Consuming the column would
|
||||
# silently drop long-term statistics for two sensors on shipped devices, so
|
||||
# the shared rows supply only the key/icon/type here.
|
||||
SENSORS = Capability(
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -51,7 +49,7 @@ SENSORS = Capability(
|
||||
state_class="measurement",
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, _ in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
@@ -134,13 +132,10 @@ def _dnd_time_write(field):
|
||||
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.
|
||||
# Issue #210: only one dump exists (DND never toggled in it), so this write
|
||||
# contract is an educated guess -- symmetric with the read side's own
|
||||
# 'true'/'false' and 'HH:MM:SS' formats, but still needs a reporter to
|
||||
# confirm it on real hardware.
|
||||
DND = Capability(
|
||||
href="/dnd/vs/0",
|
||||
poll_tier="cold",
|
||||
|
||||
@@ -1,63 +1,29 @@
|
||||
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
|
||||
(model AX60R5080WD/SE, issue #56).
|
||||
|
||||
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
|
||||
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
|
||||
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
|
||||
dishwasher.DIAGNOSIS -- identical field/write contract
|
||||
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
|
||||
Power, kids-lock, remote-control, alarms, and the energy meter are the
|
||||
shared common.py capabilities; /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS
|
||||
(identical field/write contract).
|
||||
|
||||
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
|
||||
'<Prefix>_<value>' flags into one list -- the same packed-list contract
|
||||
laundry.py's option_value/option_write already model for /course/vs/0's
|
||||
options[] (reused directly below, just against this family's own href). Per
|
||||
issue #56's follow-up (five diagnostics dumps captured with the physical unit
|
||||
set to Auto/Sleep/Low/Medium/High):
|
||||
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
|
||||
real switch, RMW-replacing just that one entry.
|
||||
NOT the same polarity as the AC family's own
|
||||
Light_On/Light_Off token on its own /mode/vs/0
|
||||
(airconditioner._display_light_on) -- that one is
|
||||
confirmed inverted (Light_Off means the panel is
|
||||
lit) on live hardware. Same token name, same
|
||||
resource name, different device type and
|
||||
opposite meaning -- don't unify them.
|
||||
Comode_Off -- read 'Off' on *every* one of the five dumps,
|
||||
including High/Low/Medium/Auto -- confirms this
|
||||
is NOT the fan-speed selector (ruling out the
|
||||
original guess); exposed read-only since its
|
||||
actual purpose is still unconfirmed.
|
||||
OptionCode_60282 -- confirmed opaque/not user-facing in the
|
||||
SmartThings app; not modeled (same treatment as
|
||||
range_hood's OptionCode_* token on the same
|
||||
href).
|
||||
Blooming_* -- confirmed to have no corresponding SmartThings
|
||||
app setting; dropped entirely rather than kept
|
||||
as an unexplained diagnostic (it did track 1:1
|
||||
with Sleep mode across the five dumps -- 0 in
|
||||
Sleep, 6 otherwise -- so it's plausibly an
|
||||
automatic side effect of sleep mode, e.g. a
|
||||
display-dimming level, but that's still a guess).
|
||||
/mode/vs/0's options[] packs several '<Prefix>_<value>' flags, the same
|
||||
packed-list contract as laundry.py's option_value/option_write. Light_On/
|
||||
Light_Off is a real on/off switch here -- NOT the same polarity as the AC
|
||||
family's own Light_On/Light_Off token on its own /mode/vs/0, which is
|
||||
inverted (airconditioner._display_light_on). Comode_Off reads 'Off' on
|
||||
every setting (Auto/Sleep/Low/Medium/High), ruling out the original
|
||||
"fan speed selector" guess; exposed read-only. OptionCode_* and Blooming_*
|
||||
are unmodeled: confirmed opaque / not app-facing.
|
||||
|
||||
/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up).
|
||||
The first round of five dumps above wasn't conclusive -- it read 0 for both
|
||||
Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five
|
||||
were captured within about a minute of each other, faster than this
|
||||
integration's own ~30s poll cycle could settle each change. A second round,
|
||||
captured 60-90s apart per setting on two independent units, confirmed a
|
||||
clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4.
|
||||
AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4
|
||||
range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's
|
||||
percentage steps straight onto the raw code, no named-preset table needed
|
||||
(unlike the TP1X family's FAN, which exposes real named modes because its
|
||||
board actually reports a supportedModes list to hang names off of).
|
||||
|
||||
/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it
|
||||
was unreliable on both units in that second round (Low/Medium collided on
|
||||
one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below
|
||||
stays a plain read-only diagnostic even after this change.
|
||||
/airflow/0's `speed` is a real fan-speed control: two independent units,
|
||||
sampled 60-90s apart per setting, confirmed a clean monotonic 0-4 mapping
|
||||
across Auto/Sleep/Low/Medium/High. AIRFLOW_GENERIC below builds an
|
||||
ordered-speed fan off that range. /airflow/vs/0's vendor `speedLevel` is
|
||||
NOT used for the same purpose -- unreliable on both units in the same
|
||||
round (collided Low/Medium on one, stuck at 0 on the other).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc,
|
||||
@@ -66,18 +32,16 @@ from ..entities import (
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
TimeDesc,
|
||||
)
|
||||
from .common import filter_usage_percent, int_or_none, sensor_item_value
|
||||
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
||||
|
||||
# Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue
|
||||
# #130) report fan modes directly on /mode/vs/0's top-level `modes`/
|
||||
# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing
|
||||
# everything into the options[] array the way the older ARTIK051_TVTL
|
||||
# family above does -- that older family's /mode/vs/0 has no top-level
|
||||
# supportedModes at all (see the module docstring's Comode_Off finding).
|
||||
# Both board generations share the /mode/vs/0 href, so FAN and MODE below
|
||||
# are mutually exclusive via this presence check rather than colliding.
|
||||
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
|
||||
# on /mode/vs/0's top-level modes/supportedModes instead of packing
|
||||
# everything into options[] like the older ARTIK051_TVTL family. Both
|
||||
# generations share this href; FAN and MODE below are mutually exclusive
|
||||
# via presence of supportedModes.
|
||||
HREF_MODE = "/mode/vs/0"
|
||||
HREF_AIRFLOW = "/airflow/0"
|
||||
HREF_WIND_STRENGTH = "/wind/strength/vs/0"
|
||||
@@ -87,12 +51,31 @@ def _has_top_level_modes(rep, resources):
|
||||
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
|
||||
|
||||
|
||||
# The fourth column is state_class, which is what makes Home Assistant keep
|
||||
# long-term statistics for a sensor -- without one, a reading is only in the
|
||||
# short-term recorder history and disappears with the next purge (10 days by
|
||||
# default), so it can't back a long-range air-quality graph. The values are
|
||||
# already numeric (sensor_item_value returns int), so nothing else was in the
|
||||
# way; three sensors in this same module (filter_progress, fan_speed_level,
|
||||
# hepa_filter_usage) already declare one.
|
||||
#
|
||||
# Only the three particulate readings get it. They fall monotonically with
|
||||
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
|
||||
# (issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
|
||||
# which is concentration behaviour, and an average over time is meaningful for
|
||||
# it. Odor and CleanLevel read 0-2 on every fixture and look like graded
|
||||
# indices instead, where the mean of a grade isn't obviously meaningful; left
|
||||
# without a state_class rather than guessing.
|
||||
#
|
||||
# Deliberately no device_class/unit here: pm1/pm25/pm10 would assert the
|
||||
# reading is a µg/m³ concentration, and the dumps never say so. That's a
|
||||
# separate call from making the series recordable at all.
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
("dust", "mdi:blur", "Dust"),
|
||||
("fine_dust", "mdi:blur", "FineDust"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust"),
|
||||
("odor", "mdi:scent", "Odor"),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel"),
|
||||
("dust", "mdi:blur", "Dust", "measurement"),
|
||||
("fine_dust", "mdi:blur", "FineDust", "measurement"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement"),
|
||||
("odor", "mdi:scent", "Odor", None),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel", None),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
@@ -103,9 +86,10 @@ AIR_QUALITY = Capability(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class=state_class,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
@@ -119,12 +103,9 @@ def _consumable_state(items, name):
|
||||
return None
|
||||
|
||||
|
||||
# FilterProgress is a 0-100 percentage counting up as the filter wears --
|
||||
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
|
||||
# once this reaches 100, so 100 means fully used, not "brand new." Named
|
||||
# after the raw field (matching the AC/range_hood filterUsage convention,
|
||||
# which counts the same direction) rather than "filter life," which would
|
||||
# imply the opposite direction.
|
||||
# FilterProgress counts UP as the filter wears (100 = "needs changing",
|
||||
# confirmed via the SmartThings app) -- named after the raw field rather
|
||||
# than "filter life," which would imply the opposite direction.
|
||||
FILTER = Capability(
|
||||
href="/consumable/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -157,11 +138,9 @@ DEVICE_ACTIVE = Capability(
|
||||
|
||||
|
||||
def _power_write(power_href, value):
|
||||
"""Shared 'power' payload handling for this family's three FanDesc write
|
||||
functions -- targets whichever power href fan.py's _power_payload picked
|
||||
(the board may only report /power/0); a hardcoded vendor href here would
|
||||
silently no-op on such a board even though the entity's own is_on
|
||||
already falls back to reading it correctly."""
|
||||
"""Shared 'power' payload handling for this family's FanDescs -- targets
|
||||
whichever power href fan.py picked (the board may only report
|
||||
/power/0)."""
|
||||
if power_href == "/power/0":
|
||||
return ["power", "0"], {"value": bool(value)}
|
||||
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
||||
@@ -176,24 +155,14 @@ def _airflow_fan_write(payload, rep, href=None):
|
||||
return None
|
||||
|
||||
|
||||
# Confirmed via issue #56's second, properly-spaced round of diagnostics
|
||||
# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is
|
||||
# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now
|
||||
# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same
|
||||
# SET_SPEED shape as the range hood's) instead of a read-only sensor --
|
||||
# no named-preset table needed, since HA's percentage steps map onto the
|
||||
# raw 0-4 code directly, the same way the range hood's numeric levels do.
|
||||
# `direction` stays a plain diagnostic: every dump seen (both rounds, both
|
||||
# units) reads 'Off' for it regardless of fan setting, so there's nothing
|
||||
# confirmed to control there yet.
|
||||
# Confirmed monotonic 0-4 speed code (see module docstring) backs a real
|
||||
# ordered-speed fan, same SET_SPEED shape as the range hood's. `direction`
|
||||
# stays a diagnostic: every dump reads 'Off' regardless of fan setting.
|
||||
#
|
||||
# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared
|
||||
# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built
|
||||
# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are
|
||||
# only *empirically* mutually exclusive (every dump seen has one board
|
||||
# generation's shape or the other, never both), not architecturally
|
||||
# enforced the way same-href caps are by _build()'s match_fn check -- a
|
||||
# same key would collide if a future board ever reported both.
|
||||
# Keyed 'airflow_fan', not 'fan' -- FAN below shares this registry and also
|
||||
# uses key 'fan'; unique_id is built from key alone, so a shared key would
|
||||
# collide if a board ever reported both (empirically mutually exclusive,
|
||||
# not architecturally enforced the way same-href caps are).
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href=HREF_AIRFLOW,
|
||||
poll_tier="warm",
|
||||
@@ -208,10 +177,8 @@ AIRFLOW_GENERIC = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Left exactly as a read-only fallback -- speedLevel is NOT the same
|
||||
# confirmed-reliable field as /airflow/0's speed above (see module
|
||||
# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
|
||||
# on the other in the same properly-spaced round.
|
||||
# Read-only fallback: speedLevel is unreliable (see module docstring),
|
||||
# unlike /airflow/0's speed.
|
||||
AIRFLOW_VS_FALLBACK = Capability(
|
||||
href="/airflow/vs/0",
|
||||
match_fn=lambda rep, resources: "/airflow/0" not in resources,
|
||||
@@ -236,12 +203,9 @@ AIRFLOW_VS_FALLBACK = Capability(
|
||||
|
||||
|
||||
def _light_write(payload, rep, href=None):
|
||||
# option_write's single-token write is confirmed on a washer's
|
||||
# /course/vs/0 (issue #54), NOT independently on this family's
|
||||
# /mode/vs/0 -- extrapolated on the assumption the same vendor field
|
||||
# merges the same way everywhere. If some unit replaces the field
|
||||
# outright instead, this would drop Comode/OptionCode alongside it on
|
||||
# the next light toggle; revisit if a real device report surfaces that.
|
||||
# option_write's single-token merge is confirmed on a washer's
|
||||
# /course/vs/0 (issue #54); extrapolated here on the assumption the
|
||||
# same vendor field merges the same way on this family's /mode/vs/0.
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Light", payload),
|
||||
}
|
||||
@@ -283,9 +247,8 @@ def _fan_write(payload, rep, href=None):
|
||||
|
||||
|
||||
def _first_fan_mode(rep):
|
||||
"""Representative scalar for the fan entity in the flattened state
|
||||
(golden/regression), mirroring airconditioner.py's own _first_mode --
|
||||
the real entity computes its state from live coordinator reads."""
|
||||
"""Representative scalar for the flattened golden state; the real
|
||||
entity reads live coordinator state instead."""
|
||||
modes = rep.get("x.com.samsung.da.modes")
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
@@ -293,10 +256,8 @@ def _first_fan_mode(rep):
|
||||
|
||||
|
||||
# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered
|
||||
# percentage -- WindFree/Smart/Sleep are named behaviors, not
|
||||
# "faster/slower" positions relative to Max/Mid, so fan.py's entity for
|
||||
# this only exposes PRESET_MODE, matching how the AC family's own named
|
||||
# convenient modes are modeled as a preset rather than a speed number.
|
||||
# percentage -- these are named behaviors, not "faster/slower" positions,
|
||||
# so fan.py only exposes PRESET_MODE here.
|
||||
FAN = Capability(
|
||||
href=HREF_MODE,
|
||||
poll_tier="warm",
|
||||
@@ -321,19 +282,14 @@ def _wind_strength_fan_write(payload, rep, href=None):
|
||||
return None
|
||||
|
||||
|
||||
# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but
|
||||
# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
|
||||
# A-VTWW-TP2-21-COMMON (issue #151): named presets like FAN above, but on a
|
||||
# distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
|
||||
# self-describing supportedModes -- x.com.samsung.da.modesName gives the
|
||||
# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's
|
||||
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded
|
||||
# per-model map. modes here is a bare string ('87'), not a single-element
|
||||
# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is.
|
||||
# real names, read live by fan.py rather than a hardcoded map. `modes` is a
|
||||
# bare string here, not a single-element list like HREF_MODE's.
|
||||
#
|
||||
# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry
|
||||
# and also uses a FanDesc; BoundEntity's unique_id is built from key alone
|
||||
# (entity.py's _key), not href, so two same-key FanDescs in one registry
|
||||
# would collide if a board ever bound both (see AIRFLOW_GENERIC's own
|
||||
# comment on this exact hazard -- missed here in the initial cut).
|
||||
# key is 'wind_strength_fan', not 'fan' -- same unique_id collision hazard
|
||||
# as AIRFLOW_GENERIC above.
|
||||
WIND_STRENGTH_FAN = Capability(
|
||||
href=HREF_WIND_STRENGTH,
|
||||
poll_tier="warm",
|
||||
@@ -347,15 +303,12 @@ WIND_STRENGTH_FAN = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several
|
||||
# resources the older ARTIK051_TVTL family never did.
|
||||
# ---------------------------------------------------------------------------
|
||||
# TP1X_DA-AC-AIR-class additions (issue #130): resources the older
|
||||
# ARTIK051_TVTL family never reported.
|
||||
|
||||
# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood
|
||||
# light): both report the same {mode, supportedModes: [On, Off]} shape on
|
||||
# separate hrefs on this dump, so they're two independent physical controls,
|
||||
# not a duplicate encoding of one.
|
||||
# Screen/indicator panel on/off, distinct from the display_light switch
|
||||
# above (ambient mood light) -- two independent controls on separate hrefs
|
||||
# with the same {mode, supportedModes: [On, Off]} shape.
|
||||
DISPLAY = Capability(
|
||||
href="/display/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -374,10 +327,8 @@ DISPLAY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own
|
||||
# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not
|
||||
# seen on this one dump, so the option list there is reused as-is rather
|
||||
# than re-deriving it from a single sample.
|
||||
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's
|
||||
# AIR_FILTER; the normal/wash/replace option list is reused as-is.
|
||||
HEPA_FILTER = Capability(
|
||||
href="/filter/hepafilter/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -403,10 +354,9 @@ HEPA_FILTER = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Physical panel/cover status -- meaning of the one value seen ('Close') is
|
||||
# plausible (the HEPA-filter access cover) but unconfirmed, and no
|
||||
# supportedStatus list is present to check against -- exposed as a plain
|
||||
# diagnostic sensor rather than an asserted binary_sensor polarity.
|
||||
# Physical panel/cover status ('Close' seen, plausibly the HEPA-filter
|
||||
# cover) -- unconfirmed, and no supportedStatus list to check against, so a
|
||||
# plain diagnostic rather than an asserted binary_sensor.
|
||||
PANEL_STATUS = Capability(
|
||||
href="/panel/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -440,22 +390,18 @@ PET_FILTER_ACTIVATION = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Sound mode/volume shapes look like laundry.py's SOUND_MODE/SOUND_VOLUME at
|
||||
# a glance, but this board's actual values differ (supportedModes here is
|
||||
# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range
|
||||
# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a
|
||||
# valid write ('buzzer') or expose the wrong number range, so these are
|
||||
# separate descriptors reading the live supported values instead of a
|
||||
# hardcoded table.
|
||||
# Sound mode/volume look like laundry.py's SOUND_MODE/SOUND_VOLUME but this
|
||||
# board's actual values differ (supportedModes here is ['mute', 'buzzer'],
|
||||
# not laundry's voice/tone/mute; volume is 0-3, not laundry's fixed 0-15) --
|
||||
# separate descriptors reading live supported values instead of reusing
|
||||
# laundry's hardcoded table.
|
||||
SOUND_MODE = Capability(
|
||||
href="/settings/sound/mode/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# Distinct translation_key from laundry.SOUND_MODE's shared
|
||||
# 'sound_mode' catalog entry -- that one's state table is
|
||||
# {voice, tone, mute}, but this board's supportedModes is
|
||||
# {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled
|
||||
# (falls through to the raw code) since the catalogs don't overlap.
|
||||
# 'sound_mode' catalog ({voice, tone, mute}) -- this board's
|
||||
# {mute, buzzer} doesn't overlap it.
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
translation_key="air_purifier_sound_mode",
|
||||
@@ -495,6 +441,10 @@ SOUND_VOLUME = Capability(
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
# Some boards (issue #319's AC) report minLevel/resolution but no
|
||||
# maxLevel -- native_max_fn would silently collapse to 0, giving a
|
||||
# slider with no real range instead of no entity at all.
|
||||
exists_fn=lambda rep, resources: "maxLevel" in rep,
|
||||
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
|
||||
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
|
||||
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
|
||||
@@ -507,35 +457,229 @@ SOUND_VOLUME = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
|
||||
# been verified against -- covered here (not globally, per ignored.py's
|
||||
# module docstring) since those hrefs collide with fridge/AC schemas
|
||||
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
|
||||
# AI Purify -- /airlevelcheck/vs/0 (issues #84, #190). Not scheduler
|
||||
# plumbing: it drives the SmartThings app's "AI Purify" feature (the unit
|
||||
# wakes on a timer, samples air, optionally acts). Reported with the same
|
||||
# field names by three of this registry's four board families (TP1X_DA-AC-AIR
|
||||
# #130, A-VTWW-TP2 #151, AVT-WW-TP1 #84/#190); ARTIK051_TVTL has no such
|
||||
# href. Bound unconditionally since it's safe to no-op where absent.
|
||||
#
|
||||
# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same
|
||||
# resources, same shapes, same reasoning as airconditioner.py's
|
||||
# _AC_IGNORED on the shared DA-AC- board family -- duplicated here rather
|
||||
# than promoted to the global ignored.py list, since that would require
|
||||
# also removing them from _AC_IGNORED in the same change (a global entry
|
||||
# colliding with a family-local bare Capability on the same href raises in
|
||||
# _build()); left as a possible follow-up DRY cleanup.
|
||||
# Two independent knobs, one entity each rather than folded into one
|
||||
# select: periodicSensingActivationState (is it running) and autoExeState
|
||||
# (what it does with a bad reading, Off/Airpurify/Alarm) -- mirrors the
|
||||
# appliance's own UI. Folding them lost information: a configured action
|
||||
# became invisible while off, and no option could toggle the feature
|
||||
# without also overwriting the action. The two "off"s are NOT
|
||||
# interchangeable: the switch's off stops sampling entirely; the select's
|
||||
# "Off" keeps sampling but doesn't act on it (the app calls that
|
||||
# "sensing only").
|
||||
#
|
||||
# range_hood.AIR_LEVEL_CHECK models the same href's read-only fields
|
||||
# (reused verbatim below) but is deliberately not imported: it exposes
|
||||
# periodic_air_sensing as a read-only BinarySensorDesc where this board
|
||||
# needs it writable, and reusing it would migrate every hood user's entity
|
||||
# to a different platform.
|
||||
#
|
||||
# Every write below was exercised on AVT-WW-TP1-23-AXX500 hardware and
|
||||
# verified by surviving a reconnect (this board 2.04s writes it silently
|
||||
# discards, so an echo proves nothing). The other two families get the same
|
||||
# writes on field-shape grounds only.
|
||||
#
|
||||
# Deferred: startSensingOnce looks like a one-shot "sense now" trigger but
|
||||
# stays unbound until its side effect (not just the echo) is confirmed.
|
||||
|
||||
|
||||
def _interval_minutes(seconds):
|
||||
"""Device stores the interval in seconds; the entity is in minutes.
|
||||
Rounds up (not to nearest) so a sub-minute value can't floor to 0."""
|
||||
secs = int_or_none(seconds)
|
||||
if secs is None:
|
||||
return None
|
||||
return -(-secs // 60) if secs > 0 else 0
|
||||
|
||||
|
||||
def _interval_write(payload, rep, href=None):
|
||||
# Minutes in the UI -> seconds on the wire. Modeled as a free Number,
|
||||
# not the app's three fixed choices, since the resource advertises no
|
||||
# constraint for this field (unlike supportedAutoExeState beside it)
|
||||
# and accepts finer values than the app offers (60s drove an observed
|
||||
# ~60s sensing cycle on hardware). One-minute floor matches this
|
||||
# board's own reporting resolution (lastSensingTime lands on exact
|
||||
# minutes). Zero is refused: unlike a real "no timer" 0 elsewhere in
|
||||
# this repo, nothing establishes what 0 does here. Silent no-op via
|
||||
# None, same shape as range_hood._lamp_level_write.
|
||||
minutes = round(float(payload))
|
||||
if minutes < 1:
|
||||
return None
|
||||
return ["airlevelcheck", "vs", "0"], {
|
||||
"x.com.samsung.da.periodicSensingInterval": str(minutes * 60)
|
||||
}
|
||||
|
||||
|
||||
def _periodic_sensing_write(payload, rep, href=None):
|
||||
# Master on/off; leaves autoExeState alone so the configured action
|
||||
# survives the feature being toggled off -- the select can't do that,
|
||||
# since every option write sets an action too.
|
||||
return ["airlevelcheck", "vs", "0"], {
|
||||
"x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off")
|
||||
}
|
||||
|
||||
|
||||
def _skip_status_write(payload, rep, href=None):
|
||||
return ["airlevelcheck", "vs", "0"], {
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": ("On" if payload == "On" else "Off")
|
||||
}
|
||||
|
||||
|
||||
# Daily skip window, stored as one HHMMHHMM string
|
||||
# (periodicSensingSkipTime). Cross-confirmed on two units (inert
|
||||
# '00000000' vs a real '03002300'). Split into two HA time entities; each
|
||||
# write reads the other half back out of the live rep so the pair
|
||||
# round-trips -- confirmed in both directions on hardware.
|
||||
def _skip_time_read(part):
|
||||
def _read(value):
|
||||
raw = str(value or "")
|
||||
chunk = raw[0:4] if part == "start" else raw[4:8]
|
||||
if len(chunk) == 4 and chunk.isdigit():
|
||||
try:
|
||||
return datetime.time(int(chunk[:2]), int(chunk[2:]))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
return _read
|
||||
|
||||
|
||||
def _skip_half(raw, part):
|
||||
"""The half this write isn't setting, normalized. An unparseable half
|
||||
becomes '0000' rather than carrying a malformed value back to the
|
||||
device."""
|
||||
chunk = (str(raw or "") + "00000000")[:8]
|
||||
other = chunk[4:8] if part == "start" else chunk[0:4]
|
||||
return other if _skip_time_read("end" if part == "start" else "start")(chunk) else "0000"
|
||||
|
||||
|
||||
def _skip_time_write(part):
|
||||
def _write(value, rep, href=None):
|
||||
raw = rep.get("x.com.samsung.da.periodicSensingSkipTime", "")
|
||||
hhmm = f"{value.hour:02d}{value.minute:02d}"
|
||||
other = _skip_half(raw, part)
|
||||
new = hhmm + other if part == "start" else other + hhmm
|
||||
return ["airlevelcheck", "vs", "0"], {"x.com.samsung.da.periodicSensingSkipTime": new}
|
||||
|
||||
return _write
|
||||
|
||||
|
||||
AIR_LEVEL_CHECK = Capability(
|
||||
href="/airlevelcheck/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="periodic_air_sensing",
|
||||
field="x.com.samsung.da.periodicSensingActivationState",
|
||||
icon="mdi:radar",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
write_fn=_periodic_sensing_write,
|
||||
),
|
||||
# Options come off supportedAutoExeState rather than a typed table,
|
||||
# so an unrecognized fourth value still reaches the user.
|
||||
SelectDesc(
|
||||
key="sensing_mode",
|
||||
field="x.com.samsung.da.autoExeState",
|
||||
options_field="x.com.samsung.da.supportedAutoExeState",
|
||||
translation_key="sensing_mode",
|
||||
icon="mdi:radar",
|
||||
entity_category="config",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["airlevelcheck", "vs", "0"],
|
||||
{"x.com.samsung.da.autoExeState": p},
|
||||
),
|
||||
),
|
||||
# The one field that varies across families: TP1X_DA-AC-AIR (#130)
|
||||
# omits it, so that board runs sensing on a fixed, unexposed
|
||||
# interval.
|
||||
NumberDesc(
|
||||
key="sensing_interval",
|
||||
field="x.com.samsung.da.periodicSensingInterval",
|
||||
icon="mdi:timer-cog",
|
||||
entity_category="config",
|
||||
native_min=1,
|
||||
native_max=60,
|
||||
step=1,
|
||||
unit="min",
|
||||
exists_fn=lambda rep, resources: "x.com.samsung.da.periodicSensingInterval" in rep,
|
||||
value_fn=_interval_minutes,
|
||||
write_fn=_interval_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="periodic_sensing_skip_status",
|
||||
field="x.com.samsung.da.periodicSensingSkipStatus",
|
||||
icon="mdi:sleep",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
write_fn=_skip_status_write,
|
||||
),
|
||||
TimeDesc(
|
||||
key="sensing_skip_start",
|
||||
field="x.com.samsung.da.periodicSensingSkipTime",
|
||||
icon="mdi:clock-start",
|
||||
entity_category="config",
|
||||
value_fn=_skip_time_read("start"),
|
||||
write_fn=_skip_time_write("start"),
|
||||
),
|
||||
TimeDesc(
|
||||
key="sensing_skip_end",
|
||||
field="x.com.samsung.da.periodicSensingSkipTime",
|
||||
icon="mdi:clock-end",
|
||||
entity_category="config",
|
||||
value_fn=_skip_time_read("end"),
|
||||
write_fn=_skip_time_write("end"),
|
||||
),
|
||||
# Read-only status, same keys as range_hood.AIR_LEVEL_CHECK.
|
||||
SensorDesc(
|
||||
key="air_sensing_state",
|
||||
field="x.com.samsung.da.sensingState",
|
||||
icon="mdi:radar",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="last_air_sensing_time",
|
||||
field="x.com.samsung.da.lastSensingTime",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
value_fn=epoch_to_utc,
|
||||
),
|
||||
# 'Kr1' on both dumps -- a region-prefixed, undocumented grade;
|
||||
# stays a raw diagnostic rather than an asserted enum.
|
||||
SensorDesc(
|
||||
key="last_air_sensing_level",
|
||||
field="x.com.samsung.da.lastSensingLevel",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# /humidity/0 and /humidity/vs/0 are empty on both dumps -- covered here
|
||||
# (not globally) since they collide with fridge/AC schemas elsewhere, same
|
||||
# reasoning as airconditioner.py's _AC_IGNORED. The next six hrefs (issue
|
||||
# #130) are the exact same DA-AC- board resources as _AC_IGNORED,
|
||||
# duplicated here rather than promoted to the global list (a possible
|
||||
# follow-up DRY cleanup).
|
||||
COVERAGE = [
|
||||
Capability(href="/humidity/0"),
|
||||
Capability(href="/humidity/vs/0"),
|
||||
Capability(href="/airlevelcheck/vs/0"), # periodic air-quality sensing scheduler plumbing
|
||||
Capability(href="/availablecontrolsets/vs/0"), # opaque hex-encoded control-set bitmap
|
||||
Capability(href="/da/softreset/vs/0"), # soft-reset trigger plumbing
|
||||
Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag
|
||||
Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here)
|
||||
Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob
|
||||
# Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/
|
||||
# useTimeSetting/functionState) -- every field reads its inert default
|
||||
# on the only dump seen (times both '00:00:00', useTimeSetting/
|
||||
# functionState both 'false'). Same "needs a multi-field schedule
|
||||
# editor" treatment as fridge.py's /defrost/reservation/vs/0.
|
||||
# Do-not-disturb/auto-sleep schedule -- every field reads its inert
|
||||
# default on the only dump seen. Needs a multi-field schedule editor,
|
||||
# same as fridge.py's /defrost/reservation/vs/0.
|
||||
Capability(href="/dnd/autosleep/vs/0"),
|
||||
# Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's
|
||||
# convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN
|
||||
# above instead.
|
||||
# Empty on the A-VTWW-TP2-21 dump (issue #151) -- this board's
|
||||
# convenient-mode equivalent lives in WIND_STRENGTH_FAN instead.
|
||||
Capability(href="/mode/convenient/vs/0"),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,12 +49,10 @@ def wh_to_kwh(v):
|
||||
|
||||
|
||||
def parse_iso_utc(raw):
|
||||
"""ISO datetime defaulting to UTC when the string carries no timezone
|
||||
of its own (this integration's convention for other bare ISO datetime
|
||||
fields -- see washer.py's drum-clean-log comment). A few boards do
|
||||
ship a 'Z'/offset suffix (fromisoformat parses that natively since
|
||||
Python 3.11) -- only fill in UTC when parsing left the result naive,
|
||||
rather than unconditionally overwriting whatever offset was parsed."""
|
||||
"""ISO datetime defaulting to UTC when the string carries no timezone of
|
||||
its own. A few boards ship a 'Z'/offset suffix already (fromisoformat
|
||||
parses that natively since Python 3.11) -- only fill in UTC when parsing
|
||||
left the result naive."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
@@ -64,24 +62,42 @@ def parse_iso_utc(raw):
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def filter_usage_percent(rep):
|
||||
"""Filter usage as a percentage of rated capacity. Several families
|
||||
(AC, air purifier) report `filterUsage` as a raw count in
|
||||
`filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain
|
||||
value with a '%' unit would be wrong -- normalize to used/capacity.
|
||||
Returns None when capacity is missing/zero."""
|
||||
used = _num(rep.get("x.com.samsung.da.filterUsage"))
|
||||
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
|
||||
if used is None or not cap:
|
||||
def epoch_to_utc(value):
|
||||
"""Unix epoch seconds -> aware UTC datetime, for boards that report a
|
||||
bare epoch rather than the ISO string parse_iso_utc handles."""
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=UTC)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
return round(used / cap * 100)
|
||||
|
||||
|
||||
def filter_usage_percent(rep):
|
||||
"""Filter usage as a percentage. `filterUsage` is already 0-100 on every
|
||||
family confirmed so far, including ARTIK051_PRAC (issue #330): its own
|
||||
fixture and three live heads all show `filterStatus == 'wash'` at
|
||||
`filterUsage == '100'` regardless of `filterCapacity` (60/224/500 across
|
||||
other families' fixtures), which only holds if `filterUsage` is already
|
||||
a percent -- dividing by capacity again would read that filter as fresh
|
||||
at 20%."""
|
||||
return int_or_none(rep.get("x.com.samsung.da.filterUsage"))
|
||||
|
||||
|
||||
def filter_usage_hours(rep):
|
||||
"""Elapsed filter hours, derived from the percentage and capacity rather
|
||||
than read off `filterUsage` directly -- `filterUsage` is a percent, not
|
||||
an hour count (issue #330). Returns None when capacity is missing/zero."""
|
||||
pct = filter_usage_percent(rep)
|
||||
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
|
||||
if pct is None or not cap:
|
||||
return None
|
||||
return round(pct / 100 * cap, 1)
|
||||
|
||||
|
||||
def normalize_temp_unit(raw, default="°F"):
|
||||
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
|
||||
`default` for any other/missing value. Shared by fridge.py and oven.py,
|
||||
both of which read a per-device unit off a `/temperature*` resource
|
||||
instead of assuming one (see fridge.py's module docstring, issue #7)."""
|
||||
which both read a per-device unit off a `/temperature*` resource
|
||||
instead of assuming one (issue #7)."""
|
||||
raw = (raw or "").strip().upper()
|
||||
if raw.startswith("C"):
|
||||
return "°C"
|
||||
@@ -96,25 +112,17 @@ def _ml_to_l(v):
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted,
|
||||
and any code ending in '_OFF'.
|
||||
"""Join active alarm codes; skip retained rows Samsung leaves as
|
||||
Deleted, and any code ending in '_OFF'.
|
||||
|
||||
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
|
||||
condition clears (see WD7000B diagnostics). Surface only live alarms so
|
||||
HA doesn't stick on a stale ErrorCode.
|
||||
|
||||
Samsung pre-populates this array with one row per alarm *type* the board
|
||||
supports, each carrying its own '<Name>_OFF' placeholder code when that
|
||||
alarm isn't firing -- confirmed across independent device families
|
||||
(ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF,
|
||||
AC_V_0002_OFF all appear in fixtures with no corresponding active
|
||||
condition). An alarm that's actually firing instead reports a plain,
|
||||
unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's
|
||||
AC dump has both a FilterAlarm_OFF placeholder and shows what a live
|
||||
filter alert looks like: code 'FilterAlarm' (no suffix), state
|
||||
'Created'. Range-hood previously special-cased only the literal
|
||||
'ErrorCode_OFF' string in its own stricter helper; this generalizes
|
||||
the same rule to the whole '_OFF' suffix convention.
|
||||
condition clears. Samsung also pre-populates this array with one row
|
||||
per alarm *type* the board supports, each carrying its own
|
||||
'<Name>_OFF' placeholder when that alarm isn't firing -- confirmed
|
||||
across independent families. A firing alarm instead reports a plain,
|
||||
unsuffixed code (FilterAlarm, DoorA_Opened, ...); issue #166 shows both
|
||||
in one dump. Generalizes what range hood used to special-case as just
|
||||
the literal 'ErrorCode_OFF' string.
|
||||
"""
|
||||
if not items or not isinstance(items, list):
|
||||
return "none"
|
||||
@@ -128,18 +136,38 @@ def _active_alarm_codes(items):
|
||||
return ", ".join(codes) if codes else "none"
|
||||
|
||||
|
||||
def hex_pairs(codes):
|
||||
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in an options[] array and return <value>.
|
||||
|
||||
Lives here rather than in laundry.py, which is where it grew, because
|
||||
cloudcourse.py needs it too and laundry.py imports *that* -- so the
|
||||
reverse import would be a module cycle. The coordinator already imports
|
||||
from this module, so nothing about the dependency direction is unusual;
|
||||
it is specifically the laundry/cloudcourse pair that can't reach each
|
||||
other. Anchored at position 0 so 'Course_' never matches
|
||||
'CloudCourse_'/'OneTimeCloudCourse_'.
|
||||
"""
|
||||
for o in options or []:
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
return o.split("_", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def merge_options_field(cached, new_tokens):
|
||||
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
|
||||
x.com.samsung.da.options[]-style array the same way the device itself
|
||||
merges them: match by prefix, replace if present, append if not.
|
||||
|
||||
Confirmed on real hardware (issue #54) that a write only needs to carry
|
||||
the changed token(s), not the whole array -- see laundry.option_write /
|
||||
oven._option_write for the write side. This is the read side of that
|
||||
same fact: coordinator.async_send_command uses it to keep the
|
||||
optimistic cache entry for the written href complete (every sibling
|
||||
option still present) during the write-settle window, since the wire
|
||||
body it applies straight to the cache no longer carries them."""
|
||||
Confirmed on hardware (issue #54) that a write only needs to carry the
|
||||
changed token(s), not the whole array -- see laundry.option_write /
|
||||
oven._option_write for the write side. coordinator.async_send_command
|
||||
uses this read-side counterpart to keep the optimistic cache entry
|
||||
complete during the write-settle window."""
|
||||
merged = list(cached or [])
|
||||
for token in new_tokens or ():
|
||||
if not isinstance(token, str) or "_" not in token:
|
||||
@@ -157,18 +185,15 @@ def merge_options_field(cached, new_tokens):
|
||||
|
||||
def merge_items_field(cached, new_items):
|
||||
"""Merge a partial x.com.samsung.da.items[]-style write (matched by
|
||||
x.com.samsung.da.id) into a cached items array -- the read-side
|
||||
counterpart of merge_options_field above, for the items[] shape instead
|
||||
of the packed options[] shape.
|
||||
x.com.samsung.da.id) into a cached items array -- the items[]
|
||||
counterpart of merge_options_field above.
|
||||
|
||||
Confirmed on hardware that a write only needs to carry the array item
|
||||
with the changed id plus the field(s) being changed; the device merges
|
||||
the rest itself (same fact as the options[] case, different array --
|
||||
see airconditioner._climate_write's vendor temperature write). Fields
|
||||
Confirmed on hardware that a write only needs to carry the item with
|
||||
the changed id plus the field(s) being changed (see
|
||||
airconditioner._climate_write's vendor temperature write). Fields
|
||||
within the matched item are merged, not replaced outright, so a
|
||||
setpoint-only write doesn't wipe current/minimum/maximum/unit from the
|
||||
optimistic cache entry for the settle window. An id with no match in
|
||||
`cached` is appended."""
|
||||
optimistic cache entry. An id with no match in `cached` is appended."""
|
||||
merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])]
|
||||
for new_item in new_items or ():
|
||||
if not isinstance(new_item, dict):
|
||||
@@ -184,22 +209,19 @@ def merge_items_field(cached, new_items):
|
||||
|
||||
|
||||
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
|
||||
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
|
||||
# life of a given board, so reading them from the /device/0 seed (no dedicated
|
||||
# poll_tier) is enough.
|
||||
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for
|
||||
# the life of a board, so reading it from the /device/0 seed is enough.
|
||||
_SETINFO_HREF = "/wm/setinfo/vs/0"
|
||||
_POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff"
|
||||
_WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC"
|
||||
|
||||
|
||||
def model_allows_power_on_off(resources: dict) -> bool:
|
||||
"""True unless firmware explicitly declares remote power on/off unsupported.
|
||||
|
||||
`/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry
|
||||
boards (washers/dryers): `/power/0` and `/power/vs/0` still report state,
|
||||
but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps
|
||||
the writable switch -- current behavior.
|
||||
"""
|
||||
"""True unless firmware explicitly declares remote power on/off
|
||||
unsupported. isModelSettingPowerOnOff is "false" on many laundry
|
||||
boards: /power/0 and /power/vs/0 still report state, but CoAP writes
|
||||
are ignored. Absent setinfo (non-laundry families) keeps the writable
|
||||
switch."""
|
||||
setinfo = resources.get(_SETINFO_HREF)
|
||||
if setinfo is None:
|
||||
return True
|
||||
@@ -210,13 +232,11 @@ def model_allows_power_on_off(resources: dict) -> bool:
|
||||
|
||||
|
||||
def model_setting_without_sc(resources: dict) -> bool:
|
||||
"""True when firmware declares settings writable without Smart Control.
|
||||
|
||||
`/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers
|
||||
that accept temperature/spin/cycle-option writes while remote control is
|
||||
off. Cycle start/pause/stop still need Smart Control on those boards --
|
||||
the flag name is settings-specific, not a blanket remote-control bypass.
|
||||
"""
|
||||
"""True when firmware declares settings writable without Smart
|
||||
Control. isModelSettingWithoutSC is "true" on washers/dryers that
|
||||
accept temperature/spin/cycle-option writes while remote control is
|
||||
off; cycle start/pause/stop still need Smart Control on those
|
||||
boards."""
|
||||
setinfo = resources.get(_SETINFO_HREF) or {}
|
||||
return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true"
|
||||
|
||||
@@ -231,11 +251,10 @@ def _power_sensor_exists(rep, resources):
|
||||
|
||||
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,
|
||||
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
|
||||
same resource shape against the same {type, sensor_type} keys."""
|
||||
item is `{type, value: [...]}`; `index` picks which slot to read
|
||||
(index 0 is the raw measurement on every 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."""
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
@@ -250,26 +269,18 @@ def sensor_item_value(items, sensor_type, index=0):
|
||||
return None
|
||||
|
||||
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
|
||||
#
|
||||
# These three controls exist as both a standard OCF resource (/power/0,
|
||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource
|
||||
# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its
|
||||
# firmware migrates onto the OCF standard model. Prefer the OCF-standard href
|
||||
# when the device exposes it; the '-vs' href (a string-encoded duplicate for
|
||||
# these three) binds only when the generic href is absent, via match_fn. Older
|
||||
# firmware has only the '-vs' resource, so the pair is behaviour-identical to a
|
||||
# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs
|
||||
# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket
|
||||
# choice. Every device registry lists both caps of each pair.
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
|
||||
# control: each exists as both a standard OCF resource (/power/0,
|
||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
|
||||
# resource (/power/vs/0, x.com.samsung.da.power), since Samsung advertises
|
||||
# both while its firmware migrates onto the OCF standard model. Prefer the
|
||||
# OCF-standard href when present; the '-vs' href binds only when it's
|
||||
# absent, via match_fn. Older firmware has only the '-vs' resource. See
|
||||
# the adding-device-support skill's "OCF-standard vs vendor" section.
|
||||
# Every device registry lists both caps of each pair.
|
||||
|
||||
POWER_GENERIC = Capability(
|
||||
href="/power/0",
|
||||
# Neither href of this pair carried a poll_tier before (issue #56's
|
||||
# follow-up), so power state only ever refreshed on the once-per-30s
|
||||
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
|
||||
# hrefs get -- the same "signal drives real-time state, but sat in the
|
||||
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# Writable when firmware allows remote power; otherwise a read-only
|
||||
@@ -319,16 +330,14 @@ POWER_VS_FALLBACK = Capability(
|
||||
KIDS_LOCK_GENERIC = Capability(
|
||||
href="/kidslock/0",
|
||||
entities=(
|
||||
# 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.
|
||||
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183): HA's
|
||||
# switch platform never honored SwitchDesc's device_class='lock'
|
||||
# ('outlet'/'switch' only), leaving a plain switch whose 'On' meant
|
||||
# different things on different boards. As a BinarySensorDesc with
|
||||
# device_class='lock', both surfaces read with the same polarity
|
||||
# ('On' = open/unlocked, per HA convention); value_fn here inverts
|
||||
# the wire value to match (value=False on /kidslock/0 means kids
|
||||
# lock is NOT active).
|
||||
BinarySensorDesc(
|
||||
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
|
||||
),
|
||||
@@ -339,16 +348,11 @@ KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
href="/kidslock/vs/0",
|
||||
match_fn=lambda rep, resources: "/kidslock/0" not in resources,
|
||||
entities=(
|
||||
# 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'.
|
||||
# Read-only, not a SwitchDesc (issues #181/#183): the old write
|
||||
# side wrote 'Enable', a value no dump ever reports back (every one
|
||||
# is 'Ready' or 'Run'), and #181's reporter confirmed writing the
|
||||
# correct value ('Run') still 4.05s -- genuinely read-only on this
|
||||
# hardware. Polarity matches KIDS_LOCK_GENERIC ('On' = unlocked).
|
||||
BinarySensorDesc(
|
||||
key="child_lock",
|
||||
field="x.com.samsung.da.kidsLock",
|
||||
@@ -361,15 +365,11 @@ KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
|
||||
def remote_control_enabled(resources: dict) -> bool:
|
||||
"""Single source of truth for the /remotectrl on/off signal, mirroring
|
||||
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence
|
||||
below. Used both to render the read-only Smart Control binary_sensor
|
||||
(via those two descriptors) and, from coordinator.async_send_command,
|
||||
to block writes outright when remote control is off. Both hrefs are
|
||||
poll_tier='warm' below so that gate reads recent state (subscribed
|
||||
when observe is live, subpolled every ~6s otherwise) rather than a
|
||||
once-per-30s cold summary poll. True (assume enabled) when neither
|
||||
href is present -- most device types don't report this capability
|
||||
at all."""
|
||||
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field precedence. Used both
|
||||
to render the read-only Smart Control binary_sensor and, from
|
||||
coordinator.async_send_command, to block writes when remote control is
|
||||
off. True (assume enabled) when neither href is present -- most device
|
||||
types don't report this capability at all."""
|
||||
generic = resources.get("/remotectrl/0")
|
||||
if generic is not None:
|
||||
return bool(generic.get("value"))
|
||||
@@ -434,26 +434,21 @@ ALARMS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and
|
||||
# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500',
|
||||
# unchanged across off/idle/running. clamp_power floors it to a misleading
|
||||
# "0 W" that reads as a real idle measurement. Gate power_watts out when the
|
||||
# sentinel is seen -- but only then, so a device reporting a real value (e.g. a
|
||||
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
|
||||
# least one washer model; the exists_fn makes that explicit rather than relying
|
||||
# on the generic field-presence gate.
|
||||
# instantaneousPower is a dead field on DA_WM_-class laundry dumps and
|
||||
# dishwashers: the literal sentinel '-500', unchanged across off/idle/
|
||||
# running. clamp_power would floor it to a misleading "0 W". Gate
|
||||
# power_watts out when the sentinel is seen, but only then, so a device
|
||||
# reporting a real value (e.g. a fridge's 93 W) still shows it (issue #6).
|
||||
_DEAD_INSTANTANEOUS_POWER = "-500"
|
||||
|
||||
ENERGY_METER = Capability(
|
||||
href="/energy/consumption/vs/0",
|
||||
entities=(
|
||||
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
|
||||
# an explicit exists_fn otherwise bypasses it, which would drop the
|
||||
# entity when /device/0 returns a not-yet-fetched stub. A genuinely
|
||||
# empty {} rep is NOT a stub -- it's the device's confirmed (if empty)
|
||||
# answer, so it falls through to the normal field/sentinel checks like
|
||||
# any populated rep. On a populated rep, hide power only for the dead
|
||||
# sentinel or an absent field.
|
||||
# is_stub_rep(rep) keeps the stub carve-out (see
|
||||
# entity._is_included): an explicit exists_fn otherwise bypasses
|
||||
# it and would drop the entity when /device/0 returns a
|
||||
# not-yet-fetched stub. A genuinely empty {} rep is NOT a stub, so
|
||||
# it still falls through to the normal field/sentinel checks.
|
||||
SensorDesc(
|
||||
key="power_watts",
|
||||
field="x.com.samsung.da.instantaneousPower",
|
||||
@@ -481,11 +476,7 @@ ENERGY_METER = Capability(
|
||||
),
|
||||
),
|
||||
# cumulativeConsumption is a second, independently-varying running
|
||||
# total alongside cumulativePower -- some fridges (issue #26) report
|
||||
# both. Self-gates off where only cumulativePower is present. The
|
||||
# `is_stub_rep(rep) or` keeps the same stub carve-out as power_watts/
|
||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
||||
# entity if setup happens to land on a not-yet-fetched stub.
|
||||
# total some fridges (issue #26) report alongside cumulativePower.
|
||||
SensorDesc(
|
||||
key="power_energy_kwh",
|
||||
field="x.com.samsung.da.cumulativeConsumption",
|
||||
@@ -498,8 +489,8 @@ ENERGY_METER = Capability(
|
||||
),
|
||||
),
|
||||
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
||||
# baseline -- present on some models (issue #21/#27), absent on
|
||||
# others (issue #20/#26).
|
||||
SensorDesc(
|
||||
key="energy_saved_kwh",
|
||||
field="x.com.samsung.da.cumulativeSavedPower",
|
||||
@@ -511,9 +502,8 @@ ENERGY_METER = Capability(
|
||||
is_stub_rep(rep) or "x.com.samsung.da.cumulativeSavedPower" in rep
|
||||
),
|
||||
),
|
||||
# Monthly billing-cycle totals -- the completed prior month and the
|
||||
# in-progress current month. Not ever-increasing (each resets at
|
||||
# month boundary), so no state_class.
|
||||
# Monthly billing-cycle totals -- completed prior month and
|
||||
# in-progress current month. Not ever-increasing, so no state_class.
|
||||
SensorDesc(
|
||||
key="energy_last_month_kwh",
|
||||
field="x.com.samsung.da.monthlyConsumption",
|
||||
@@ -574,26 +564,21 @@ WATER_FILTER = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
|
||||
# additional level(s) the device offers ('1' meaning just "on" on most
|
||||
# hardware, but multi-level boards have been reported). Verified cross-family:
|
||||
# fridge (issue #21) and washer (issue #40) both expose this href.
|
||||
#
|
||||
# supportedAiLevel is a single-entry list on most captured hardware, where a
|
||||
# select would offer only one real choice against an implicit "off" -- shown
|
||||
# as a switch instead. '0' itself is never in supportedAiLevel but has been
|
||||
# observed live as the off value of aiLevel, so the select synthesizes it
|
||||
# back in as an explicit option rather than leaving no way to turn off.
|
||||
#
|
||||
# No translation_key: aiLevel's values are plain digit strings, and
|
||||
# select.py's _display() already renders an untranslated numeric string
|
||||
# as-is -- there's nothing a catalog entry adds that's worth maintaining
|
||||
# against an unknown, growing number of future levels.
|
||||
# AI energy-saving level -- '0' is off, supportedAiLevel lists the
|
||||
# additional level(s) offered ('1' meaning just "on" on most hardware,
|
||||
# multi-level on some). Verified cross-family: fridge (issue #21) and
|
||||
# washer (issue #40). Most hardware's supportedAiLevel is a single-entry
|
||||
# list, so a select there would offer only one real choice against an
|
||||
# implicit "off" -- shown as a switch instead; '0' is never in
|
||||
# supportedAiLevel but is the observed off value, so the select
|
||||
# synthesizes it back in as an explicit option. No translation_key:
|
||||
# aiLevel's values are plain digit strings, and select.py already renders
|
||||
# an untranslated numeric string as-is.
|
||||
|
||||
|
||||
def _ai_energy_supported_levels(rep):
|
||||
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
|
||||
be len()-checked as if it were a list."""
|
||||
"""supportedAiLevel as a list -- a stray scalar must not be
|
||||
len()-checked as if it were one."""
|
||||
sl = rep.get("supportedAiLevel")
|
||||
return list(sl) if isinstance(sl, (list, tuple)) else []
|
||||
|
||||
@@ -618,21 +603,15 @@ AI_ENERGY_LEVEL = Capability(
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# No is_stub_rep carve-out on either side, unlike most exists_fn
|
||||
# gates in this file -- entity creation only ever runs once, against
|
||||
# whichever snapshot happens to be current the moment platforms are
|
||||
# set up (see entity._is_included / __init__.py's
|
||||
# async_config_entry_first_refresh-before-forward-entry-setups
|
||||
# ordering), while flatten() re-evaluates exists_fn every poll
|
||||
# against live data. Both descriptors share key='ai_energy_level',
|
||||
# so if a stub carve-out let one of them win at setup time while the
|
||||
# other wins once real data lands, flatten() would feed the
|
||||
# instantiated entity a value shaped for the other platform (e.g. a
|
||||
# bool into a Select). Requiring real, populated data on both sides
|
||||
# keeps the entity-creation decision and the live-value decision in
|
||||
# permanent agreement -- the cost is this entity doesn't appear
|
||||
# until a reload if the device's very first poll stubs this
|
||||
# cold-tier href, the same reload already required to fix which
|
||||
# platform got picked in that case.
|
||||
# gates in this file: entity creation runs once against whichever
|
||||
# snapshot is current at platform setup, while flatten() re-checks
|
||||
# exists_fn every poll against live data. Both descriptors share
|
||||
# key='ai_energy_level' -- a stub carve-out could let one win at
|
||||
# setup and the other win once real data lands, feeding the
|
||||
# instantiated entity a value shaped for the other platform.
|
||||
# Requiring populated data on both sides keeps the two decisions in
|
||||
# permanent agreement, at the cost of the entity not appearing
|
||||
# until a reload if the first poll stubs this cold-tier href.
|
||||
SwitchDesc(
|
||||
key="ai_energy_level",
|
||||
field="aiLevel",
|
||||
@@ -708,37 +687,27 @@ SELF_CHECK = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-family bundles, unpacked into every by_type registry's _build([...])
|
||||
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
|
||||
# discover() only binds a capability whose href is actually present in a
|
||||
# given device's resource dump, so listing one here for a family that
|
||||
# doesn't expose the href is a no-op, not a phantom entity -- see the
|
||||
# adding-device-support skill's coverage-discipline section.
|
||||
# call the same way ignored.IGNORED is. discover() only binds a capability
|
||||
# whose href is actually present in a given device's dump, so listing one
|
||||
# here for a family that doesn't expose the href is a no-op, not a phantom
|
||||
# entity -- see the adding-device-support skill's coverage-discipline
|
||||
# section.
|
||||
#
|
||||
# UNIVERSAL holds every capability with no known family that both (a) has
|
||||
# the href and (b) needs to model it some other way -- broadening one of
|
||||
# these to a new family is a safe, harmless guess (issue #40's AI energy
|
||||
# level: 2 of 6 families confirmed, blanket-added everywhere else).
|
||||
# UNIVERSAL holds every capability with no known family that both has the
|
||||
# href and needs to model it some other way.
|
||||
#
|
||||
# POWER is kept separate -- airconditioner is the one family that opts out
|
||||
# of it entirely. Canonical reason (see by_type/airconditioner.py and its
|
||||
# test for pointers back here, not restatements): AC's climate entity
|
||||
# already owns /power/0 and /power/vs/0 via bare, no-entity Capability
|
||||
# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
||||
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
|
||||
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
|
||||
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
|
||||
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
|
||||
# in UNIVERSAL.
|
||||
# POWER is kept separate: airconditioner opts out of it entirely, since
|
||||
# its climate entity already owns /power/0 and /power/vs/0 via bare
|
||||
# no-entity Capability objects (airconditioner.COVERAGE), and a second
|
||||
# real cap on the same href would make _build() raise (see
|
||||
# by_type/airconditioner.py). Kids-lock/remote-control have no such
|
||||
# conflict, so they stay in UNIVERSAL.
|
||||
#
|
||||
# Airconditioner also partially opts out of UNIVERSAL itself, not just
|
||||
# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by
|
||||
# board generation, so by_type/airconditioner.py excludes just that one
|
||||
# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`)
|
||||
# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in
|
||||
# its place -- every other registry still unpacks UNIVERSAL wholesale.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Airconditioner also partially opts out of UNIVERSAL itself: issue #193
|
||||
# needs ENERGY_METER's cumulativePower scale to differ by board
|
||||
# generation, so by_type/airconditioner.py excludes just that one member
|
||||
# and substitutes its own ENERGY_METER_GENERIC/ENERGY_METER_LEGACY.
|
||||
|
||||
UNIVERSAL = (
|
||||
ALARMS,
|
||||
|
||||
@@ -15,8 +15,8 @@ from .common import int_or_none
|
||||
|
||||
def _first_mode(rep):
|
||||
"""Representative scalar for the operating-mode select. `modes` is a
|
||||
single-element list on every dump seen so far, mirroring
|
||||
airconditioner._first_mode's handling of the same field shape."""
|
||||
single-element list on every dump seen, mirroring
|
||||
airconditioner._first_mode."""
|
||||
modes = rep.get("x.com.samsung.da.modes")
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
@@ -40,14 +40,11 @@ MODE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Target humidity is this device's primary control (the issue-#88 dump's
|
||||
# equivalent of a thermostat setpoint). No min/max range field is present in
|
||||
# any dump seen so far -- native_min/native_max are deliberately left unset
|
||||
# so the number entity falls back to HA's own 0-100 default, the natural
|
||||
# bound for a percentage field, rather than a bound guessed from one unit's
|
||||
# spec sheet (see the adding-device-support skill's "never hard-code the one
|
||||
# dump's values" section). Step comes live from the device's own `increment`
|
||||
# field.
|
||||
# Target humidity is this device's primary control (issue #88's equivalent
|
||||
# of a thermostat setpoint). No min/max range field is present in any dump
|
||||
# seen, so native_min/native_max are left unset, falling back to HA's own
|
||||
# 0-100 default rather than a bound guessed from one unit's spec sheet.
|
||||
# Step comes live from the device's own `increment` field.
|
||||
HUMIDITY = Capability(
|
||||
href="/humidity/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -77,15 +74,13 @@ HUMIDITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Water-tank ambient light (issues #271/#231, TP1X_DA_AC_DHM_01001_0000):
|
||||
# on/off, color, and brightness are three independent controls on this one
|
||||
# resource. `waterfullAlarmStatus` differs between the two dumps that
|
||||
# reported this href (On vs. Off) so it's a real live flag, not a constant --
|
||||
# but its exact meaning (tank actually full vs. the chime feature merely
|
||||
# enabled) isn't confirmed by either dump alone, and /alarms/vs/0's
|
||||
# alarm_code already surfaces a live WaterTankFull condition when one fires
|
||||
# (see common._active_alarm_codes), so this is exposed read-only as a plain
|
||||
# diagnostic value rather than guessed at as a binary_sensor.
|
||||
# Water-tank ambient light (issues #271/#231): on/off, color, and
|
||||
# brightness are three independent controls on this one resource.
|
||||
# `waterfullAlarmStatus` differs between the two dumps that reported this
|
||||
# href, so it's a real live flag, but its exact meaning (tank full vs. the
|
||||
# chime feature merely enabled) isn't confirmed, and /alarms/vs/0 already
|
||||
# surfaces a live WaterTankFull condition -- exposed read-only as a plain
|
||||
# diagnostic rather than guessed at as a binary_sensor.
|
||||
WATERTANK_LIGHTING = Capability(
|
||||
href="/watertank/lighting/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -131,13 +126,10 @@ WATERTANK_LIGHTING = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state
|
||||
# or no documented write contract, following the same 'don't guess' rule as
|
||||
# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in
|
||||
# the global ignored.IGNORED since some of these hrefs collide with other
|
||||
# families' schemas.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable
|
||||
# state, following the same rule as airconditioner._AC_IGNORED (same
|
||||
# DA_AC_ board family). Not in the global ignored.IGNORED since some hrefs
|
||||
# collide with other families' schemas.
|
||||
_DHM_IGNORED = [
|
||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
|
||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||
|
||||
@@ -41,26 +41,16 @@ DISHWASHER_SETTINGS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
|
||||
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
|
||||
# same options array (shared laundry.bool_option_switch, same options[]
|
||||
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
|
||||
# use). Course display names live in translations under
|
||||
# entity.select.dishwasher_cycle (see laundry.cycle_select).
|
||||
# /course/vs/0 -- cycle selection (shared laundry.cycle_select) plus the
|
||||
# dishwasher-only StormWashZone / AutoDoorRelease toggles riding in the same
|
||||
# options array (shared laundry.bool_option_switch). Course display names
|
||||
# live in translations under entity.select.dishwasher_cycle.
|
||||
#
|
||||
# '83'/'86' were transposed in that catalog until issue #226: both the
|
||||
# original DW9000F-class fixture this table was built from and the issue
|
||||
# #226 reporter's board report the identical DeviceType_0812 (a real
|
||||
# per-board-generation id also seen on unrelated washer/dryer fixtures, so
|
||||
# this is one shared course table, not a Table_02/Table_03-style generation
|
||||
# split), and the original fixture's own live editCourseList
|
||||
# ('EditCourseList_0E07908683848D808E8F') puts '86' and '83' back to back at
|
||||
# positions 4-5 -- exactly the kind of adjacent pair a manual screenshot
|
||||
# transcription slips on. The reporter's live confirmation (selecting
|
||||
# 'Normal' ran the physical Express 60 program and vice versa) settles
|
||||
# which way: '86' is Express 60, '83' is Normal.
|
||||
# ---------------------------------------------------------------------------
|
||||
# '83'/'86' were transposed in that catalog until issue #226: the original
|
||||
# fixture's own live editCourseList puts them back to back, exactly the
|
||||
# kind of adjacent pair a manual screenshot transcription slips on. The
|
||||
# reporter's live confirmation (selecting 'Normal' ran the physical Express
|
||||
# 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
|
||||
|
||||
CYCLE_OPTIONS = Capability(
|
||||
href="/course/vs/0",
|
||||
|
||||
@@ -38,27 +38,26 @@ DRYER_SETTINGS = Capability(
|
||||
)
|
||||
|
||||
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
|
||||
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
|
||||
# an RMW on the options array). Course display names live in translations
|
||||
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
|
||||
# 2026-05-29). Codes '01' Normal and '06' Time dry were confirmed on a
|
||||
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
|
||||
# appliance and reading back the raw code from the entity's state (issue
|
||||
# #80). Codes '51' Eco Cotton, '53' AI Dry+, and '4e' Self Dry were
|
||||
# confirmed the same way on a DV90DG6845LHU5 (issue #244). The
|
||||
# /st/dryercourse/vs/0 resource re-encodes the same selected course and is
|
||||
# ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 is ignored
|
||||
# for washers.
|
||||
# laundry.cycle_select. Course display names live in translations under
|
||||
# entity.select.dryer_cycle (Table_03, DV5000-class). Codes '01' Normal and
|
||||
# '06' Time dry were confirmed on a DVE50A8600V/A3 by selecting each cycle
|
||||
# on the appliance and reading back the raw code (issue #80); '51' Eco
|
||||
# Cotton, '53' AI Dry+, and '4e' Self Dry the same way on a DV90DG6845LHU5
|
||||
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
|
||||
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
|
||||
#
|
||||
# dryer_cycle_table_00 is a separate, older course-code family reported by
|
||||
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
|
||||
# selected each cycle on the appliance and read back the resulting raw
|
||||
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
|
||||
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
|
||||
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
|
||||
#
|
||||
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
||||
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
||||
# options[] array -- see laundry.drum_clean_cycles_remaining/
|
||||
# drum_clean_last_cleaned's docstrings for the field contract, including
|
||||
# the dryer-specific '|'-joined multi-entry DrumCleanLog_ shape. No
|
||||
# separate heat-exchanger-clean tracking was found on either dump #258
|
||||
# supplied (DV90BB7445GES7, DV91T6440LE/SA) -- no HeatExchanger*-prefixed
|
||||
# token, nor any other options[] entry that looks like a second maintenance
|
||||
# counter -- so if the Samsung app surfaces that reminder for these units,
|
||||
# drum_clean_last_cleaned. No separate heat-exchanger-clean tracking was
|
||||
# found on either dump #258 supplied, so if the app surfaces that reminder,
|
||||
# it isn't computed from anything this integration can read locally.
|
||||
DRYER_COURSE = Capability(
|
||||
href="/course/vs/0",
|
||||
|
||||
@@ -9,11 +9,10 @@ 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 --
|
||||
zone1 has no HA platform with matching semantics (a leaving-water-
|
||||
temperature setpoint, not a thermostat with HVAC modes), 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 entity (see DHW below),
|
||||
following the same primary-resource-plus-sibling-reads pattern as
|
||||
airconditioner.py's CLIMATE/climate.py.
|
||||
|
||||
@@ -35,8 +34,7 @@ def _num(v):
|
||||
|
||||
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."""
|
||||
list on every dump seen, mirroring airconditioner._first_mode."""
|
||||
modes = rep.get("x.com.samsung.da.modes")
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
@@ -48,14 +46,9 @@ def _temp_unit(rep):
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""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 bound with an invented default, silently wrong."""
|
||||
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)
|
||||
@@ -102,8 +95,8 @@ ZONE_MODE = Capability(
|
||||
)
|
||||
|
||||
# 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.
|
||||
# room setpoint, not a literal water temperature -- 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",
|
||||
@@ -134,12 +127,10 @@ ZONE_TEMPERATURE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# 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.
|
||||
# Canonical dhw resource hrefs. water_heater.py binds 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.
|
||||
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
|
||||
@@ -150,8 +141,7 @@ 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."""
|
||||
airconditioner._climate_write, across the dhw loop's three resources."""
|
||||
kind, value = payload
|
||||
if kind == "power":
|
||||
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
||||
@@ -174,19 +164,15 @@ DHW = Capability(
|
||||
|
||||
# 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).
|
||||
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS).
|
||||
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.
|
||||
# HA core's smartthings integration wires this same Samsung capability up
|
||||
# to WaterHeaterEntityFeature.AWAY_MODE, so the divergence is worth
|
||||
# stating: /option/outgoing/vs/0 is device-wide (one `away` flag covering
|
||||
# zone1 too, with no dhw-scoped sibling href). Hanging it off the DHW card
|
||||
# would present a device-wide setting as hot-water-only.
|
||||
AWAY_MODE = Capability(
|
||||
href="/option/outgoing/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -205,13 +191,9 @@ AWAY_MODE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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-scoped coverage: opaque vendor plumbing or resources with no
|
||||
# confirmed write contract on this dump. Not in the global ignored.IGNORED
|
||||
# since these are EHS-only shapes needing their own verification elsewhere.
|
||||
_EHS_IGNORED = [
|
||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
|
||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
|
||||
|
||||
Temperature unit is read live from each resource, not assumed: the RF9000B
|
||||
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
|
||||
but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the
|
||||
exact same resources — the device tells you which one it is, it's just never
|
||||
been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are
|
||||
NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path
|
||||
from href at write time.
|
||||
dump reports Fahrenheit, but a TP1X_REF_21K dump (issue #7) reports the same
|
||||
fields in Celsius for the exact same resources -- the device tells you which
|
||||
one it is. See `_temp_unit`/`_temp_item_unit` below.
|
||||
|
||||
Multi-instance note: the two door resources (/door/cooler/0 and
|
||||
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
|
||||
/icemaker/two/vs/0) use named path segments, so they are modelled via
|
||||
pattern capabilities that auto-derive distinct entity keys from href segments.
|
||||
Multi-instance note: the two door resources (/door/cooler/0,
|
||||
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0,
|
||||
/icemaker/two/vs/0) use named path segments, so they are modeled via
|
||||
pattern capabilities that auto-derive distinct entity keys from href
|
||||
segments.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -27,13 +25,11 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
TimeDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .common import int_or_none, normalize_temp_unit
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in translations/en.json,
|
||||
# keyed by the lowercased raw device value — select.py and SensorDesc.options
|
||||
# normalize to lowercase for HA's translation lookup and map back to this
|
||||
# original casing before writing to the device.
|
||||
# ice-making-status enums below live in translations/en.json, keyed by the
|
||||
# lowercased raw device value.
|
||||
|
||||
|
||||
def _int(v):
|
||||
@@ -44,14 +40,13 @@ def _int(v):
|
||||
|
||||
|
||||
def _temp_unit(rep):
|
||||
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to
|
||||
°F (this module's original assumption) if the device omits the field."""
|
||||
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults
|
||||
to °F if the device omits the field."""
|
||||
return normalize_temp_unit(rep.get("units"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Temperature (generic -- covers /temperature/current/* and
|
||||
# /temperature/desired/*)
|
||||
|
||||
TEMP_CURRENT_GENERIC = Capability(
|
||||
href=None,
|
||||
@@ -74,14 +69,10 @@ TEMP_CURRENT_GENERIC = Capability(
|
||||
|
||||
|
||||
def _temp_setpoint_write(p, rep, href=None, resources=None):
|
||||
"""Write temperature — prefer vendor /temperatures/vs/0 when available,
|
||||
fall back to direct OCF /temperature/desired/ write otherwise.
|
||||
|
||||
Samsung fridges expose both OCF-standard /temperature/desired/* and vendor
|
||||
/temperatures/vs/0. On some models only the vendor path commits the change;
|
||||
on others both work. Using the vendor path when present is always correct.
|
||||
Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler.
|
||||
"""
|
||||
"""Prefer vendor /temperatures/vs/0 when present, else the direct OCF
|
||||
/temperature/desired/ write -- on some models only the vendor path
|
||||
commits. Item IDs follow the Samsung convention: "0" = Freezer,
|
||||
"1" = Fridge/Cooler."""
|
||||
if not href:
|
||||
return None
|
||||
if resources and "/temperatures/vs/0" in resources:
|
||||
@@ -127,18 +118,12 @@ TEMP_SETPOINT = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only")
|
||||
# fridges report no /temperature/current|desired/* pair at all (this href
|
||||
# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/'
|
||||
# or '/temperature/desired/' prefixes), only this one vendor resource that
|
||||
# bundles the live desired value together with the *specific* values the
|
||||
# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue
|
||||
# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid
|
||||
# setpoints on this model -- so a NumberDesc with a min/max/step would let a
|
||||
# user pick an unsupported value; modeled as a select reading its own live
|
||||
# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discrete cooler setpoint (issue #186): single-door "cooler only" fridges
|
||||
# report no /temperature/current|desired/* pair, only this vendor resource
|
||||
# bundling the live desired value with the specific values the unit
|
||||
# accepts. supportedList (e.g. ['1','2','3','4','7']) is not a contiguous
|
||||
# range, so this is a select reading its own live options rather than a
|
||||
# NumberDesc with min/max/step.
|
||||
|
||||
|
||||
def _definite_cooler_write(p, rep, href=None):
|
||||
@@ -171,11 +156,8 @@ def _definite_freezer_write(p, rep, href=None):
|
||||
)
|
||||
|
||||
|
||||
# Freezer half of the same discrete-setpoint pattern (issue #229): a
|
||||
# fridge/freezer combo reporting no /temperature/current|desired/freezer
|
||||
# pair, only this bundled vendor resource -- identical shape to
|
||||
# DEFINITE_TEMPERATURE_COOLER above (down to the field names), just negative
|
||||
# supportedList values (e.g. ['-23','-21','-19','-17','-15']).
|
||||
# Freezer half of the same discrete-setpoint pattern (issue #229) -- same
|
||||
# shape as DEFINITE_TEMPERATURE_COOLER, negative supportedList values.
|
||||
DEFINITE_TEMPERATURE_FREEZER = Capability(
|
||||
href="/temperature/definite/freezer/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -191,10 +173,6 @@ DEFINITE_TEMPERATURE_FREEZER = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icemaker nighttime quiet mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ICEMAKER_NIGHTTIME = Capability(
|
||||
href="/icemaker/nighttime/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -213,18 +191,13 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
|
||||
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
|
||||
# Icemaker (generic -- covers /icemaker/one/vs/0, /icemaker/two/vs/0).
|
||||
# /icemaker/status/vs/0 is an exact-href cap and binds first;
|
||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
||||
#
|
||||
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") -- read via name_field, reaching the translated name as the
|
||||
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
|
||||
# ice makers are independent on/off toggles that can both be enabled at once
|
||||
# (issue #27), so they stay separate entities rather than a single ice-type
|
||||
# select, but users still want them labeled with the device's own names.
|
||||
# ---------------------------------------------------------------------------
|
||||
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these
|
||||
# two makers can both be enabled at once (issue #27), so they stay
|
||||
# separate entities rather than one ice-type select.
|
||||
|
||||
|
||||
def _icemaker_write(field):
|
||||
@@ -273,10 +246,6 @@ ICEMAKER_GENERIC = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Door alert tone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DOOR_ALERT = Capability(
|
||||
href="/settings/sound/alert/door/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -296,9 +265,38 @@ DOOR_ALERT = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status/lock — auto door opener and fridge sound
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Internal deodorizing filter (issue #318, TP1X_REF_21K). Same
|
||||
# filterUsage/filterStatus field pair as common.WATER_FILTER, but
|
||||
# filterUsage here is already a 0-100 percentage with no filterCapacity to
|
||||
# divide by (confirmed by filterStatus=="wash" at filterUsage=="100") --
|
||||
# 'air_'-prefixed keys so a fridge with both a water and an air filter gets
|
||||
# two distinct entities rather than a unique_id collision.
|
||||
AIR_FILTER = Capability(
|
||||
href="/filter/airdustfilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="air_filter_usage",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="air_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _status_lock_write(field):
|
||||
@@ -328,21 +326,159 @@ STATUS_LOCK = Capability(
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_status_lock_write("x.com.samsung.da.device.sound"),
|
||||
),
|
||||
# Auto Door Open's own voice/sound feedback toggles (issue #328,
|
||||
# TP1X_REF_21K family) -- siblings of auto_door_opener above, not
|
||||
# duplicates of fridge_sound (device.sound is the general appliance
|
||||
# beep, these two gate ado's own prompts). Only seen on the
|
||||
# auto-door-equipped variants (single/kimchi/winecellar), not the
|
||||
# earlier TP1X_REF_21K dumps that predate that feature -- gated on
|
||||
# each field's own presence rather than assumed universal.
|
||||
SwitchDesc(
|
||||
key="auto_door_voice_control",
|
||||
field="x.com.samsung.da.ado.voicecontrol",
|
||||
icon="mdi:microphone",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_status_lock_write("x.com.samsung.da.ado.voicecontrol"),
|
||||
exists_fn=lambda rep, resources: "x.com.samsung.da.ado.voicecontrol" in rep,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="auto_door_sound_control",
|
||||
field="x.com.samsung.da.ado.soundcontrol",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_status_lock_write("x.com.samsung.da.ado.soundcontrol"),
|
||||
exists_fn=lambda rep, resources: "x.com.samsung.da.ado.soundcontrol" in rep,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defrost delay / active-defrost status
|
||||
# Auto Door Open's paired delay setting (issue #328, TP1X_REF_21K family):
|
||||
# how long the door stays held open before it re-closes, on/off itself is
|
||||
# STATUS_LOCK.auto_door_opener above. Same discrete-options-select shape as
|
||||
# DEFINITE_TEMPERATURE_COOLER/FREEZER. Unit unconfirmed -- no supportedList
|
||||
# field states it and the report carried no app screenshot -- so this is a
|
||||
# raw-code select rather than a guessed seconds/minutes NumberDesc.
|
||||
|
||||
|
||||
def _auto_door_timer_write(p, rep, href=None):
|
||||
return ["autodoor", "timer", "vs", "0"], {"x.com.samsung.da.time.desired": p}
|
||||
|
||||
|
||||
AUTO_DOOR_TIMER = Capability(
|
||||
href="/autodoor/timer/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="auto_door_timer",
|
||||
field="x.com.samsung.da.time.desired",
|
||||
icon="mdi:timer-outline",
|
||||
translation_key="auto_door_timer",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.time.supportedOptions",
|
||||
write_fn=_auto_door_timer_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# /autodoor/<variant>/vs/0 -- one per fridge sub-type sharing the Auto Door
|
||||
# Open feature (single-door, kimchi, winecellar seen so far; issue #328).
|
||||
# Each reports only x.com.samsung.da.ado.openOptions, declaring which open
|
||||
# styles that variant supports -- every dump seen so far carries exactly
|
||||
# one option ('Single') with no paired desired/current field to make a
|
||||
# choice against, the same "no real choice to expose yet" shape as
|
||||
# ignored.py's /mode/0. Bound with no entities to record coverage; revisit
|
||||
# if a device ever reports more than one option.
|
||||
#
|
||||
# A pattern cap rather than one entry per variant: match_fn (not just the
|
||||
# prefix) is what actually gates this, so a future variant href needs no
|
||||
# code change to stay covered, and /autodoor/timer/vs/0's own exact-href
|
||||
# AUTO_DOOR_TIMER above always wins for that href regardless (discover()
|
||||
# only falls through to pattern caps when no exact cap matched). This is
|
||||
# registry-scoped, not global -- unlike ignored.IGNORED, the unknown-
|
||||
# device-type fallback never reaches it, so the prefix caveat in
|
||||
# ignored.py's own docstring doesn't apply here.
|
||||
AUTO_DOOR_VARIANT = Capability(
|
||||
href=None,
|
||||
href_prefix="/autodoor/",
|
||||
match_fn=lambda rep, resources: "x.com.samsung.da.ado.openOptions" in rep,
|
||||
)
|
||||
|
||||
# Wine-cellar variant (x.com.st.d.winecellar, issue #328) of the same
|
||||
# deodorizing filter AIR_FILTER models -- filterUsage/filterStatus at a
|
||||
# different href, same 0-100-percentage-already shape (see AIR_FILTER's own
|
||||
# comment). filterUsage reads '-1' on the only dump seen (filterStatus
|
||||
# 'normal'), relayed as-is rather than special-cased -- no second dump to
|
||||
# confirm whether that's a real sentinel or this unit just not tracking it.
|
||||
# Own 'deodor_'-prefixed keys rather than reusing AIR_FILTER.entities
|
||||
# verbatim -- same collision AIR_FILTER's own 'air_' prefix was chosen to
|
||||
# avoid against WATER_FILTER, and both filters are plausible on one unit
|
||||
# (this device's own board reports an internal air filter on other
|
||||
# TP1X_REF_21K variants).
|
||||
DEODOR_FILTER = Capability(
|
||||
href="/filter/deodorfilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="deodor_filter_usage",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="deodor_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Wine-cellar multi-compartment pantry select (issue #328): same
|
||||
# mode/supportedOptions shape as PANTRY_ZONE, at its own href with a 5-way
|
||||
# option set (Processed_Meat/Cheese/Nuts/Fruit/Wine) instead of PANTRY_ZONE's.
|
||||
# Only a "one" instance seen -- not generalized to a pattern cap, same
|
||||
# discipline as PANTRY_ZONE itself.
|
||||
|
||||
|
||||
def _winecellar_pantry_write(p, rep, href=None):
|
||||
return ["status", "winecellar", "pantry", "one", "vs", "0"], {"x.com.samsung.da.mode": p}
|
||||
|
||||
|
||||
WINECELLAR_PANTRY_ZONE = Capability(
|
||||
href="/status/winecellar/pantry/one/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="winecellar_pantry_zone_mode",
|
||||
field="x.com.samsung.da.mode",
|
||||
icon="mdi:glass-wine",
|
||||
translation_key="winecellar_pantry_zone_mode",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedOptions",
|
||||
write_fn=_winecellar_pantry_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Wine-cellar internal table-revision marker (issue #328) -- versioning
|
||||
# metadata, not appliance state. Same treatment as ignored.py's
|
||||
# /wm/setinfo/vs/0.
|
||||
WINECELLAR_INFO = Capability(href="/information/winecellar/vs/0")
|
||||
|
||||
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
||||
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
|
||||
# status: despite its "block" naming (originally assumed to mean "defrost
|
||||
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the
|
||||
# defrost cycle is *actively running* right now, seen with defrost_delay
|
||||
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
|
||||
# not a blocking/prevention state. Exposed as a read-only diagnostic
|
||||
# binary sensor.
|
||||
# ---------------------------------------------------------------------------
|
||||
# defrost. /defrost/block/vs/0 is unrelated: despite the "block" naming,
|
||||
# live dumps confirm DEFROST_BLOCK_ON means the defrost cycle is actively
|
||||
# running right now (seen with defrost_delay off) -- "block" refers to the
|
||||
# evaporator/coil block being defrosted, not a prevention state.
|
||||
|
||||
DEFROST_DELAY = Capability(
|
||||
href="/defrost/delay/vs/0",
|
||||
@@ -362,10 +498,9 @@ DEFROST_DELAY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K
|
||||
# firmware publishes the same state on both hrefs, but only the vendor resource
|
||||
# above has a confirmed write contract. Bind the native mirror without another
|
||||
# entity so discovery records it as an intentional duplicate.
|
||||
# OCF-native boolean mirror of DEFROST_DELAY -- only the vendor resource
|
||||
# above has a confirmed write contract, so bind this without another
|
||||
# entity to record it as an intentional duplicate.
|
||||
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
|
||||
href="/defrost/delay/0",
|
||||
)
|
||||
@@ -384,10 +519,6 @@ DEFROST_BLOCK_STATUS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refrigeration modes (rapid cooling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _refrigeration_write(field_name):
|
||||
def _write(p, rep, href=None):
|
||||
@@ -421,10 +552,6 @@ REFRIGERATION = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autofill
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _autofill_write(p, rep, href=None):
|
||||
if p not in ("On", "Off"):
|
||||
@@ -447,10 +574,6 @@ AUTOFILL = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Welcome lighting (proximity-triggered cabinet light)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WELCOME_LIGHTING = Capability(
|
||||
href="/proximity/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -469,14 +592,11 @@ WELCOME_LIGHTING = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enhanced cabinet light — nighttime lighting schedule
|
||||
#
|
||||
# night.starttime is an ISO datetime; only the time portion is meaningful.
|
||||
# night.duration.minute encodes the window length. End time is derived so
|
||||
# both time entities write back to the same resource without stepping on each
|
||||
# other: writing start preserves duration; writing end recalculates duration.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enhanced cabinet light nighttime schedule: night.starttime is an ISO
|
||||
# datetime (only the time portion matters), night.duration.minute is the
|
||||
# window length. End time is derived so both time entities write back to
|
||||
# the same resource without stepping on each other: writing start
|
||||
# preserves duration; writing end recalculates it.
|
||||
|
||||
_NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100")
|
||||
|
||||
@@ -600,10 +720,6 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cabinet light
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cabinet_light_write(p, rep, href=None):
|
||||
if p not in ("On", "Off"):
|
||||
@@ -638,10 +754,6 @@ CABINET_LIGHT = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sabbath mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sabbath_write(p, rep, href=None):
|
||||
if p not in ("On", "Off"):
|
||||
@@ -664,10 +776,6 @@ SABBATH = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Beverage zone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bzone_write(p, rep, href=None):
|
||||
return ["specialzone", "one", "vs", "0"], {"roomDesiredMode": p}
|
||||
@@ -689,16 +797,11 @@ BEVERAGE_ZONE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pantry / Cool Select Zone -- a convertible compartment toggled between
|
||||
# wine/deli/drinks temperature presets (issue #20). Same shape as
|
||||
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes
|
||||
# list) but a distinct resource/field set -- x.com.samsung.da.mode /
|
||||
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than
|
||||
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
|
||||
# instance has been seen; not generalized to a pattern cap until a second
|
||||
# instance turns up.
|
||||
# ---------------------------------------------------------------------------
|
||||
# wine/deli/drinks presets (issue #20). Same shape as BEVERAGE_ZONE but a
|
||||
# distinct field set (x.com.samsung.da.mode/supportedOptions vs
|
||||
# roomDesiredMode/roomSupportedModes). Only a "one" instance seen; not
|
||||
# generalized to a pattern cap until a second instance turns up.
|
||||
|
||||
|
||||
def _pantry_write(p, rep, href=None):
|
||||
@@ -721,18 +824,13 @@ PANTRY_ZONE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges)
|
||||
#
|
||||
# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the
|
||||
# flex-zone entry is whichever item is also a member of supportedOptions --
|
||||
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker)
|
||||
# never appear there. The prefix on that item varies by fridge family
|
||||
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
|
||||
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
|
||||
# stuck on None), so match by list membership instead of a hardcoded prefix.
|
||||
# Write replaces only that item; other flags are preserved.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flex zone (convertible drawer -- /mode/vs/0 on RF9000-class fridges):
|
||||
# x.com.samsung.da.modes holds several orthogonal flags in one list; the
|
||||
# flex-zone entry is whichever item also appears in supportedOptions (the
|
||||
# other flags, WATERFILTER_*/DEFROST_BLOCK_*/CVN_*_ZONE, never do). The
|
||||
# prefix on that item varies by family (CV_TTYPE_RF9000A_ vs CV_FDR_ on
|
||||
# Bespoke, issues #27/#26), so match by list membership instead of a
|
||||
# hardcoded prefix. Write replaces only that item.
|
||||
|
||||
|
||||
def _flex_zone_supported(rep):
|
||||
@@ -740,10 +838,9 @@ def _flex_zone_supported(rep):
|
||||
|
||||
|
||||
def _flex_zone_current(rep):
|
||||
# Every dump seen has at most one modes/supportedOptions overlap, so
|
||||
# "first match" and "strip all matches" (in the write below) agree. If a
|
||||
# future device ever reports two, this reads the first and the write
|
||||
# would drop both -- revisit if that turns up.
|
||||
# Every dump seen has at most one modes/supportedOptions overlap; a
|
||||
# future device reporting two would read the first and the write below
|
||||
# would drop both.
|
||||
modes = rep.get("x.com.samsung.da.modes") or []
|
||||
supported = _flex_zone_supported(rep)
|
||||
return next((m for m in modes if m in supported), None)
|
||||
@@ -767,15 +864,11 @@ FLEX_ZONE = Capability(
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedOptions",
|
||||
# A nonempty supportedOptions alone isn't sufficient: the
|
||||
# kimchi-refrigerator family (issue #26) also populates
|
||||
# /mode/vs/0's modes/supportedOptions with real data, but
|
||||
# its tokens carry a "_[n]:[n]" parameter suffix on
|
||||
# supportedOptions that modes never repeats, so no item
|
||||
# ever overlaps -- the RF9000/Bespoke-class overlap this
|
||||
# capability was built for never happens there. Require an
|
||||
# actual resolvable value instead of just a populated
|
||||
# list, so this stays absent on that family rather than
|
||||
# showing a select permanently stuck on "unknown".
|
||||
# kimchi-refrigerator family (issue #26) also populates both
|
||||
# fields, but its tokens carry a "_[n]:[n]" suffix on
|
||||
# supportedOptions that modes never repeats, so nothing ever
|
||||
# overlaps there. Require an actual resolvable value so this
|
||||
# stays absent on that family instead of stuck on "unknown".
|
||||
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
|
||||
rep_fn=_flex_zone_current,
|
||||
write_fn=_flex_zone_write,
|
||||
@@ -783,18 +876,13 @@ FLEX_ZONE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic door pattern capability (href=None — use as pattern_cap only)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic door pattern capability (href=None -- use as pattern_cap only)
|
||||
|
||||
|
||||
def _door_open_state(rep):
|
||||
"""Most /door/* resources report bare `openState`, but the
|
||||
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83)
|
||||
reports the vendor-prefixed `x.com.samsung.da.openState` instead. This
|
||||
capability still binds either way (href_prefix match doesn't care about
|
||||
field names), but a plain `field=` lookup against the wrong key means
|
||||
the entity exists and is permanently unavailable -- check both."""
|
||||
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77,
|
||||
#83) reports `x.com.samsung.da.openState` instead -- check both."""
|
||||
v = rep.get("openState")
|
||||
if v is None:
|
||||
v = rep.get("x.com.samsung.da.openState")
|
||||
@@ -816,45 +904,27 @@ DOOR_GENERIC = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi
|
||||
# units, issue #26) -- top/middle/bottom each report their own storage mode
|
||||
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in
|
||||
# an identical shape; modeled as a pattern capability the same way
|
||||
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
|
||||
# key and {instance_name} from the href's top/middle/bottom segment. Only
|
||||
# the top compartment's door has been seen reported separately (kimchidoors);
|
||||
# middle/bottom apparently have no contact switch of their own, so that's
|
||||
# its own narrower pattern cap rather than assumed universal.
|
||||
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment
|
||||
# units, issue #26): top/middle/bottom each report their own storage mode
|
||||
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, modeled as a
|
||||
# pattern capability the same way DOOR_GENERIC is. Only the top
|
||||
# compartment's door is reported separately (kimchidoors); middle/bottom
|
||||
# apparently have no contact switch, hence the narrower KIMCHI_DOOR_GENERIC
|
||||
# below rather than assuming it's universal.
|
||||
#
|
||||
# The same state is also mirrored -- packed into single tokens like
|
||||
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
|
||||
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
|
||||
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/
|
||||
# supportMode fields are unpacked and self-describing, so that's what this
|
||||
# binds to instead.
|
||||
# The same state is also packed into single tokens (e.g.
|
||||
# "KIMCHIT_KIMCHI_STORAGE_NORMAL") on /mode/vs/0, the resource FLEX_ZONE
|
||||
# reads for RF9000-class fridges -- this binds to /status/kimchi/<slot>/
|
||||
# vs/0's plain, self-describing currentMode/supportMode instead.
|
||||
#
|
||||
# Write path is unconfirmed (no live write against a real unit) -- same
|
||||
# "write the same field back to the entity's own href" convention as
|
||||
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
|
||||
# Write path is unconfirmed on real hardware; same "write the field back to
|
||||
# the entity's own href" convention as PANTRY_ZONE/BEVERAGE_ZONE.
|
||||
#
|
||||
# translations/en.json's kimchi_zone_mode state labels were translated
|
||||
# directly from the reporter's own (Korean-language) SmartThings app
|
||||
# screenshots, not guessed from the codes or from their English paraphrase.
|
||||
# Cross-checking the screenshots against supportMode confirms the on-screen
|
||||
# option order matches the array order everywhere it's verifiable: the top
|
||||
# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
|
||||
# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
|
||||
# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
|
||||
# ripening list, and 4-entry custom-storage list each line up 1:1 with their
|
||||
# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
|
||||
# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
|
||||
# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
|
||||
# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
|
||||
# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
|
||||
# and BUY (구입, "purchased") are also confirmed exact matches, not
|
||||
# abbreviation guesses.
|
||||
# ---------------------------------------------------------------------------
|
||||
# translations/en.json's kimchi_zone_mode labels were translated directly
|
||||
# from the reporter's own Korean SmartThings app screenshots (not guessed),
|
||||
# and cross-checked against supportMode order to confirm the on-screen
|
||||
# option order matches the array order throughout -- so COLD/WARM
|
||||
# consistently means Strong/Weak everywhere that suffix appears.
|
||||
|
||||
|
||||
def _kimchi_mode_write(p, rep, href=None):
|
||||
@@ -896,9 +966,8 @@ KIMCHI_ZONE = Capability(
|
||||
icon="mdi:timer-sand",
|
||||
translation_key="kimchi_ripening_remaining",
|
||||
entity_category="diagnostic",
|
||||
# No dump has this nonzero (ripeStatus is always "Off" so
|
||||
# far) -- device-reported unit unconfirmed, so this stays
|
||||
# a bare number rather than asserting minutes or hours.
|
||||
# No dump has this nonzero (ripeStatus is always "Off" so far)
|
||||
# -- unit unconfirmed, so this stays a bare number.
|
||||
value_fn=_int,
|
||||
),
|
||||
SensorDesc(
|
||||
@@ -921,12 +990,11 @@ KIMCHI_DOOR_GENERIC = Capability(
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
# Not deduped against DOORS_FALLBACK below: on the one reporter
|
||||
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
|
||||
# /doors/vs/0 aggregate carries a single generic item (id "4", no
|
||||
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
|
||||
# that doesn't share this compartment's "top" instance numbering --
|
||||
# a distinct main-cabinet door, not this kimchi drawer's own contact
|
||||
# switch reported twice.
|
||||
# this binds alongside, /doors/vs/0's aggregate carries a single
|
||||
# generic item (id "4", no /door/<instance> siblings) that doesn't
|
||||
# share this compartment's "top" numbering -- a distinct
|
||||
# main-cabinet door, not this drawer's contact switch reported
|
||||
# twice.
|
||||
BinarySensorDesc(
|
||||
key="open",
|
||||
rep_fn=_door_open_state,
|
||||
@@ -937,19 +1005,12 @@ KIMCHI_DOOR_GENERIC = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregate-resource fallbacks
|
||||
#
|
||||
# /doors/vs/0, /temperatures/vs/0, and /icemaker/status/vs/0 each duplicate
|
||||
# information exposed more precisely by per-instance hrefs (DOOR_GENERIC,
|
||||
# TEMP_CURRENT_GENERIC/TEMP_SETPOINT_GENERIC, ICEMAKER_GENERIC) on hardware
|
||||
# that has them. Not every fridge does — a simpler model may only ever
|
||||
# advertise the aggregate resource. Each fallback's match_fn checks the
|
||||
# full resource set for the richer sibling hrefs and only binds when
|
||||
# they're absent, so it's a no-op (not a gap — see discovery.py) wherever
|
||||
# the richer hrefs exist, and a real (if coarser) source of the same data
|
||||
# where they don't.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregate-resource fallbacks: /doors/vs/0, /temperatures/vs/0, and
|
||||
# /icemaker/status/vs/0 each duplicate information the per-instance hrefs
|
||||
# above expose more precisely, on hardware that has them -- not every
|
||||
# fridge does. Each fallback's match_fn checks for the richer sibling
|
||||
# hrefs and only binds when they're absent, so it's a no-op wherever the
|
||||
# richer hrefs exist and a real (coarser) source where they don't.
|
||||
|
||||
|
||||
def _any_door_generic(resources):
|
||||
@@ -1045,23 +1106,19 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
|
||||
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit
|
||||
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so
|
||||
# keep the proven per-unit/vendor controls and bind this as a duplicate only.
|
||||
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
|
||||
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer
|
||||
# per-unit hrefs; its write contract isn't advertised, so bind it as a
|
||||
# duplicate only.
|
||||
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
|
||||
href="/icemaker/status/0",
|
||||
)
|
||||
|
||||
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out
|
||||
# in this section: its three fields duplicate two *different* richer
|
||||
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and
|
||||
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a
|
||||
# single capability-level match_fn can't express it. Gated per-entity
|
||||
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
|
||||
# REFRIGERATION's href is present; defrost_active only when
|
||||
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
|
||||
# these are read-only, unlike REFRIGERATION's switches.
|
||||
# OCF-native /refrigeration/0 (issue #7): its three fields duplicate two
|
||||
# different richer hrefs (REFRIGERATION's rapidFridge/rapidFreezing,
|
||||
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so
|
||||
# gating is per-entity (exists_fn) rather than one capability-level
|
||||
# match_fn. No write path confirmed, so these stay read-only.
|
||||
REFRIGERATION_FALLBACK = Capability(
|
||||
href="/refrigeration/0",
|
||||
poll_tier="warm",
|
||||
@@ -1078,7 +1135,7 @@ REFRIGERATION_FALLBACK = Capability(
|
||||
key="rapid_fridge",
|
||||
field="rapidCool",
|
||||
icon="mdi:fridge-industrial",
|
||||
entity_category="config",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources,
|
||||
),
|
||||
@@ -1086,7 +1143,7 @@ REFRIGERATION_FALLBACK = Capability(
|
||||
key="rapid_freezing",
|
||||
field="rapidFreeze",
|
||||
icon="mdi:snowflake",
|
||||
entity_category="config",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources,
|
||||
),
|
||||
|
||||
@@ -19,9 +19,9 @@ here would silently do nothing on that path. Enumerate each known href
|
||||
instead; it's a short, stable list.
|
||||
|
||||
This list is maintainer-curated only; there is no per-installation
|
||||
override. Grow it as real /device/0 dumps surface more universal noise —
|
||||
do not add a href here on a guess. If a href's relevance is unclear, leave
|
||||
it unbound so it surfaces as a gap for a human to look at.
|
||||
override. Grow it as real /device/0 dumps surface more universal noise --
|
||||
never on a guess. If a href's relevance is unclear, leave it unbound so it
|
||||
surfaces as a gap for a human to look at.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
@@ -61,12 +61,9 @@ IGNORED: list[Capability] = [
|
||||
# Redundant with capabilities already declared elsewhere.
|
||||
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
||||
Capability(href="/speakersound/vs/0"),
|
||||
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
|
||||
# editCourseList is read directly out of the resource snapshot by
|
||||
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
|
||||
# (options=_cycle_options) to build that device's actual supported
|
||||
# course list, rather than exposing this href's raw byte string
|
||||
# through its own entity.
|
||||
# No entities of its own -- editCourseList is read directly out of the
|
||||
# resource snapshot by dishwasher.CYCLE_OPTIONS/washer.WASHER_COURSE's
|
||||
# cycle selects to build the device's supported course list.
|
||||
Capability(href="/wm/editcourse/vs/0"),
|
||||
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
||||
# listening) — only meaningful with Bixby enabled, which this
|
||||
@@ -96,17 +93,13 @@ IGNORED: list[Capability] = [
|
||||
# Temperature-unit display preference, redundant with HA's own units.
|
||||
Capability(href="/wm/submode/vs/0"),
|
||||
# Read-only re-encoding of the course already exposed by
|
||||
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
|
||||
# is literally "Table_02_Course_<same hex code>").
|
||||
# washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed
|
||||
# "Table_02_Course_").
|
||||
Capability(href="/st/washercourse/vs/0"),
|
||||
# Dryer counterpart of the above: re-encoding of the course already
|
||||
# exposed by dryer.DRYER_COURSE at /course/vs/0
|
||||
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
|
||||
# Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0.
|
||||
Capability(href="/st/dryercourse/vs/0"),
|
||||
# AirDresser counterpart of the above (issue #157): read only for its
|
||||
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no
|
||||
# entity of its own -- same "no entity, just the table id" role as
|
||||
# /st/washercourse/vs/0 and /st/dryercourse/vs/0.
|
||||
# AirDresser counterpart (issue #157): read only for its courseTable id
|
||||
# (air_dresser.AIR_DRESSER_COURSE's table_href), no entity of its own.
|
||||
Capability(href="/st/airdressercourse/vs/0"),
|
||||
# Empty on every washer dump seen so far.
|
||||
Capability(href="/wm/welcomemsg/vs/0"),
|
||||
@@ -114,29 +107,21 @@ IGNORED: list[Capability] = [
|
||||
# state without a multi-slot editor; revisit if that becomes valuable.
|
||||
Capability(href="/wm/personalcourse/vs/0"),
|
||||
# OCF-native energy resource is empty ({}) on washer hardware seen so
|
||||
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
|
||||
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
||||
# real source for this control.
|
||||
# far -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
||||
# real source.
|
||||
Capability(href="/energy/consumption/0"),
|
||||
# Empty ({}) on every washer dump seen so far -- nothing to expose.
|
||||
Capability(href="/cycleinterface/vs/0"),
|
||||
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
|
||||
# dependency this integration doesn't support locally.
|
||||
Capability(href="/drlc/0"),
|
||||
# OCF-native duplicate of /operational/state/vs/0, which is already
|
||||
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
|
||||
# capability with start/pause/stop buttons and a delay-start control)
|
||||
# used by washer, dishwasher, dryer, and oven. This generic href only
|
||||
# carries read-only overlapping data (current job state, remaining
|
||||
# time, progress percentage) with no write path -- not worth building a
|
||||
# parallel write-capable capability around an unverified generic OCF
|
||||
# write contract.
|
||||
# OCF-native duplicate of /operational/state/vs/0, already modeled by
|
||||
# operational.OPERATIONAL_STATE (richer, write-capable, used by washer,
|
||||
# dishwasher, dryer, oven). This generic href is read-only overlapping
|
||||
# data with no verified write contract worth building around.
|
||||
Capability(href="/operational/state/0"),
|
||||
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
|
||||
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
|
||||
# a textData.menu string -- every field empty/zero on the only dump
|
||||
# seen so far (device idle, no guided-cooking program active). Same
|
||||
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
|
||||
# Revisit if a dump with an active recipe surfaces.
|
||||
# Cooktop guided-cooking/recipe status (issue #86): every field
|
||||
# empty/zero on the only dump seen (device idle). Same "don't guess"
|
||||
# treatment as the microwave family's /recipe/cook/vs/0.
|
||||
Capability(href="/cooktop/recipe/status/vs/0"),
|
||||
]
|
||||
|
||||
@@ -23,9 +23,11 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
from datetime import UTC, datetime
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ... import cloudcourse
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
from .common import hex_pairs, option_value
|
||||
|
||||
_LED_LEVELS = ("Low", "High")
|
||||
_SOUND_MODES = ("voice", "tone", "mute")
|
||||
@@ -178,38 +180,24 @@ BUZZER_SOUND = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle selection over /course/vs/0.
|
||||
#
|
||||
# The selected course and every other user-tunable option ride in the
|
||||
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens.
|
||||
# Confirmed on real hardware (issue #54): a write only needs to carry the one
|
||||
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
|
||||
# the device matches by prefix, evicts the stale token, and merges the result
|
||||
# into the array itself. No read-modify-write of the whole array needed (see
|
||||
# option_write). The set of *selectable* courses is not hardcoded -- it's read
|
||||
# live from
|
||||
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
|
||||
# never show a course a given model doesn't have or hide one it does. Course
|
||||
# codes are uppercase hex; display names live in translations under
|
||||
# entity.select.<translation_key>.state.<id lowercased> so they can be
|
||||
# localized -- every device-enum select in this integration works this way.
|
||||
# washer.py's course comment has the byte-level evidence for why the options[]
|
||||
# MostUsed_* entry is *not* a trustworthy second source.
|
||||
# x.com.samsung.da.options array as `<Prefix>_<value>` tokens. Confirmed on
|
||||
# real hardware (issue #54): a write only needs to carry the one changed
|
||||
# token -- the device matches by prefix, evicts the stale token, and merges
|
||||
# the result itself (see option_write). The set of selectable courses is
|
||||
# read live from editCourseList on /wm/editcourse/vs/0 (cycle_options), not
|
||||
# hardcoded. Course codes are uppercase hex; display names live in
|
||||
# translations under entity.select.<translation_key>.state.<id lowercased>.
|
||||
#
|
||||
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
||||
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
|
||||
# the same list from /course/vs/0's own supportedOptions in that case; see
|
||||
# _course_codes_from_supported_options for the byte-level evidence.
|
||||
# editCourseList itself (issue #1) -- cycle_options() falls back to
|
||||
# deriving the list from /course/vs/0's own supportedOptions in that case;
|
||||
# see _course_codes_from_supported_options.
|
||||
#
|
||||
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
|
||||
# expose the same /course/vs/0 options contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hex_pairs(codes):
|
||||
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family
|
||||
# boards expose the same /course/vs/0 options contract.
|
||||
|
||||
|
||||
def parse_edit_course_list(raw):
|
||||
@@ -227,23 +215,13 @@ def cycle_options(resources):
|
||||
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
|
||||
|
||||
|
||||
def option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in the options array and return <value>."""
|
||||
for o in options or []:
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
return o.split("_", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
|
||||
# #258); both families use identical DrumCleanProposal_/WashingTimes_/
|
||||
# DrumCleanLog_ tokens. DrumCleanProposal_<N> is the wash/dry-cycle interval
|
||||
# between recommended cleans; WashingTimes_<N> is the count since the last
|
||||
# one -- their difference is exactly the "N cycles until due" figure the
|
||||
# Samsung app shows (verified on a washer: DrumCleanProposal_40 -
|
||||
# WashingTimes_3 == 37, matching a live app screenshot's "Potreba cistenia
|
||||
# po 37 cykloch").
|
||||
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
|
||||
# DrumCleanProposal_<N> is the cycle interval between recommended cleans;
|
||||
# WashingTimes_<N> is the count since the last one -- their difference is
|
||||
# the "N cycles until due" figure the app shows (verified: 40 - 3 == 37,
|
||||
# matching a live app screenshot).
|
||||
def drum_clean_cycles_remaining(rep):
|
||||
opts = rep.get("x.com.samsung.da.options") or []
|
||||
proposal = option_value(opts, "DrumCleanProposal")
|
||||
@@ -257,14 +235,10 @@ def drum_clean_cycles_remaining(rep):
|
||||
|
||||
|
||||
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
|
||||
# datetime (the last clean, verified against the same app screenshot's "10
|
||||
# days ago"); a dryer (issue #258's Dillton-reported dump) instead reports a
|
||||
# '|'-joined history of every past clean, ten deep on that dump, in
|
||||
# strictly increasing order. Splitting on '|' and taking the last element
|
||||
# handles both shapes identically -- a no-'|' value is unaffected. No
|
||||
# explicit timezone field accompanies either shape, so it's treated as UTC,
|
||||
# matching this integration's convention for other bare ISO datetime fields
|
||||
# (see fridge.py's night-light schedule comment).
|
||||
# datetime (the last clean); a dryer (issue #258) instead reports a
|
||||
# '|'-joined history of every past clean in increasing order. Splitting on
|
||||
# '|' and taking the last element handles both shapes identically. No
|
||||
# timezone accompanies either shape, so it's treated as UTC.
|
||||
def drum_clean_last_cleaned(rep):
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
|
||||
if not raw:
|
||||
@@ -278,41 +252,27 @@ def drum_clean_last_cleaned(rep):
|
||||
|
||||
def _course_codes_from_supported_options(course_rep):
|
||||
"""Fallback for an empty/missing editCourseList: derive the selectable
|
||||
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
|
||||
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
|
||||
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
|
||||
course list from /course/vs/0's own supportedOptions instead (issue #1:
|
||||
some boards populate /wm/editcourse/vs/0 but never fill in
|
||||
editCourseList itself).
|
||||
|
||||
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
||||
record per selectable course, self-indexed rather than positional --
|
||||
the first byte of every record is that course's own hex code, just in
|
||||
the firmware's own internal order, not editCourseList's. Confirmed
|
||||
against six independent real-world washer/dryer/dishwasher dumps: every
|
||||
one divides evenly into `header + N * K bytes` with fully unique first
|
||||
bytes across all N records, at the record's true byte width. (What the
|
||||
rest of each record encodes is still unconfirmed -- this only uses the
|
||||
course-code byte.)
|
||||
the first byte of every record is that course's own hex code.
|
||||
Confirmed against six independent real-world dumps: every one divides
|
||||
evenly into `header + N * K bytes` with fully unique first bytes across
|
||||
all N records, at the record's true byte width.
|
||||
|
||||
Two guards, deliberately conservative rather than guessing further: the
|
||||
derived codes must (a) all be distinct -- a real course table, not
|
||||
noise -- and (b) include whatever course is currently selected
|
||||
(x.com.samsung.da.options' Course_<code> token), which must always be a
|
||||
member of its own device's valid list. If no split satisfies both, this
|
||||
returns [] rather than guess.
|
||||
Two conservative guards rather than guessing further: the derived codes
|
||||
must all be distinct, and must include whatever course is currently
|
||||
selected. If no split satisfies both, this returns [].
|
||||
|
||||
Among splits that satisfy both, the *smallest* passing K wins, rather
|
||||
than requiring a single unambiguous one -- more than one K reliably
|
||||
does pass on real data (e.g. the shipped dishwasher fixture: true
|
||||
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
|
||||
7 -- position 0 always lands on the same real course code regardless
|
||||
of K, which is enough on its own to satisfy the current-course guard
|
||||
for several unrelated splits). Smallest-K-wins is a heuristic, not a
|
||||
proof: it matches the confirmed answer on every one of six independent
|
||||
real-world dumps this was checked against, but a coincidentally
|
||||
unique, current-course-inclusive *smaller* K is not mathematically
|
||||
impossible on some future device, and would be picked silently. Not
|
||||
guarded against further here, since course tables are typically large
|
||||
enough (double digits) that colliding by chance on both checks is
|
||||
unlikely, and no device seen so far actually needs it.
|
||||
Among splits that satisfy both, the smallest passing K wins -- more
|
||||
than one K reliably passes on real data, and smallest-K-wins matches
|
||||
the confirmed answer on all six dumps checked, though it's a heuristic
|
||||
rather than a proof. Not guarded further: course tables are typically
|
||||
large enough that colliding by chance on both checks is unlikely, and
|
||||
no device seen so far needs it.
|
||||
"""
|
||||
raw = course_rep.get("x.com.samsung.da.supportedOptions")
|
||||
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
||||
@@ -338,57 +298,205 @@ def _course_codes_from_supported_options(course_rep):
|
||||
return []
|
||||
|
||||
|
||||
def option_tokens(*pairs):
|
||||
"""[(prefix, value), ...] -> ['<prefix>_<value>', ...] -- the general
|
||||
form of option_write, for the one write that needs two tokens to land in
|
||||
the same options[] array together (see cycle_write's cloud branch)."""
|
||||
return [f"{prefix}_{value}" for prefix, value in pairs]
|
||||
|
||||
|
||||
def option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write -- see the module comment
|
||||
above cycle_options for why this doesn't read/rewrite the whole array."""
|
||||
return [f"{prefix}_{new_value}"]
|
||||
above for why this doesn't read/rewrite the whole array."""
|
||||
return option_tokens((prefix, new_value))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloud "Download" programs, folded into this same cycle select (issue #342).
|
||||
#
|
||||
# A device that has downloaded programs advertises them on the same
|
||||
# /course/vs/0 options array; cloudcourse.py owns the token shapes, the
|
||||
# learned store, and the reasoning for all of it. Everything below is just
|
||||
# how that store reaches the select: the coordinator merges it onto this
|
||||
# href's rep under cloudcourse.FIELD, so the option list, current value,
|
||||
# label, and write path each read it from the rep or snapshot they already
|
||||
# receive.
|
||||
#
|
||||
# They ride in the cycle select rather than a select of their own because
|
||||
# that is what they are to a user -- on the appliance's own controls,
|
||||
# "Download" occupies one position among the ordinary courses, and picking a
|
||||
# downloaded program is picking a cycle. Their raw values are namespaced
|
||||
# ('cloud:<slot>') so they can never be confused with, or collide with, a
|
||||
# two-hex-char local course code.
|
||||
#
|
||||
# Bound by whichever families declare it. Washers are where this was worked
|
||||
# out, but a DW5000C dishwasher advertises the same token (see
|
||||
# cloudcourse.py), so nothing below is washer-specific.
|
||||
#
|
||||
# Confirmed on hardware before any of this was written (issue #342): writing
|
||||
# the program token alone, while some other course is selected, is silently
|
||||
# ignored -- the course token has to switch to Download in the *same* write.
|
||||
# Hence the two-token write, the only one in this module.
|
||||
|
||||
|
||||
def _cloud_state(rep):
|
||||
return rep.get(cloudcourse.FIELD) or {}
|
||||
|
||||
|
||||
def cloud_options(rep):
|
||||
"""Namespaced raw values for every named, learned cloud program."""
|
||||
return [
|
||||
f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {}))
|
||||
]
|
||||
|
||||
|
||||
def cloud_label(value, resources):
|
||||
"""The user's own name for a 'cloud:<slot>' value.
|
||||
|
||||
Cloud program names are user-supplied, never translated: the appliance
|
||||
reports only an opaque slot id, and inventing an English label for one
|
||||
is exactly what this module refuses to do for unrecognized local course
|
||||
codes (see washer_cycle_fallback).
|
||||
"""
|
||||
if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX):
|
||||
return None
|
||||
slot = value[len(cloudcourse.RAW_PREFIX) :]
|
||||
rep = resources.get(cloudcourse.COURSE_HREF) or {}
|
||||
program = _cloud_state(rep).get("programs", {}).get(slot)
|
||||
return program["name"] if program else None
|
||||
|
||||
|
||||
def cloud_current(rep):
|
||||
"""'cloud:<slot>' when a named cloud program is the live selection.
|
||||
|
||||
Gated on the course actually being this device's confirmed Download
|
||||
course: tokens in this array are replaced by prefix and never evicted, so
|
||||
a one-time program token outlives the run it belonged to and would
|
||||
otherwise report "Jeans" while an ordinary cotton cycle runs.
|
||||
"""
|
||||
state = _cloud_state(rep)
|
||||
download = state.get("download_course")
|
||||
options = rep.get("x.com.samsung.da.options")
|
||||
if not download or option_value(options, "Course") != download:
|
||||
return None
|
||||
blob = option_value(options, cloudcourse.ONESHOT_PREFIX)
|
||||
slot = cloudcourse.slot_of(blob)
|
||||
if slot is None:
|
||||
# No one-time override loaded: the appliance falls back to whatever
|
||||
# the persisted default holds (confirmed with the issue #342
|
||||
# reporter -- leaving Download and returning to it re-selects the
|
||||
# saved program, not the last one-time one).
|
||||
slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX))
|
||||
if slot is None or slot not in state.get("programs", {}):
|
||||
return None
|
||||
return f"{cloudcourse.RAW_PREFIX}{slot}"
|
||||
|
||||
|
||||
def cycle_write(p, rep, href=None):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX):
|
||||
return _cloud_cycle_write(p, rep)
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Course", p),
|
||||
}
|
||||
|
||||
|
||||
def _cloud_cycle_write(p, rep):
|
||||
state = _cloud_state(rep)
|
||||
download = state.get("download_course")
|
||||
program = state.get("programs", {}).get(p[len(cloudcourse.RAW_PREFIX) :])
|
||||
if not download or program is None:
|
||||
return None
|
||||
# Order matches what the appliance was confirmed to accept.
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_tokens(
|
||||
("Course", download), (cloudcourse.ONESHOT_PREFIX, program["blob"])
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def personal_course_labels(resources, href="/wm/personalcourse/vs/0"):
|
||||
"""Return device-provided personal course names keyed by course code.
|
||||
|
||||
Populated entries use a small TLV payload. The leading field is
|
||||
``01 <UTF-8-byte-length> <name>``; later fields contain a description and
|
||||
settings and are intentionally left uninterpreted. Empty slots are
|
||||
encoded as ``<code>_00``. Malformed or undecodable entries are ignored so
|
||||
opaque device data can never become a misleading label.
|
||||
"""
|
||||
rep = resources.get(href) or {}
|
||||
labels = {}
|
||||
for entry in rep.get("x.com.samsung.da.courses") or []:
|
||||
if not isinstance(entry, str) or "_" not in entry:
|
||||
continue
|
||||
code, encoded = entry.split("_", 1)
|
||||
try:
|
||||
payload = bytes.fromhex(encoded)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(payload) < 3 or payload[0] != 0x01:
|
||||
continue
|
||||
name_length = payload[1]
|
||||
if name_length == 0 or len(payload) < 2 + name_length:
|
||||
continue
|
||||
try:
|
||||
name = payload[2 : 2 + name_length].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if name.strip() and name.isprintable():
|
||||
labels[code.upper()] = name
|
||||
return labels
|
||||
|
||||
|
||||
def washer_cycle_fallback(value, resources):
|
||||
"""Label a personal washer course from its device-provided name.
|
||||
|
||||
No fallback for an unrecognized standard code -- an invented English
|
||||
label would defeat translation (PR #251 review); the raw code displays
|
||||
instead, same as before this function existed.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
return personal_course_labels(resources).get(value.upper())
|
||||
|
||||
|
||||
def _table_id(resources, table_href):
|
||||
rep = resources.get(table_href) or {}
|
||||
return rep.get("x.com.samsung.da.st.courseTable")
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The option list, current value, and write path are all shared across
|
||||
washer/dryer/dishwasher; only the translation is family- (and, for
|
||||
washer/dryer, board-) specific.
|
||||
The option list, current value, and write path are shared across
|
||||
washer/dryer/dishwasher; only the translation is family/board-specific.
|
||||
|
||||
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
|
||||
suffixes translation_key with the device's own course-table id, read
|
||||
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
|
||||
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||
'washer_cycle_table_02'). An absent or unrecognized table id gets the
|
||||
name-only ``cycle`` translation key while the raw course code remains
|
||||
visible and writable.
|
||||
table_href (washer/dryer only) suffixes translation_key with the
|
||||
device's own course-table id, read from /st/washercourse/vs/0 or
|
||||
/st/dryercourse/vs/0's courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||
'washer_cycle_table_02'). This matters because course codes are NOT
|
||||
guaranteed consistent across board generations sharing the same
|
||||
/course/vs/0 contract: washer_cycle_table_02 was confirmed against
|
||||
Table_02 devices, but FlexWash's older board reports Table_00, where
|
||||
the same hex code could mean a different course. An absent or
|
||||
unrecognized table id falls back to the name-only ``cycle`` key
|
||||
instead of borrowing a label from another board generation --
|
||||
translating a new table is a translations-only change.
|
||||
|
||||
This matters because course codes are NOT guaranteed consistent across
|
||||
board generations sharing the same /course/vs/0 contract: every code in
|
||||
washer_cycle_table_02 was confirmed against Table_02-reporting devices
|
||||
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
|
||||
Table_00 instead, so the same hex code could mean a different course
|
||||
there for all we've verified. So a table-specific key is used only when
|
||||
the shipped catalog actually has one; any other table (Table_00 today,
|
||||
whatever ships next) falls back to the name-only ``cycle`` key, which
|
||||
shows the raw course code rather than a label borrowed from another
|
||||
board generation. Translating a new table is therefore a
|
||||
translations-only change -- add the ``<family>_cycle_<table>`` entry and
|
||||
this resolver picks it up.
|
||||
The raw course code remains writable regardless; its display uses
|
||||
display_fn when supplied, otherwise it remains raw. display_fn is an
|
||||
optional family-specific fallback for untranslated raw values --
|
||||
select.py applies it after catalog lookup to both state and options.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource in any dump seen and no evidence its course codes vary by
|
||||
table the way washer/dryer's do -- there's nothing to build a
|
||||
table-specific key from.
|
||||
resource and no evidence its codes vary by table the way washer/
|
||||
dryer's do.
|
||||
|
||||
Any cloud "Download" programs the user has discovered and named join the
|
||||
same option list, after the local courses -- see the cloud section above.
|
||||
A device with none (or one whose owner hasn't named any yet) gets exactly
|
||||
the list it got before they existed.
|
||||
"""
|
||||
key = translation_key
|
||||
if table_href is not None:
|
||||
@@ -400,25 +508,40 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
candidate = f"{translation_key}_{table.lower()}"
|
||||
return candidate if has_entity_translation("select", candidate) else "cycle"
|
||||
|
||||
def options(resources):
|
||||
rep = resources.get(cloudcourse.COURSE_HREF) or {}
|
||||
# Local courses first: a user-supplied cloud name that happens to
|
||||
# match a translated course name resolves back to the real local
|
||||
# course on write, which is the safer of the two. The options flow
|
||||
# rejects such a name outright, so this is a backstop, not the fix.
|
||||
return [*cycle_options(resources), *cloud_options(rep)]
|
||||
|
||||
def current(rep):
|
||||
return cloud_current(rep) or option_value(rep.get("x.com.samsung.da.options"), "Course")
|
||||
|
||||
def label(value, resources):
|
||||
cloud = cloud_label(value, resources)
|
||||
if cloud is not None:
|
||||
return cloud
|
||||
return display_fn(value, resources) if display_fn is not None else None
|
||||
|
||||
return SelectDesc(
|
||||
key="cycle",
|
||||
icon=icon,
|
||||
translation_key=key,
|
||||
options=cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
||||
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
|
||||
options=options,
|
||||
exists_fn=lambda rep, resources: bool(options(resources)),
|
||||
rep_fn=current,
|
||||
display_fn=label,
|
||||
write_fn=cycle_write,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plain boolean toggles over /course/vs/0's options[] array: a
|
||||
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
|
||||
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
|
||||
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
|
||||
# both families ride this exact contract, just with different prefixes and
|
||||
# different presence/validation needs on top.
|
||||
# ---------------------------------------------------------------------------
|
||||
# '<prefix>_On'/'<prefix>_Off' token, merged the same way as the 'Course'
|
||||
# token above. Shared by washer (bubble soak, pre-wash, intensive -- issue
|
||||
# #22) and dishwasher (storm wash, auto release dry), just with different
|
||||
# prefixes and presence/validation needs on top.
|
||||
|
||||
|
||||
def bool_option_write(prefix):
|
||||
@@ -450,12 +573,11 @@ def bool_option_switch(
|
||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||
|
||||
gate_on_presence self-gates the entity off on models that never report
|
||||
the token at all (washer's bubble soak/pre-wash/intensive); leave False
|
||||
for a toggle every device in the family reports (dishwasher's storm
|
||||
wash). validate_fn is passed straight through to SwitchDesc for callers
|
||||
that need to reject a write against live device state (e.g. washer's
|
||||
per-course availability check) -- this factory has no opinion on it and
|
||||
building one, if needed, is the caller's job.
|
||||
the token (washer's bubble soak/pre-wash/intensive); leave False for a
|
||||
toggle every device in the family reports (dishwasher's storm wash).
|
||||
validate_fn passes straight through to SwitchDesc for callers that need
|
||||
to reject a write against live state -- this factory has no opinion on
|
||||
it.
|
||||
"""
|
||||
return SwitchDesc(
|
||||
key=key,
|
||||
@@ -468,14 +590,11 @@ def bool_option_switch(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
|
||||
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
|
||||
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
|
||||
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
|
||||
# ever carried that field, so the dryer sensor was always blank -- fixed by
|
||||
# sharing this one reader.
|
||||
# ---------------------------------------------------------------------------
|
||||
# (e.g. door open, no water), x.com.samsung.da.currentStatus on every dump
|
||||
# that populates it. An earlier dryer descriptor read
|
||||
# x.com.samsung.da.jobBeginingStatus instead, which no dump ever carried,
|
||||
# so the dryer sensor was always blank -- fixed by sharing this one reader.
|
||||
|
||||
JOB_BEGINNING_STATUS = Capability(
|
||||
href="/wm/jobbeginingstatus/vs/0",
|
||||
|
||||
@@ -8,33 +8,25 @@ oven.py in by_type/microwave.py rather than duplicated. What's genuinely
|
||||
different from an oven, and defined fresh here:
|
||||
|
||||
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
|
||||
KeepWarm never appear on an oven's /mode/vs/0, and this family spells
|
||||
some shared-sounding modes differently than oven.py's own constants
|
||||
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
|
||||
mode list, not oven.OVEN_MODE.
|
||||
KeepWarm never appear on an oven's /mode/vs/0, and some shared-sounding
|
||||
modes are spelled differently (e.g. 'AirFryer', not oven.py's
|
||||
'AirFry') -- a distinct SelectDesc and mode list, not oven.OVEN_MODE.
|
||||
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
|
||||
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
|
||||
30-270 C range (verified against a different, bake-oven-class board).
|
||||
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
|
||||
on the MicroWave mode's powerListData) that plain ovens don't report --
|
||||
exposed as its own sensor.
|
||||
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
|
||||
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
|
||||
the combi dump (issue #121), so it's gated with exists_fn rather than
|
||||
assumed universal like oven.py's lamp switch. Issue #137's dump only
|
||||
ever showed 'Off', so 'On' was a guess at the paired value; issue #152's
|
||||
ME7500D dump is the first to show a real non-Off value, and it's 'High'
|
||||
(a brightness level, not literally 'On') -- the switch now treats any
|
||||
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
|
||||
(the two confirmed tokens) rather than the never-confirmed 'On'.
|
||||
(issue #121) reports 40-200°C / step 5, not oven.py's 30-270°C range.
|
||||
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W)
|
||||
that plain ovens don't report -- exposed as its own sensor.
|
||||
* Lamp: this family's option-array token is bare 'Lamp' (issue #137), not
|
||||
oven.py's 'UpperLamp', and genuinely absent on the combi dump (issue
|
||||
#121), so it's exists_fn-gated rather than assumed universal. 'On' has
|
||||
never been observed as a value; the only confirmed non-Off token is
|
||||
'High' (issue #152) -- the switch treats any non-Off/non-None value as
|
||||
"on" for reads and writes back 'High'/'Off'.
|
||||
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
|
||||
option-array tokens (issue #181), both with On and Off observed live
|
||||
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
|
||||
option-array tokens (issue #181), gated with exists_fn like Lamp since
|
||||
the MW7300B combi dump has neither.
|
||||
|
||||
Note: cooking-mode writes are unproven here, same caveat as oven.py's
|
||||
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
|
||||
is also the test.
|
||||
Cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE
|
||||
-- exposed as a SelectDesc for fidelity, first real-world write is the test.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
@@ -46,13 +38,10 @@ from .laundry import option_value, option_write
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Union of every mode seen across the two known dumps: issue #121's combi
|
||||
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
|
||||
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
|
||||
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
|
||||
# every mode below on one device -- the select surfaces whatever a given
|
||||
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
|
||||
# never sends just never gets picked.
|
||||
# Union of every mode seen across the two known dumps (issues #121, #137).
|
||||
# No dump has shown every mode below on one device -- the select surfaces
|
||||
# whatever a given board's own supportedModes reports; an entry here a
|
||||
# device never sends just never gets picked.
|
||||
_MICROWAVE_MODES = (
|
||||
"NoOperation",
|
||||
"MicroWave",
|
||||
@@ -67,22 +56,18 @@ _MICROWAVE_MODES = (
|
||||
"KeepWarm",
|
||||
)
|
||||
|
||||
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
|
||||
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
|
||||
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
|
||||
# against issue #44's range dump), there's nothing to verify a microwave's
|
||||
# Fahrenheit bounds against, so this module only exposes the setpoint
|
||||
# control when the live unit is Celsius (see _microwave_temp_unit below).
|
||||
# Convection/MicroWaveConvection modeSpec on issue #121's dump: 40-200°C,
|
||||
# step 5. No Fahrenheit dump exists for this family, unlike oven.py's own
|
||||
# independently-verified F bounds, so this module only exposes the
|
||||
# setpoint control when the live unit is Celsius (see _microwave_temp_unit).
|
||||
SETPOINT_MIN_C = 40
|
||||
SETPOINT_MAX_C = 200
|
||||
SETPOINT_STEP_C = 5
|
||||
|
||||
|
||||
def _microwave_temp_unit(rep):
|
||||
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
|
||||
carries a per-item x.com.samsung.da.unit field. Both known dumps for
|
||||
this family report 'Celsius'; kept live rather than hardcoded per the
|
||||
fridge/oven convention (issue #7)."""
|
||||
"""Same shape as oven.py's _oven_temp_unit. Both known dumps report
|
||||
'Celsius'; kept live rather than hardcoded (issue #7)."""
|
||||
items = rep.get("x.com.samsung.da.items") or []
|
||||
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
||||
return normalize_temp_unit(unit, default="°C")
|
||||
@@ -90,8 +75,7 @@ def _microwave_temp_unit(rep):
|
||||
|
||||
def _setpoint_write(p, rep, href=None):
|
||||
"""RMW write to /temperatures/vs/0 items array -- unproven for this
|
||||
family (no live write confirmed against a real unit), same "exposed for
|
||||
fidelity" caveat as the mode select."""
|
||||
family, same "exposed for fidelity" caveat as the mode select."""
|
||||
try:
|
||||
temp = float(p)
|
||||
except (TypeError, ValueError):
|
||||
@@ -118,12 +102,11 @@ def _power_level_watts(v):
|
||||
|
||||
|
||||
def _cooking_mode_options(resources):
|
||||
"""Live mode list from the device's own /mode/vs/0 supportedModes when
|
||||
it reports one (both known dumps do); the union-of-all-dumps
|
||||
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
|
||||
pattern as oven._oven_mode_options -- a fixed list here would offer
|
||||
users modes their own unit doesn't have (issue #152's ME7500D reports
|
||||
only 4 of _MICROWAVE_MODES' 11)."""
|
||||
"""Live mode list from the device's own supportedModes when reported
|
||||
(both known dumps do); the union-of-all-dumps _MICROWAVE_MODES guess
|
||||
otherwise. Same live-first, static-fallback pattern as
|
||||
oven._oven_mode_options -- a fixed list would offer modes a unit
|
||||
doesn't have (issue #152 reports only 4 of _MICROWAVE_MODES' 11)."""
|
||||
rep = resources.get("/mode/vs/0") or {}
|
||||
live = rep.get("x.com.samsung.da.supportedModes")
|
||||
return list(live) if live else list(_MICROWAVE_MODES)
|
||||
@@ -163,9 +146,8 @@ def _lamp_write(p, rep, href=None):
|
||||
return None
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
|
||||
# (issues #137/#152) -- 'On' has never been observed and the device
|
||||
# likely doesn't recognize it (see module docstring).
|
||||
# 'High'/'Off' are the two confirmed tokens (see module docstring);
|
||||
# 'On' has never been observed and likely isn't recognized.
|
||||
token = "High" if p == "On" else "Off"
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Lamp", token),
|
||||
@@ -271,11 +253,9 @@ MICROWAVE_MODE = Capability(
|
||||
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
|
||||
write_fn=_lamp_write,
|
||||
),
|
||||
# issue #181: Filter Reminder / End Signal Reminder toggles,
|
||||
# confirmed present (both On and Off observed across dumps -- see
|
||||
# issue #152's ME7500D fixtures) but only on boards that carry the
|
||||
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
|
||||
# MW7300B combi dump has neither) rather than assumed universal.
|
||||
# issue #181: Filter Reminder / End Signal Reminder toggles, only on
|
||||
# boards carrying the FilterRemind_*/RemindBeep_* tokens; gated off
|
||||
# elsewhere (the MW7300B combi dump has neither).
|
||||
SwitchDesc(
|
||||
key="filter_remind",
|
||||
field="x.com.samsung.da.options",
|
||||
|
||||
@@ -35,12 +35,48 @@ def _int(v):
|
||||
return None
|
||||
|
||||
|
||||
def _state_is_active(rep):
|
||||
return _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
|
||||
|
||||
def _is_active(rep):
|
||||
"""Check if appliance is actively running and cycle is not finished."""
|
||||
return (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
)
|
||||
return _state_is_active(rep) and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
|
||||
|
||||
def _just_finished(rep):
|
||||
"""progress/progress_percentage's sticky_fn (issue #345, see sensor.py's
|
||||
_apply_sticky): arms their grace window the moment `progress` reads
|
||||
'Finish'.
|
||||
|
||||
Deliberately not also requiring `state == 'active'` in the same rep,
|
||||
unlike _is_active above: #345 reports a washer whose `state` can
|
||||
already read idle by the time `progress` is observed at 'Finish' --
|
||||
the same-family dryer's firmware apparently keeps `state` at 'active'
|
||||
longer, per the report -- so requiring both together risked the arm
|
||||
condition never actually firing on the one device this fixes.
|
||||
_apply_sticky's edge-triggering (only a fresh False->True transition
|
||||
(re)arms) is what keeps a `progress` stuck at 'Finish' indefinitely
|
||||
(the same class of quirk `_completion_minutes` below already works
|
||||
around) from holding this open forever instead."""
|
||||
return rep.get("x.com.samsung.da.progress") == "Finish"
|
||||
|
||||
|
||||
def _new_cycle_running(rep):
|
||||
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
|
||||
early once a new cycle is genuinely running.
|
||||
|
||||
Gated on `state == 'active'`, unlike _just_finished's arm condition
|
||||
above: issue #358's dryer resets `progress` to its course's first
|
||||
stage ('Drying') in the same moment `state` goes idle, ~4s before
|
||||
settling to 'None' -- confirmed by the reporter's machine_state
|
||||
history, which flips to idle on the exact second progress reads
|
||||
'Drying', in both captured cycles. A bypass keyed on the progress
|
||||
code alone read that as a new cycle and republished it. Releasing
|
||||
late costs nothing -- an unreleased hold still expires on its own --
|
||||
so this side takes the stronger signal."""
|
||||
v = rep.get("x.com.samsung.da.progress")
|
||||
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
|
||||
|
||||
|
||||
def _remaining_seconds(raw):
|
||||
@@ -75,7 +111,7 @@ def _delay_hours(v):
|
||||
def _format_delay(hours):
|
||||
total_minutes = round(max(float(hours), 0) * 60)
|
||||
h, m = divmod(total_minutes, 60)
|
||||
return f"{h}:{m:02d}:00"
|
||||
return f"{h:02d}:{m:02d}:00"
|
||||
|
||||
|
||||
def _delay_field(rep):
|
||||
@@ -98,11 +134,9 @@ def _finish_time(rep):
|
||||
if not total_s:
|
||||
return None
|
||||
# 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.
|
||||
# resolution, but datetime.now()'s fresh seconds/microseconds would
|
||||
# otherwise change the result on nearly every poll, flooding the
|
||||
# recorder with values that look identical once the UI rounds them.
|
||||
finish = datetime.now(UTC) + timedelta(seconds=total_s)
|
||||
return finish.replace(second=0, microsecond=0)
|
||||
|
||||
@@ -147,31 +181,34 @@ OPERATIONAL_STATE = Capability(
|
||||
translation_key="machine_state",
|
||||
value_fn=_to_ocf,
|
||||
),
|
||||
# cycle_active is a bool derived from machine_state; used by the
|
||||
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
||||
# Harmless for non-oven appliances — just an extra bool in state.
|
||||
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
|
||||
# so we also gate on progress to avoid a stuck 'Running' indication.
|
||||
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
|
||||
# start/pause/stop buttons below) is shared across the dryer/
|
||||
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
|
||||
# vocabulary that doesn't fit an oven's bake/roast/etc.
|
||||
# cycle_active is a bool derived from machine_state, gated on
|
||||
# progress too since firmware keeps state='Run' after progress
|
||||
# reaches 'Finish' (a stuck 'Running' indication otherwise). Named
|
||||
# 'Running' in the catalog, not 'Cycle active' -- this href is
|
||||
# shared with oven, and 'cycle' is laundry-specific vocabulary.
|
||||
BinarySensorDesc(
|
||||
key="cycle_active",
|
||||
device_class="running",
|
||||
rep_fn=lambda rep: (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
),
|
||||
rep_fn=_is_active,
|
||||
),
|
||||
# sticky_* (issue #345): once progress reads 'Finish', keep
|
||||
# showing Finish/100 for a grace window even after machine_state
|
||||
# reverts, rather than falling to Idle/0 the instant it does --
|
||||
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
|
||||
# stays the only definition of a live value -- the hold decides
|
||||
# only *whether* to freeze. A second, ungated one here is what
|
||||
# let issue #358's post-Finish tail reach the entity.
|
||||
SensorDesc(
|
||||
key="progress",
|
||||
icon="mdi:progress-wrench",
|
||||
rep_fn=lambda rep: (
|
||||
"Idle"
|
||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
||||
if not _state_is_active(rep)
|
||||
else _progress(rep.get("x.com.samsung.da.progress"))
|
||||
),
|
||||
sticky_fn=_just_finished,
|
||||
sticky_value_fn=lambda rep: "Finish",
|
||||
sticky_bypass_fn=_new_cycle_running,
|
||||
),
|
||||
SensorDesc(
|
||||
key="progress_percentage",
|
||||
@@ -179,13 +216,15 @@ OPERATIONAL_STATE = Capability(
|
||||
state_class="measurement",
|
||||
rep_fn=lambda rep: (
|
||||
0
|
||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
||||
if not _state_is_active(rep)
|
||||
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
||||
),
|
||||
sticky_fn=_just_finished,
|
||||
sticky_value_fn=lambda rep: 100,
|
||||
sticky_bypass_fn=_new_cycle_running,
|
||||
),
|
||||
# Only show finish time when machine is actively running. Samsung
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
# Only show finish time while actively running -- firmware leaves a
|
||||
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
|
||||
SensorDesc(
|
||||
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
|
||||
),
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"""Capabilities for the oven family (Samsung NV7000BS-class).
|
||||
|
||||
Resources verified against the live device via DTLS-CoAP.
|
||||
See `local-tools/comparisons/oven-tree.md` for the full field reference.
|
||||
Resources verified against the live device via DTLS-CoAP. See
|
||||
`local-tools/comparisons/oven-tree.md` for the full field reference.
|
||||
|
||||
Write surfaces this module exposes:
|
||||
Proven write: lamp, via /mode/vs/0 options RMW, works even with Remote
|
||||
Control off. Unproven (first HA use is also the test): sound/fastPreheat/
|
||||
naturalSteam (same RMW pattern), setpoint via /temperatures/vs/0 items RMW,
|
||||
cook time via /operational/state/vs/0's operationTime/remainingTime, mode
|
||||
select via /mode/vs/0.modes (mid-cook acceptance unknown), stop via
|
||||
state='Ready'.
|
||||
|
||||
proven:
|
||||
* Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
|
||||
— works even with Remote Control off.
|
||||
|
||||
unproven (first HA use is also the test):
|
||||
* Sound, FastPreheat, NaturalSteam — same RMW pattern as lamp.
|
||||
* Setpoint via /temperatures/vs/0 items RMW.
|
||||
* Cook time via /operational/state/vs/0 operationTime/remainingTime.
|
||||
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown.
|
||||
* Stop via /operational/state/vs/0 state='Ready'.
|
||||
|
||||
Note: Cycle start is not implemented. Reverse-engineering shows local-OCF
|
||||
cycle start is not reproducible on this firmware (see project_oven_remote
|
||||
_start_open.md). Mode writes are also unreliable — the oven rolls them back
|
||||
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
|
||||
but is effectively read-only in practice.
|
||||
Cycle start is not implemented: local-OCF cycle start isn't reproducible on
|
||||
this firmware. Mode writes are also unreliable -- the oven rolls them back
|
||||
once a cycle is active, so OVEN_MODE's SelectDesc is effectively read-only
|
||||
in practice.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -45,26 +38,18 @@ SETPOINT_MIN_C = 30
|
||||
SETPOINT_MAX_C = 270
|
||||
SETPOINT_STEP_C = 5
|
||||
|
||||
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
|
||||
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
|
||||
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
|
||||
# constant set rather than converted from the Celsius bounds above, which
|
||||
# are themselves unverified (no live dump; see module docstring).
|
||||
# Verified against issue #44's range dump: Bake mode's modeSpec reports
|
||||
# tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept separate rather than
|
||||
# converted from the Celsius bounds above, which are themselves unverified.
|
||||
SETPOINT_MIN_F = 175
|
||||
SETPOINT_MAX_F = 550
|
||||
SETPOINT_STEP_F = 5
|
||||
|
||||
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
|
||||
# from Samsung documentation and firmware observations. The firmware will
|
||||
# reject unknown modes; missing entries here are a coverage gap, not a bug.
|
||||
#
|
||||
# This is a fallback only, used when a device's own /mode/vs/0 doesn't report
|
||||
# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/
|
||||
# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports
|
||||
# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
|
||||
# in its own supportedModes; those are read live rather than added here, per
|
||||
# the adding-device-support skill's preference for device-reported option
|
||||
# lists over hardcoded ones.
|
||||
# Mode options seen on NV7000BS-class. No dump exists so this list is
|
||||
# inferred from Samsung documentation and firmware observations; the
|
||||
# firmware rejects unknown modes, so a missing entry is a coverage gap, not
|
||||
# a bug. Fallback only, used when a device's own /mode/vs/0 doesn't report
|
||||
# supportedModes at all -- see _oven_mode_options/_oven_mode_write below.
|
||||
_OVEN_MODES = (
|
||||
"NoOperation",
|
||||
"Bake",
|
||||
@@ -138,22 +123,18 @@ def _option_value(options, prefix):
|
||||
|
||||
|
||||
def _has_option(prefix):
|
||||
"""exists_fn for an options-array switch: bind only when the device's own
|
||||
options[] actually carries a `<prefix>_<value>` token.
|
||||
"""exists_fn for an options-array switch: bind only when the device's
|
||||
own options[] actually carries a `<prefix>_<value>` token.
|
||||
|
||||
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
|
||||
an unverified guess (see module docstring) -- issue #183's dump (model
|
||||
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
|
||||
options[] at all, so both switches were phantom controls: always read as
|
||||
off, and toggling them wrote a token the firmware never recognized in
|
||||
the first place, hence "does not appear to do anything."
|
||||
fast_preheat/natural_steam were shipped unconditionally (no exists_fn)
|
||||
as an unverified guess -- issue #183's dump reports neither token in
|
||||
its options[] at all, so both switches were phantom controls that
|
||||
"don't appear to do anything."
|
||||
|
||||
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
|
||||
identical per-token exists_fn on its own options[]-array href: a stub
|
||||
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
|
||||
without this an entity whose token is genuinely present would never get
|
||||
a first chance to bind, since exists_fn runs before that first real
|
||||
fetch lands.
|
||||
identical exists_fn: a stub /device/0 seed rep has no options[] at all,
|
||||
and without this a genuinely-present token would never get a first
|
||||
chance to bind.
|
||||
"""
|
||||
return lambda rep, resources: (
|
||||
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||
@@ -163,13 +144,10 @@ def _has_option(prefix):
|
||||
def _option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write, mirroring
|
||||
laundry.option_write. NOT independently confirmed on an oven -- issue
|
||||
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
|
||||
This extrapolates that same vendor field/contract to the oven's
|
||||
/mode/vs/0, on the assumption the firmware handles the array the same
|
||||
way there. If that assumption is wrong for some oven, a device that
|
||||
replaces the field outright instead of merging would drop every other
|
||||
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
|
||||
real device report surfaces that."""
|
||||
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0;
|
||||
this extrapolates the same contract here. If some oven replaces the
|
||||
field outright instead of merging, this would drop every other option
|
||||
on the next write -- revisit if a real device report surfaces that."""
|
||||
return [f"{prefix}_{new_value}"]
|
||||
|
||||
|
||||
@@ -318,13 +296,11 @@ OVEN_CAVITY = Capability(
|
||||
|
||||
|
||||
def _oven_temp_unit(rep):
|
||||
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
|
||||
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
||||
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
||||
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
||||
Keeps the verified '°C' default when the field is absent (the original
|
||||
NV7000BS-class dump this module was written against), but reads it live
|
||||
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
|
||||
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this aggregate
|
||||
`/temperatures/vs/0` items[] resource carries a per-item `unit` field
|
||||
that was previously hardcoded away (issue #7). Keeps the verified '°C'
|
||||
default when the field is absent, but reads it live -- issue #44's
|
||||
range dump is the first to report 'Fahrenheit' here."""
|
||||
items = rep.get("x.com.samsung.da.items") or []
|
||||
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
||||
return normalize_temp_unit(unit, default="°C")
|
||||
@@ -404,17 +380,12 @@ OVEN_CONNECTED = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Static cavity capability metadata (count/type/supported features) -- no
|
||||
# per-cavity data varies at runtime on any dump seen so far (issue #44's
|
||||
# range: single cavity, no supportedFeatureList entries). Bound with no
|
||||
# entities purely for coverage; revisit if a multi-cavity dump surfaces
|
||||
# fields worth exposing.
|
||||
# Static cavity capability metadata -- no per-cavity data varies at runtime
|
||||
# on any dump seen so far. Bound with no entities purely for coverage.
|
||||
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
|
||||
|
||||
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
|
||||
# string (language/menu/servingSize/option) with every field blank on the
|
||||
# only dump seen, and no documented write contract. No entity to bind per
|
||||
# the 'don't guess' rule; a bare Capability still marks the href covered.
|
||||
# Quick-recipe display blob (combi microwave, issue #121) -- every field
|
||||
# blank on the only dump seen, no documented write contract.
|
||||
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
|
||||
|
||||
OVEN_MODE = Capability(
|
||||
@@ -430,10 +401,17 @@ OVEN_MODE = Capability(
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write,
|
||||
),
|
||||
# No exists_fn on the NV7000BS-class board this was proven against
|
||||
# (UpperLamp_ is always in its options[]) -- but issue #300's
|
||||
# steam-oven-class WALLOVEN board's options[] has no UpperLamp_
|
||||
# token at all, so this was a phantom, always-off, write-does-
|
||||
# nothing switch there. Same fastpreheat/NaturalSteam-class gap
|
||||
# issue #183 already fixed on the other switches below.
|
||||
SwitchDesc(
|
||||
key="lamp",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:track-light",
|
||||
exists_fn=_has_option("UpperLamp"),
|
||||
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
|
||||
write_fn=_option_switch_write("UpperLamp"),
|
||||
),
|
||||
@@ -462,9 +440,8 @@ OVEN_MODE = Capability(
|
||||
write_fn=_option_switch_write("NaturalSteam"),
|
||||
),
|
||||
# 120-hour energy-saving standby (issue #183): confirmed present in
|
||||
# this unit's options[] (EnergySaving_On) and directly requested --
|
||||
# unlike fast_preheat/natural_steam above, this token is real on this
|
||||
# hardware, just previously unbound entirely.
|
||||
# this unit's options[] -- unlike fast_preheat/natural_steam above,
|
||||
# this token is real on this hardware, just previously unbound.
|
||||
SwitchDesc(
|
||||
key="energy_saving",
|
||||
field="x.com.samsung.da.options",
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
|
||||
model TP1X_DA-KS-RANGE-0102X).
|
||||
|
||||
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
|
||||
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
|
||||
burner state as strings inside /mode/vs/0's options array instead of the
|
||||
structured /cooktop/status/vs/0 resource this module reads -- two different
|
||||
OCF surfaces that happen to share the English word "cooktop".
|
||||
Not to be confused with registry/capabilities/cooktop.py, which covers an
|
||||
unrelated standalone-cooktop product (NA9300K-class) that encodes burner
|
||||
state as strings inside /mode/vs/0's options array instead of the
|
||||
structured /cooktop/status/vs/0 resource this module reads -- two
|
||||
different OCF surfaces that happen to share the English word "cooktop".
|
||||
|
||||
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
|
||||
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already
|
||||
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
|
||||
names (no `x.com.samsung.da.` prefix).
|
||||
|
||||
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
|
||||
array (indexed by `burnerNumber`, not by a separate href per burner like
|
||||
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
|
||||
and gated by exists_fn against whichever indices the device actually
|
||||
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
|
||||
an index absent from burnerList just never binds.
|
||||
`/cooktop/status/vs/0` carries every burner's live state in one
|
||||
`burnerList` array (indexed by `burnerNumber`), so per-burner entities are
|
||||
hardcoded up to MAX_BURNERS and gated by exists_fn against whichever
|
||||
indices the device actually reports -- an index absent from burnerList
|
||||
just never binds.
|
||||
|
||||
Write surfaces here are unproven (no live device to verify against, same
|
||||
caveat as oven.py's RMW writes) -- power level uses the same read-modify-
|
||||
write pattern already proven safe elsewhere in this codebase (oven setpoint,
|
||||
icemaker toggles).
|
||||
caveat as oven.py's RMW writes) -- power level uses the same
|
||||
read-modify-write pattern already proven safe elsewhere in this codebase.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
|
||||
# burners exists. Kept a little above both since exists_fn gates unused
|
||||
# slots out -- see module docstring.
|
||||
# Observed as high as 4; user-reported hardware with 5 burners exists.
|
||||
# Kept a little above both since exists_fn gates unused slots out.
|
||||
MAX_BURNERS = 6
|
||||
|
||||
|
||||
@@ -140,11 +136,10 @@ COOKTOP_STATUS = Capability(
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
|
||||
# The cooktop section's own on/off (issue #86) -- distinct from
|
||||
# common.POWER's /power/0 or /power/vs/0, which some boards in this
|
||||
# family (the range combo) additionally carry for the whole
|
||||
# appliance. Read-only: no live device to confirm remotely turning
|
||||
# a cooktop on wouldn't leave a burner active unattended.
|
||||
# The cooktop section's own on/off (issue #86), distinct from
|
||||
# common.POWER's whole-appliance switch. Read-only: no live device
|
||||
# to confirm remotely turning it on wouldn't leave a burner active
|
||||
# unattended.
|
||||
BinarySensorDesc(
|
||||
key="cooktop_power",
|
||||
field="power",
|
||||
@@ -153,12 +148,13 @@ COOKTOP_STATUS = Capability(
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
),
|
||||
# Safe to write -- a lock toggle, not a heat control -- via a
|
||||
# direct single-field PUT (no RMW needed; unlike burnerList this
|
||||
# is a lone scalar, not an array of siblings to preserve).
|
||||
# direct single-field PUT, no RMW needed. No device_class:
|
||||
# SwitchDeviceClass only has 'outlet'/'switch', not 'lock' --
|
||||
# passing it crashed switch platform setup for the whole device
|
||||
# (issue #349, same bug as water_purifier.py's lock switches).
|
||||
SwitchDesc(
|
||||
key="cooktop_child_lock",
|
||||
field="childLock",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
icon="mdi:lock",
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
@@ -169,16 +165,14 @@ COOKTOP_STATUS = Capability(
|
||||
)
|
||||
|
||||
# Static burner-count/power-level-list metadata, read directly by
|
||||
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
|
||||
# than exposed through its own entity -- same "informs another capability,
|
||||
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
|
||||
# COOKTOP_STATUS's power-level select (options=_power_level_options)
|
||||
# rather than exposed through its own entity.
|
||||
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
|
||||
|
||||
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
|
||||
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
|
||||
# feature itself is enabled -- not a live "surface is hot right now" alert
|
||||
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
|
||||
# verified, so read-only for now.
|
||||
# duration; state on/off is whether the feature itself is enabled -- not a
|
||||
# live "surface is hot right now" alert (that's COOKTOP_STATUS's per-burner
|
||||
# hot_surface). No write contract verified, so read-only for now.
|
||||
COOKTOP_SAFETY = Capability(
|
||||
href="/cooktop/settings/status/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -186,7 +180,7 @@ COOKTOP_SAFETY = Capability(
|
||||
BinarySensorDesc(
|
||||
key="cooktop_safety_shutoff_enabled",
|
||||
field="safetyAlert",
|
||||
entity_category="config",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: (v or {}).get("state") == "on",
|
||||
),
|
||||
),
|
||||
@@ -194,9 +188,8 @@ COOKTOP_SAFETY = Capability(
|
||||
|
||||
# Bluetooth meat probe (issue #86). All-idle sentinel values when
|
||||
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
|
||||
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of
|
||||
# showing a disconnected accessory's fields plainly rather than hiding the
|
||||
# whole capability.
|
||||
# gating, matching cooktop.PAIRED_HOOD_STATUS's precedent of showing a
|
||||
# disconnected accessory's fields plainly rather than hiding the capability.
|
||||
PROBE_STATUS = Capability(
|
||||
href="/bluetooth/probe/status/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -232,14 +225,12 @@ PROBE_STATUS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
|
||||
# burner array at all -- their local API only exposes this coarse
|
||||
# monitoring resource for the cooktop half, with no per-burner detail.
|
||||
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
|
||||
# `warmingCenterState`'s full value set aren't confirmed, so both are
|
||||
# exposed as plain sensors rather than guessed at as a switch/select --
|
||||
# `supportedHoodLampStateList` has no corresponding live-state field on
|
||||
# this resource, so nothing to bind it to yet.
|
||||
# Some range boards (issue #74) report no /cooktop/status/vs/0 burner
|
||||
# array at all -- their local API only exposes this coarse monitoring
|
||||
# resource, with no per-burner detail. Meaning of `cooktopMonitoring`
|
||||
# (bare "0" on the only dump seen) and `warmingCenterState`'s full value
|
||||
# set aren't confirmed, so both are plain sensors rather than a guessed
|
||||
# switch/select.
|
||||
COOKTOP_MONITORING = Capability(
|
||||
href="/cooktopmonitoring/vs/0",
|
||||
poll_tier="warm",
|
||||
|
||||
@@ -7,8 +7,6 @@ brightness remain separate controls because the device advertises them as two
|
||||
independent fields.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
@@ -19,14 +17,7 @@ from ..entities import (
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
|
||||
def _timestamp(value):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=UTC)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
from .common import epoch_to_utc, int_or_none, sensor_item_value
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
@@ -191,18 +182,13 @@ HOOD_FILTER = Capability(
|
||||
)
|
||||
|
||||
|
||||
# After Run (issue #147): the hood keeps the fan running at low speed for a
|
||||
# while after it's switched off, to clear residual cooking smoke -- a
|
||||
# feature a user actively watches and cancels, not passive diagnostics, so
|
||||
# none of the three entities below carry entity_category. No
|
||||
# supported-values list is advertised for activationState, so it's modeled
|
||||
# read-only (monitoring, not an invented "enable" write) per the 'don't
|
||||
# guess' rule; runningCancel's only observed value is the command name
|
||||
# itself ('Cancel'), the same self-describing command-field shape as
|
||||
# operational.STOP_BUTTON. runningProgress's own name states its domain
|
||||
# (a percentage of the cycle completed), so it's modeled as one rather than
|
||||
# left an opaque passthrough -- unlike activationState/runningCancel, there's
|
||||
# no ambiguous field name or missing-write-contract question here to hedge on.
|
||||
# After Run (issue #147): the hood keeps the fan running at low speed after
|
||||
# it's switched off, to clear residual cooking smoke -- a feature a user
|
||||
# actively watches and cancels, so none of the three entities below carry
|
||||
# entity_category. No supported-values list is advertised for
|
||||
# activationState, so it's read-only monitoring rather than an invented
|
||||
# "enable" write; runningCancel's only observed value is the command name
|
||||
# itself ('Cancel'), the same shape as operational.STOP_BUTTON.
|
||||
AFTER_RUN = Capability(
|
||||
href="/afterrun/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -286,7 +272,7 @@ AIR_LEVEL_CHECK = Capability(
|
||||
field="x.com.samsung.da.lastSensingTime",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_timestamp,
|
||||
value_fn=epoch_to_utc,
|
||||
),
|
||||
SensorDesc(
|
||||
key="last_air_sensing_level",
|
||||
|
||||
@@ -27,64 +27,53 @@ from .laundry import (
|
||||
hex_pairs,
|
||||
option_value,
|
||||
option_write,
|
||||
washer_cycle_fallback,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Course_XX hex codes. 23 of the codes named in translations/en.json
|
||||
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
|
||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
||||
# positionally against a Slovak-UI user's screenshots of their app's course
|
||||
# list (same order, same count -- see issue #2) and cross-checked against
|
||||
# the printed user manual's course table (confirming e.g. '8F' as 'Intense
|
||||
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a
|
||||
# cycle the manual marks "applicable models only" and that does not appear
|
||||
# in this device's editCourseList -- nor does 'AI Wash', also "applicable
|
||||
# models only"). FixedCourseList_1C29 (the two courses always pinned in the
|
||||
# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what
|
||||
# you'd expect to be pinned (default cycle + maintenance cycle),
|
||||
# corroborating the positional match.
|
||||
# Course_XX hex code labels (translations/en.json,
|
||||
# washer_cycle_table_02.state.<id>) come from several devices, cross-checked
|
||||
# rather than guessed: 23 codes from a live WW90DG6U25LEU4's editCourseList,
|
||||
# matched positionally against a user's app screenshots and the printed
|
||||
# manual (issue #2); 5 more (Wash+Dry, Air Wash, Cotton Dry, Synthetics Dry,
|
||||
# a second distinct '1F' Intense Cold) from a WD90T654DBN/S1 combo's own
|
||||
# editCourseList and screenshots (issue #22, a combo's own course set, not
|
||||
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
|
||||
# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
|
||||
# code after selecting each cycle on the appliance (issue #80). Two code
|
||||
# pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels)
|
||||
# legitimately share a label across different course tables -- not typos.
|
||||
#
|
||||
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
|
||||
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
|
||||
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
|
||||
# combo's editCourseList and were named from that user's app screenshot
|
||||
# (issue #22). Combo units carry their own course set, so these codes
|
||||
# don't imply anything about '1F' on a plain washer.
|
||||
# No static fallback list is kept here: other models have different actual
|
||||
# course sets, so hardcoding one device's list would show/hide the wrong
|
||||
# options elsewhere. laundry.cycle_options() reads only the live
|
||||
# x.com.samsung.da.editCourseList; a device that doesn't populate it gets no
|
||||
# cycle select at all (see cycle_select's exists_fn). x.com.samsung.da.
|
||||
# options' MostUsed_* entry was considered as a fallback source (its first
|
||||
# byte matches the selected Course_XX on both dumps), but the remaining
|
||||
# bytes don't decode to any confirmed course code, so it isn't used.
|
||||
#
|
||||
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a
|
||||
# WF50A8600AV/US, verified directly rather than by inference: the reporter
|
||||
# selected each cycle on the physical appliance and read back the resulting
|
||||
# raw code from the cycle_select entity's state (issue #80). '54' shares a
|
||||
# display name with the existing '24' Towels -- a different code on a
|
||||
# different course table legitimately landing on the same label, not a typo
|
||||
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
|
||||
# The owner of a Korean Table_02 washer confirmed the names for its newer
|
||||
# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names
|
||||
# live only in the table-scoped translation catalog; a code not confirmed by
|
||||
# the owner or device metadata falls back to washer_cycle_fallback, which
|
||||
# surfaces a personal-course name only -- no invented English label for an
|
||||
# unrecognized standard code (PR #251 review).
|
||||
#
|
||||
# No static fallback list of those codes is kept here, deliberately: other
|
||||
# washer models have a different actual course set (a second dump's active
|
||||
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
|
||||
# Load' -- both "applicable models only" per the manual -- would have yet
|
||||
# another set), so hardcoding one device's list would show/hide the wrong
|
||||
# options on a different model. laundry.cycle_options() reads only the live
|
||||
# x.com.samsung.da.editCourseList; if a device doesn't populate that
|
||||
# resource, the cycle select isn't created at all (see cycle_select's
|
||||
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
|
||||
# fallback source (its first byte reliably equals the currently-selected
|
||||
# Course_XX on both dumps we have), but the bytes after that don't
|
||||
# correspond to any confirmed course code on either device -- e.g. dump 1's
|
||||
# MostUsed_1C8410923FA67F00000000000000 decodes to
|
||||
# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code --
|
||||
# so it isn't trustworthy as a list of selectable courses and isn't used.
|
||||
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
|
||||
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
|
||||
# cycle on the appliance and reading back the raw code, the same method used
|
||||
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
|
||||
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
|
||||
# instance) still renders that code raw rather than borrowing a Table_02
|
||||
# label -- the two tables are unrelated code spaces despite a handful of
|
||||
# overlapping hex values.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
|
||||
#
|
||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
|
||||
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
|
||||
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
|
||||
# path for different device families. Only one of the two ever binds for a
|
||||
# given device, since dryer and washer are separate by_type registries.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WASHER_SETTINGS = Capability(
|
||||
href="/washer/vs/0",
|
||||
@@ -123,9 +112,8 @@ WASHER_SETTINGS = Capability(
|
||||
),
|
||||
),
|
||||
# Washer/dryer combo units carry a dryLevel field on the wash
|
||||
# resource itself (no separate dryer device/course) -- see issue
|
||||
# #22. Self-gates off on plain washers, which never report
|
||||
# supportedDryLevel.
|
||||
# resource itself (issue #22). Self-gates off on plain washers,
|
||||
# which never report supportedDryLevel.
|
||||
SelectDesc(
|
||||
key="dry_level",
|
||||
field="x.com.samsung.da.dryLevel",
|
||||
@@ -142,12 +130,9 @@ WASHER_SETTINGS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
|
||||
# drum-clean and dispenser-dosing entities below are washer-specific reads off
|
||||
# the same options array.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# drum-clean and dispenser-dosing entities below are washer-specific reads
|
||||
# off the same options array.
|
||||
|
||||
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
|
||||
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
|
||||
@@ -157,30 +142,17 @@ WASHER_SETTINGS = Capability(
|
||||
|
||||
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
||||
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
|
||||
# detergent, concentration for softener -- matching the SmartThings app's
|
||||
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
|
||||
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
|
||||
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
|
||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
||||
# '<Prefix>Level2Ctrl_<code>' is a second dial (water hardness for
|
||||
# detergent, concentration for softener), matching the app's two-field
|
||||
# dispenser screens. 'Supported<Prefix>Ctrl_<hexpairs>' lists the valid raw
|
||||
# codes, same hex-pair shape as EditCourseList. '<Prefix>Alarm_<On/Off>' is
|
||||
# a low-reservoir warning flag.
|
||||
#
|
||||
# Label mapping (entity.select.{detergent,softener}_quantity /
|
||||
# detergent_water_hardness / softener_concentration in translations/en.json) is an
|
||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
||||
# on-screen equivalent -- the app's Quantité picker only offers
|
||||
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
|
||||
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
|
||||
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
|
||||
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
|
||||
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
|
||||
# 1x/2x/3x for softener concentration does *not* cleanly match
|
||||
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
|
||||
# setting the user changed in the app between the dump (issue body) and the
|
||||
# screenshots (a later comment), not a different code scheme, since it's
|
||||
# otherwise identical in shape to the detergent side. Revisit if a second
|
||||
# device's dump contradicts this.
|
||||
# Label mapping (translations/en.json's {detergent,softener}_quantity /
|
||||
# detergent_water_hardness / softener_concentration) is an assumed reading
|
||||
# of the single issue #9 dump + screenshots, cross-checked against the
|
||||
# selected value on both dispensers, not independently verified per code --
|
||||
# revisit if a second device's dump contradicts it.
|
||||
def _supported_level_options(resources, prefix):
|
||||
rep = resources.get("/course/vs/0") or {}
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
|
||||
@@ -192,15 +164,13 @@ def _level_options(prefix):
|
||||
|
||||
|
||||
def _dosing_level(prefix):
|
||||
"""Current dose code, normalized to the `Supported<prefix>` code format.
|
||||
|
||||
The device reports the selected level as `<prefix>_<code>` with the code
|
||||
un-padded (e.g. '3'), but the valid codes -- which are also this select's
|
||||
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
|
||||
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
|
||||
outside the select's own option list, so HA renders it 'unknown' (issue #9).
|
||||
Resolve it to the supported code with the same integer value so
|
||||
current_option matches an option (and its translation)."""
|
||||
"""Current dose code, normalized to the `Supported<prefix>` code
|
||||
format. The device reports the selected level as `<prefix>_<code>`
|
||||
un-padded (e.g. '3'), but the select's own options come from
|
||||
`Supported<prefix>_<hexpairs>` as zero-padded hex pairs (e.g. '03').
|
||||
Left as '3', the value sits outside the select's own option list and
|
||||
HA renders it 'unknown' (issue #9) -- resolve it to the matching
|
||||
zero-padded code instead."""
|
||||
|
||||
def fn(rep):
|
||||
opts = rep.get("x.com.samsung.da.options")
|
||||
@@ -227,9 +197,8 @@ def _level_write(prefix):
|
||||
def write(p, rep, href=None):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
# `p` is the zero-padded supported code the UI selected (e.g. '03');
|
||||
# the device stores the level un-padded (e.g. '3'), matching how it
|
||||
# reports it, so write it back in that native shape.
|
||||
# `p` is the zero-padded supported code (e.g. '03'); the device
|
||||
# stores it un-padded (e.g. '3'), matching how it's reported.
|
||||
try:
|
||||
native = format(int(p, 16), "X")
|
||||
except (TypeError, ValueError):
|
||||
@@ -248,37 +217,28 @@ def _dosing_low(prefix):
|
||||
|
||||
|
||||
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
|
||||
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
|
||||
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
|
||||
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
|
||||
# already used by AiOption and KidsLockBypass in this same array, so
|
||||
# PreWashSetting/IntensiveSetting are assumed to follow suit.
|
||||
# array (issue #22 follow-up). Each rides as a plain '<Prefix>_On'/'_Off'
|
||||
# token, confirmed against a dump taken with Bubble Soak switched on in the
|
||||
# app -- the same shape as AiOption/KidsLockBypass in this array.
|
||||
#
|
||||
# Each also has a differently-named hex-pair availability field that lines up
|
||||
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
|
||||
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
|
||||
# 24), all three read 'F0' at that position and the toggle was writable --
|
||||
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
|
||||
# Off') decodes to '00' for that course, matching the app graying the
|
||||
# control out there. 'F0'/'00' is treated as available/unavailable on that
|
||||
# evidence. exists_fn (device-level presence) still only runs once, against
|
||||
# the setup-time snapshot, so it isn't a fit for this per-course check --
|
||||
# validate_fn runs on every write attempt instead (dispatched from
|
||||
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
|
||||
# for a course whose byte isn't 'F0' with a user-facing error rather than
|
||||
# silently no-opping against the device. The read/write/presence machinery
|
||||
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
|
||||
# auto-release-dry toggles -- only this per-course gating is washer-only, so
|
||||
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
|
||||
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
|
||||
# Each also has a hex-pair availability field positional with
|
||||
# editCourseList (BubbleSoakSet, PreWashAvailableSet,
|
||||
# IntensiveAvailableSet): on the reporter's dump 'F0' at a course's
|
||||
# position matched the app enabling the control there, '00' matched it
|
||||
# grayed out. exists_fn only runs once at setup, so it can't do this
|
||||
# per-course check -- validate_fn runs on every write attempt instead,
|
||||
# rejecting an on-write for a course whose byte isn't 'F0' with a
|
||||
# user-facing error rather than silently no-opping. The read/write/
|
||||
# presence machinery is laundry.bool_option_switch, shared with
|
||||
# dishwasher's storm-wash/auto-release-dry toggles; only this per-course
|
||||
# gating is washer-only.
|
||||
def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
def validate(p, rep, resources):
|
||||
"""Reject turning on when the selected course's byte in
|
||||
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
||||
back to allowing the write whenever the availability data can't be
|
||||
resolved (unrecognized course, missing/mismatched-length bitmap)
|
||||
rather than guessing -- a false rejection is worse than an
|
||||
occasional no-op write."""
|
||||
`availability_field` isn't 'F0'. Turning off is never blocked.
|
||||
Falls back to allowing the write whenever the availability data
|
||||
can't be resolved (unrecognized course, missing/mismatched-length
|
||||
bitmap) -- a false rejection is worse than an occasional no-op."""
|
||||
if p != "On":
|
||||
return None
|
||||
opts = rep.get("x.com.samsung.da.options") or []
|
||||
@@ -308,6 +268,7 @@ WASHER_COURSE = Capability(
|
||||
translation_key="washer_cycle",
|
||||
icon="mdi:washing-machine",
|
||||
table_href="/st/washercourse/vs/0",
|
||||
display_fn=washer_cycle_fallback,
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
|
||||
@@ -25,19 +25,13 @@ DISPENSE = Capability(
|
||||
{"x.com.samsung.da.desiredType": p},
|
||||
),
|
||||
),
|
||||
# Only a handful of discrete temperatures are selectable (not a
|
||||
# continuous range) -- a select over the live-reported set, not a
|
||||
# number with invented bounds.
|
||||
#
|
||||
# Newer boards (issue #196, RWP70F15ANW) don't populate
|
||||
# supportedHotTemperatures at all -- they report a hotwaterRange
|
||||
# (min/max) and a hotwaterLevel (step count?) instead, with no
|
||||
# confirmed write contract for values off the old preset list. With
|
||||
# an empty options_field result, HA's current_option still returns
|
||||
# the live tempDesiredHotWater, which isn't in the (empty) options
|
||||
# list and renders as "unknown" -- the exact symptom reported. Gate
|
||||
# the entity off entirely when the board doesn't report a supported
|
||||
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
|
||||
# Only a handful of discrete temperatures are selectable -- a select
|
||||
# over the live-reported set, not a number with invented bounds.
|
||||
# Newer boards (issue #196) don't populate supportedHotTemperatures
|
||||
# at all, reporting a hotwaterRange/hotwaterLevel pair instead with
|
||||
# no confirmed write contract -- gate the entity off entirely there
|
||||
# rather than guess at that pair's meaning (an empty options list
|
||||
# otherwise left current_option rendering "unknown").
|
||||
SelectDesc(
|
||||
key="hot_water_temperature",
|
||||
field="x.com.samsung.da.tempDesiredHotWater",
|
||||
@@ -53,12 +47,10 @@ DISPENSE = Capability(
|
||||
),
|
||||
),
|
||||
# Bounds and step come live from the device's own
|
||||
# desiredCapacityRange/capacityResolution fields, not a hardcoded
|
||||
# constant -- see the adding-device-support skill's "never hard-code
|
||||
# the one dump's values" section. No unit is set: capacityUnit reads
|
||||
# "C" on this dump, which can't be right for a volume field, so per
|
||||
# the 'don't guess' rule the unit is left unset rather than assumed
|
||||
# to be mL.
|
||||
# desiredCapacityRange/capacityResolution, not a hardcoded constant.
|
||||
# No unit is set: capacityUnit reads "C" on this dump, which can't
|
||||
# be right for a volume field, so it's left unset rather than
|
||||
# assumed to be mL.
|
||||
NumberDesc(
|
||||
key="dispense_capacity",
|
||||
field="x.com.samsung.da.desiredCapacity",
|
||||
@@ -159,24 +151,16 @@ FAVORITE_CAPACITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
|
||||
# for the hot water dispensed alongside brewing, same shape as
|
||||
# FAVORITE_CAPACITY above.
|
||||
|
||||
|
||||
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
|
||||
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
|
||||
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favoring
|
||||
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
|
||||
still ambiguous:
|
||||
|
||||
- href entirely absent from this device -> definitely no clash, the
|
||||
switchHotwater fallback below may claim the entity.
|
||||
- href present but an unfetched stub ({}) -> outcome pending, *not* a
|
||||
confirmed absence. LOCK's own exists_fn optimistically includes itself
|
||||
through a stub (matching entity.py's default), so returning True here
|
||||
too would register both descriptors -- as SwitchDescs sharing one key,
|
||||
with identical unique_ids -- until the next poll resolves it.
|
||||
- href present and fetched -> the real answer."""
|
||||
still ambiguous: href absent -> True (fallback may claim the entity);
|
||||
href present but an unfetched stub ({}) -> False (pending, not
|
||||
confirmed absence -- LOCK's own exists_fn optimistically includes
|
||||
itself through a stub too, so returning True would register both
|
||||
descriptors under one key until the next poll); href present and
|
||||
fetched -> the real answer."""
|
||||
rep = resources.get("/status/lock/vs/0")
|
||||
if rep is None:
|
||||
return True
|
||||
@@ -189,28 +173,24 @@ FAVORITE_HOTWATER = Capability(
|
||||
href="/favorite/hotwater/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# Despite the resource/field naming, switchHotwater's value domain is
|
||||
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
|
||||
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
|
||||
# this href on boards that don't populate /status/lock/vs/0's
|
||||
# hotwaterLock field. Shares that descriptor's key so only one "Hot
|
||||
# water lock" entity ever appears.
|
||||
#
|
||||
# Both halves of this fallback pair need an exists_fn, not just this
|
||||
# one: adapter.flatten() (the coordinator.data source every entity's
|
||||
# is_on reads) only ever honours exists_fn, never entity.py's
|
||||
# implicit "require own field present" default that gates plain
|
||||
# registration. Two same-keyed descriptors with only one of them
|
||||
# gated still both land in flatten()'s output dict -- whichever is
|
||||
# processed last silently wins, decided by device-reported href
|
||||
# order, not by which one is actually correct. So this exists_fn
|
||||
# also re-asserts its own field's presence (switchHotwater), the
|
||||
# gate a bare `field=` used to get for free before it had to share a
|
||||
# key with LOCK's descriptor.
|
||||
# Despite the naming, switchHotwater's value domain is
|
||||
# Locked/Unlocked, not an enable flag (issue #144) -- the same
|
||||
# hot-water lock as LOCK.hotwater_lock below, surfaced through this
|
||||
# href on boards that don't populate /status/lock/vs/0's
|
||||
# hotwaterLock. Shares that descriptor's key so only one "Hot water
|
||||
# lock" entity appears; both halves need an exists_fn since
|
||||
# adapter.flatten() only ever honors exists_fn, not entity.py's
|
||||
# implicit field-presence default -- without it, whichever
|
||||
# same-keyed descriptor is processed last would silently win.
|
||||
# No device_class: SwitchDeviceClass only has 'outlet'/'switch',
|
||||
# not 'lock' -- passing it crashed switch platform setup entirely
|
||||
# for the whole device (issue #349), same bug KIDS_LOCK_GENERIC
|
||||
# dodged by switching to BinarySensorDesc (issues #181/#183). This
|
||||
# entity stays a SwitchDesc since it's genuinely writable.
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.switchHotwater",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
exists_fn=lambda rep, resources: (
|
||||
@@ -222,18 +202,12 @@ FAVORITE_HOTWATER = Capability(
|
||||
{"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.
|
||||
# Issue #196: `supportedList` is only the four fixed presets -- the
|
||||
# app also lets the user add one custom value to their own display
|
||||
# list, which shows up in `showList` but never in `supportedList`.
|
||||
# Reading from `supportedList` meant a unit whose current default
|
||||
# was that custom value rendered as "unknown"; `showList` is a
|
||||
# superset that always includes the actual current default.
|
||||
SelectDesc(
|
||||
key="favorite_hotwater_temperature",
|
||||
field="x.com.samsung.da.favorite.defaultTemperature",
|
||||
@@ -291,11 +265,9 @@ CUP_STATE = Capability(
|
||||
)
|
||||
|
||||
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
|
||||
# air_purifier.py's same-named hrefs, but this board's own values differ
|
||||
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
|
||||
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
|
||||
# reject a live-supported value, so these read the device's own supported
|
||||
# list/range like air_purifier's versions do.
|
||||
# air_purifier.py's same-named hrefs, but this board's own supportedModes
|
||||
# (voice/fixedTone/mute) differs from both, so these read the device's own
|
||||
# supported list/range rather than reusing either.
|
||||
SOUND_MODE = Capability(
|
||||
href="/settings/sound/mode/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -359,9 +331,8 @@ SOUND_VOLUME = Capability(
|
||||
)
|
||||
|
||||
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
|
||||
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
|
||||
# which at least has an -- albeit suspect -- capacityUnit) so it's left
|
||||
# unitless rather than assumed to be mL.
|
||||
# (no sibling unit field on this resource) so it's left unitless rather
|
||||
# than assumed to be mL.
|
||||
STATISTIC_POUR = Capability(
|
||||
href="/statistic/pour/vs/0",
|
||||
poll_tier="cold",
|
||||
@@ -387,16 +358,14 @@ LOCK = Capability(
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
||||
# above (issue #144); see the comment there for why this half also
|
||||
# needs an explicit exists_fn now that the two share a key in
|
||||
# adapter.flatten()'s output. A stub rep ({}) still counts as
|
||||
# "present" here (matches entity.py's own default for a field-less
|
||||
# gate) since the alternative -- treating an unfetched resource as
|
||||
# confirmed-absent -- is what let both descriptors register at once.
|
||||
# above (issue #144); see the comment there. A stub rep ({}) still
|
||||
# counts as "present" here, matching entity.py's own default. No
|
||||
# device_class on any of the three locks below -- see the
|
||||
# device_class note on FAVORITE_HOTWATER's hotwater_lock (issue #349).
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.hotwaterLock",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
|
||||
@@ -408,7 +377,7 @@ LOCK = Capability(
|
||||
SwitchDesc(
|
||||
key="coldwater_lock",
|
||||
field="x.com.samsung.da.coldwaterLock",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -419,7 +388,7 @@ LOCK = Capability(
|
||||
SwitchDesc(
|
||||
key="buzz_lock",
|
||||
field="x.com.samsung.da.buzzLock",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -430,29 +399,21 @@ LOCK = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
|
||||
# confirmed contract, following the 'don't guess' rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
_WP_IGNORED = [
|
||||
# supportedModes carries a single opaque wizard-workflow token
|
||||
# ('HOMECARE_WIZARD_V2') and modes reports a completely different,
|
||||
# unrelated value ('WATERFILTER_DISABLE') not even present in
|
||||
# supportedModes -- internal plumbing, not a real user-facing mode
|
||||
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
|
||||
# already covered by the global ignored.IGNORED (fridge's OCF-native
|
||||
# vacation-mode flag shares that href).
|
||||
# supportedModes carries a single opaque wizard-workflow token and
|
||||
# modes reports an unrelated value not even in supportedModes --
|
||||
# internal plumbing, not a real mode select.
|
||||
"/mode/vs/0",
|
||||
# Static support-flags blob (automation.supported.modes/options) -- no
|
||||
# live "current automation setting" field to expose.
|
||||
# Static support-flags blob -- no live "current setting" field.
|
||||
"/automation/waterpurifier/vs/0",
|
||||
# Coffee-capable variant (issue #107). All four are static
|
||||
# capability-advertisement blobs or empty -- no live "current recipe" /
|
||||
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0
|
||||
# (COFFEE above), which does carry live brew status.
|
||||
# Coffee-capable variant (issue #107): static capability-advertisement
|
||||
# blobs or empty, unlike /favorite/coffee/vs/0 (COFFEE above) which
|
||||
# does carry live brew status.
|
||||
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata
|
||||
"/coffee/custom/recipe/vs/0", # publisher.support: allowed custom-recipe slot IDs
|
||||
"/recipe/coffee/vs/0", # same publisher.support shape, no per-recipe content
|
||||
"/coffee/custom/recipe/vs/0", # allowed custom-recipe slot IDs
|
||||
"/recipe/coffee/vs/0", # same shape, no per-recipe content
|
||||
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
|
||||
]
|
||||
|
||||
|
||||
@@ -31,10 +31,9 @@ class BoundEntity:
|
||||
key_override: str | None = None
|
||||
instance_name: str | None = None
|
||||
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
||||
# `href` above is always the *actual*, on-the-wire href for that
|
||||
# subdevice -- MAIN's
|
||||
# to_actual is the identity transform, so every device with no subdevices
|
||||
# behaves exactly as before this field existed.
|
||||
# `href` above is always the actual, on-the-wire href for that
|
||||
# subdevice -- MAIN's to_actual is the identity transform, so a device
|
||||
# with no subdevices is unaffected.
|
||||
subdevice: Subdevice = MAIN
|
||||
|
||||
|
||||
@@ -101,24 +100,19 @@ def discover(
|
||||
tier_log: Callable[[str, str], None] | None = None,
|
||||
subdevice: Subdevice = MAIN,
|
||||
) -> list[BoundEntity]:
|
||||
"""`tier_log(href, poll_tier)` fires for every href a capability actually
|
||||
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
|
||||
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows.
|
||||
Callers that need a href's poll cadence (the coordinator's hot/warm
|
||||
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
|
||||
coverage-only capability's `poll_tier` would otherwise be silently
|
||||
dropped since it never appears in `bound`.
|
||||
"""`tier_log(href, poll_tier)` fires for every href a capability
|
||||
actually matches, even a no-entity "coverage-only" capability that
|
||||
`_bind()` turns into zero `BoundEntity` rows. Callers that need a
|
||||
href's poll cadence must use this, not `bound` -- a coverage-only
|
||||
capability's `poll_tier` would otherwise never appear in `bound`.
|
||||
|
||||
`resources` is always keyed by *canonical* hrefs -- for a subdevice
|
||||
(issue #177) that means its own canonical view (see
|
||||
subdevices.canonical_view), the same shape as a plain single-subdevice
|
||||
device's resources dict, so registry lookups/rt_filter/match_fn/
|
||||
instance_suffix all behave identically regardless of which subdevice is
|
||||
being discovered.
|
||||
`subdevice` only affects the *href* stamped onto each BoundEntity (via
|
||||
`_bind`, see above) and the href `log`/`tier_log` report -- both the
|
||||
real, subscribable/pollable path, not the canonical one the registry is
|
||||
keyed on.
|
||||
`resources` is always keyed by canonical hrefs -- for a subdevice
|
||||
(issue #177), its own canonical view (see subdevices.canonical_view),
|
||||
the same shape as a single-subdevice device's resources dict, so
|
||||
registry lookups behave identically regardless of which subdevice is
|
||||
being discovered. `subdevice` only affects the href stamped onto each
|
||||
BoundEntity and the href `log`/`tier_log` report -- the real,
|
||||
subscribable/pollable path, not the canonical one.
|
||||
"""
|
||||
out: list[BoundEntity] = []
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
|
||||
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
||||
# option list).
|
||||
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
|
||||
DisplayFn = Callable[[Any, dict], Any] | None
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -33,10 +34,10 @@ class SamsungEntityDescription:
|
||||
# here, so a descriptor only sets this to share one catalog entry across
|
||||
# several descriptors, or to point at a differently-named one.
|
||||
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
||||
# callable form receives the coordinator's full href->rep resource
|
||||
# snapshot and returns the key to use -- for a descriptor shared across
|
||||
# board generations whose state-code meaning isn't guaranteed consistent
|
||||
# between them; see laundry.cycle_select's table-id-gated resolver.
|
||||
# callable form receives the full href->rep snapshot and returns the key
|
||||
# to use -- for a descriptor shared across board generations whose
|
||||
# state-code meaning isn't consistent between them; see
|
||||
# laundry.cycle_select's table-id-gated resolver.
|
||||
translation_placeholders: Mapping[str, str] | None = None
|
||||
# Dynamic resources such as fridge compartments and ice makers use a
|
||||
# device-provided or href-derived instance label inside a translated name.
|
||||
@@ -59,11 +60,22 @@ class SensorDesc(SamsungEntityDescription):
|
||||
unit: str | None = None
|
||||
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
|
||||
options: tuple | None = 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.
|
||||
# Opt-in: gate this value behind CONF_FINISH_TIME_HYSTERESIS_MINUTES
|
||||
# (see sensor.py). Only for values expected to jitter between
|
||||
# device-side revisions -- not a general-purpose flag.
|
||||
hysteresis: bool = False
|
||||
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
|
||||
# for the full contract (arm/value/bypass semantics, one window per
|
||||
# bypass, why this never touches the coordinator cache).
|
||||
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
|
||||
# moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
|
||||
# hold and lets rep_fn's own live result through; sticky_seconds
|
||||
# bounds how long it can hold. There is deliberately no hook for
|
||||
# computing a live value differently from rep_fn -- see issue #358.
|
||||
sticky_fn: Callable[[dict], bool] | None = None
|
||||
sticky_value_fn: Callable[[dict], Any] | None = None
|
||||
sticky_bypass_fn: Callable[[dict], bool] | None = None
|
||||
sticky_seconds: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -78,6 +90,10 @@ class SelectDesc(SamsungEntityDescription):
|
||||
# snapshot (not just this entity's own href) and returns raw device
|
||||
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||
options_field: str | None = None # resource field that contains the live options list
|
||||
# Optional device-specific fallback for values absent from the translation
|
||||
# catalog. Receives (raw_value, canonical_resources); select.py applies it
|
||||
# identically to the current state and every option.
|
||||
display_fn: DisplayFn = None
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@@ -102,10 +118,9 @@ class NumberDesc(SamsungEntityDescription):
|
||||
native_min: float | None = None
|
||||
native_max: float | None = None
|
||||
step: float | None = None
|
||||
# Override native_min/native_max/step from the live rep, when set --
|
||||
# same "static default, live override" shape as unit_fn, for resources
|
||||
# whose sane bounds depend on a per-device value (e.g. a temperature
|
||||
# setpoint reported in Celsius on one device, Fahrenheit on another).
|
||||
# Override native_min/max/step from the live rep, when set -- same
|
||||
# "static default, live override" shape as unit_fn, for resources whose
|
||||
# bounds depend on a per-device value (e.g. Celsius vs. Fahrenheit).
|
||||
native_min_fn: Callable[[dict], float] | None = None
|
||||
native_max_fn: Callable[[dict], float] | None = None
|
||||
step_fn: Callable[[dict], float] | None = None
|
||||
@@ -120,27 +135,23 @@ class TimeDesc(SamsungEntityDescription):
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ClimateDesc(SamsungEntityDescription):
|
||||
# A composite entity: it binds one *primary* resource (its href) but the
|
||||
# climate platform reads sibling resources (power, temperature, wind) from
|
||||
# the coordinator snapshot and writes to several of them. write_fn takes a
|
||||
# (kind, value) payload from the platform and returns the (path_segs, body)
|
||||
# for that one sub-write, so a single desc drives multi-resource writes.
|
||||
# Composite entity: binds one primary resource (its href) but the
|
||||
# climate platform reads sibling resources from the coordinator
|
||||
# snapshot and writes to several of them. write_fn takes a (kind,
|
||||
# value) payload and returns the (path_segs, body) for that sub-write.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FanDesc(SamsungEntityDescription):
|
||||
# Composite fan entity: reads power from /power/0 and speed/support data
|
||||
# from its bound href. Payloads are (kind, value), like ClimateDesc.
|
||||
# from its bound href. Payloads are (kind, value), like ClimateDesc.
|
||||
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,
|
||||
# Composite water_heater entity, same (kind, value) -> (path_segs,
|
||||
# body) write_fn shape as ClimateDesc/FanDesc.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@@ -22,24 +22,19 @@ def is_placeholder_serial(serial: str) -> bool:
|
||||
|
||||
The ARTIK051_DONGLE_REF firmware family reports the literal string
|
||||
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
|
||||
check doesn't catch it, and the resolved 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).
|
||||
check doesn't catch it, and two such units on the same install silently
|
||||
collide, dropping the second one's entities (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.
|
||||
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry family reports a
|
||||
flash-unset sentinel instead -- every character the same repeated hex
|
||||
digit -- which the 'nothing' check doesn't catch either, aborting the
|
||||
second unit's config flow as already configured.
|
||||
|
||||
Lives here, rather than being duplicated in config_flow.py and
|
||||
coordinator.py as it once was, because the config flow now resolves the
|
||||
serial once and persists it on the entry for the coordinator to seed its
|
||||
registry keys from (issue #236). Two copies of this rule meant the two
|
||||
sides could disagree about what a device's identity is -- and a
|
||||
disagreement is exactly what orphans a registry entry.
|
||||
Lives here rather than duplicated in config_flow.py/coordinator.py: the
|
||||
config flow resolves the serial once and persists it for the
|
||||
coordinator to seed its registry keys from (issue #236), so two copies
|
||||
of this rule could let the two sides disagree and orphan a registry
|
||||
entry.
|
||||
"""
|
||||
s = serial.strip()
|
||||
if s.lower().startswith("nothing"):
|
||||
@@ -65,15 +60,12 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
|
||||
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
"""The model string to name and register a device under.
|
||||
|
||||
`model_num` is /information/vs/0's x.com.samsung.da.modelNum, which many
|
||||
boards report as `<model>|<board>` -- only the part before the pipe is the
|
||||
model a user would recognize. A board that reports no modelNum at all
|
||||
falls back to /oic/p's mnmo, which read_identity already parsed.
|
||||
|
||||
Shared with resolve_serial's motivation: the config flow resolves this
|
||||
once and persists it on the entry, and the coordinator recomputes it after
|
||||
the first poll. Two copies of the split rule would let those two disagree,
|
||||
and a device that renames itself on the first poll is the visible symptom.
|
||||
`model_num` is /information/vs/0's modelNum, which many boards report
|
||||
as `<model>|<board>` -- only the part before the pipe is recognizable.
|
||||
A board reporting no modelNum falls back to /oic/p's mnmo. Shared with
|
||||
resolve_serial's motivation: two copies of this split rule could let
|
||||
the config flow and the coordinator's post-poll recompute disagree, and
|
||||
a device renaming itself after the first poll is the visible symptom.
|
||||
"""
|
||||
if model_num:
|
||||
return model_num.split("|", 1)[0]
|
||||
@@ -81,13 +73,11 @@ def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
|
||||
|
||||
def device_display_name(device_type_name: str | None, model: str) -> str:
|
||||
"""The HA device name for a resolved device type + model.
|
||||
|
||||
Shared by the config flow (which builds the entry's stored identity) and
|
||||
the coordinator's post-discovery rebuild, so the name a device is first
|
||||
registered under is the same string discovery would produce later --
|
||||
otherwise every setup would rename the device once the first poll landed.
|
||||
"""
|
||||
"""The HA device name for a resolved device type + model. Shared by the
|
||||
config flow and the coordinator's post-discovery rebuild, so the name a
|
||||
device is first registered under matches what discovery produces later
|
||||
-- otherwise every setup would rename the device once the first poll
|
||||
landed."""
|
||||
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
|
||||
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
|
||||
@@ -120,15 +110,13 @@ def _get_links(sess, path) -> list:
|
||||
def _device_types(d: dict) -> tuple[str, ...]:
|
||||
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
|
||||
|
||||
In OCF this is the one standardized "what am I" field: alongside the
|
||||
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
|
||||
or a 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.
|
||||
The one standardized "what am I" field in OCF: alongside the generic
|
||||
'oic.wk.d' it carries a concrete type like 'oic.d.airconditioner' or a
|
||||
SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
|
||||
consults this first, via `for_device_by_oic_type`, but only a minority
|
||||
of dumps populate it, so the modelNum/description path stays
|
||||
load-bearing. Kept whole in diagnostics (see `raw` below) so issue
|
||||
reports keep surfacing types the table doesn't know about yet.
|
||||
"""
|
||||
rt = d.get("rt")
|
||||
if isinstance(rt, str):
|
||||
@@ -142,17 +130,13 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
||||
p = _get(sess, ["oic", "p"])
|
||||
d = _get(sess, ["oic", "d"])
|
||||
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
|
||||
# RETRIEVE on it returns every Resource/Collection href this endpoint
|
||||
# hosts, not just the one /device/0 seed path the coordinator polls.
|
||||
# Relevant for the OCF "Composite Device" model (issue #177: a single
|
||||
# physical device -- one IP, one /oic/p -- exposing more than one logical
|
||||
# subdevice, each as its own Collection resource, same rt shape as our own
|
||||
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
|
||||
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's
|
||||
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
|
||||
# /device/2 speculative fallback it used to run right here on every
|
||||
# _connect_session (including every reconnect), moved to that module so
|
||||
# it only runs once, at first discovery, instead of on every reconnect.
|
||||
# RETRIEVE returns every Resource/Collection href this endpoint hosts,
|
||||
# not just /device/0. Relevant for the "Composite Device" model (issue
|
||||
# #177: one physical device exposing more than one logical subdevice,
|
||||
# each its own Collection). registry.subdevices.enumerate_subdevices
|
||||
# reads this to find a board's `/device/<n>` siblings -- that probing
|
||||
# used to run right here on every _connect_session/reconnect and moved
|
||||
# to that module so it only runs once, at first discovery.
|
||||
res = _get_links(sess, ["oic", "res"])
|
||||
return DeviceIdentity(
|
||||
manufacturer=p.get("mnmn") or "Samsung",
|
||||
@@ -160,8 +144,8 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
||||
name=d.get("n") or "",
|
||||
serial=serial,
|
||||
device_types=_device_types(d),
|
||||
# Kept whole rather than field-by-field: these resources are outside
|
||||
# the /device/0 dump diagnostics already captures, and we don't yet
|
||||
# know which of their fields will turn out to identify a device type.
|
||||
# Kept whole rather than field-by-field: outside the /device/0 dump
|
||||
# diagnostics already captures, and we don't yet know which fields
|
||||
# will turn out to identify a device type.
|
||||
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
|
||||
)
|
||||
|
||||
@@ -30,19 +30,12 @@ _SENSITIVE_SUBSTRINGS = (
|
||||
"secret",
|
||||
)
|
||||
|
||||
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
|
||||
# with bare one- and two-letter keys that the rules above cannot see, being
|
||||
# far too short to match on -- 'di' alone is a substring of 'condition',
|
||||
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
|
||||
#
|
||||
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
|
||||
# number above.
|
||||
# 'n' -- /oic/d's device name. Free text the owner can set from the
|
||||
# SmartThings app, so it may well carry a person's name. Nothing
|
||||
# in the /device/0 dump has ever exposed it; it only became
|
||||
# reachable when diagnostics started reporting /oic/d, and the
|
||||
# device-type signal we actually want from that resource is `rt`,
|
||||
# which is not redacted.
|
||||
# Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the
|
||||
# unit with bare one/two-letter keys too short for the substring rules above
|
||||
# ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the
|
||||
# device/platform UUIDs; 'n' is /oic/d's free-text device name, which may
|
||||
# carry a person's name -- the device-type signal we actually want from
|
||||
# that resource is `rt`, which is not redacted.
|
||||
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
|
||||
|
||||
|
||||
|
||||
@@ -1,96 +1,43 @@
|
||||
"""Subdevice ("composite device") support for one physical connection exposing
|
||||
more than one logical indoor subdevice -- issue #177.
|
||||
"""Subdevice ("composite device") support for one physical connection
|
||||
exposing more than one logical indoor subdevice -- issue #177.
|
||||
|
||||
Two reporters, two different board families, two genuinely different
|
||||
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
|
||||
session (see DESIGN-177.md section 1 for the full evidence trail; the two
|
||||
diagnostics dumps this was built against come from the Pattern A and
|
||||
Pattern B reporters, respectively -- they each filed one of the two
|
||||
reports this module unifies):
|
||||
Three discovery patterns, unified by the same shape: a logical subdevice is
|
||||
a seed collection path to poll, plus an href transform between the
|
||||
canonical href the registry knows (e.g. `/mode/vs/0`) and the actual
|
||||
on-the-wire href.
|
||||
|
||||
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's
|
||||
board). `/oic/res` lists three complete parallel resource sets whose
|
||||
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`,
|
||||
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s
|
||||
batch carries only the index-0 hrefs -- the sibling subdevices are
|
||||
reachable only via their own `/device/<n>` collection.
|
||||
- **Pattern A -- indexed siblings** (`ARTIK051_DONGLE_FAC_18K`). `/oic/res`
|
||||
lists parallel resource sets by trailing index (`/mode/vs/0`,
|
||||
`/mode/vs/1`, ...); each sibling has its own `/device/<n>` Collection.
|
||||
- **Pattern B -- UUID-prefixed tree** (`TP2X_FAC_BORA_21K`). `/oic/res`
|
||||
hides the tree; `/subdevices/vs/0`'s `subdeviceIdList` gives the UUID.
|
||||
`GET /<uuid>/device/0` is tried first; when it comes back empty (issue
|
||||
#205 -- not even the reference board always exposes it), this falls back
|
||||
to probing every href the master answered this cycle individually under
|
||||
the UUID prefix (see `Subdevice.flat_hrefs`).
|
||||
- **Pattern C -- UUID prefix via `/oic/res` only** (`AWM-WW-AID-26-ONEBODY`
|
||||
washer+dryer combo, issue #241). No `subdeviceIdList`, `/device/<n>`
|
||||
404s; the sibling's UUID only appears as a link prefix in `/oic/res`
|
||||
(e.g. the `x.com.samsung.da.multidevice` link) -- treated as Pattern B's
|
||||
transform with the UUID sourced from there instead.
|
||||
|
||||
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter's board).
|
||||
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
|
||||
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
|
||||
same UUID appears as a literal href prefix in `/oic/res`
|
||||
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
|
||||
that reporter's unit is narrower than early issue #177 writeups suggested: a
|
||||
single individual `GET /<uuid>/information/vs/0` was read by hand through
|
||||
the debug panel and came back carrying a different model/serial than the
|
||||
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
|
||||
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
|
||||
second subdevice exists at that prefix, but not evidence that `GET
|
||||
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
|
||||
around) itself returns anything. Issue #205, the same unit on a later
|
||||
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
|
||||
So `enumerate_subdevices` tries it first (a future board might genuinely
|
||||
expose it) and falls back, when it's empty, to probing every href the
|
||||
master itself answered this cycle individually under the UUID prefix --
|
||||
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`.
|
||||
|
||||
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
|
||||
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
|
||||
no `/device/1`), so no disambiguation logic is needed --
|
||||
`enumerate_subdevices` checks both and materializes any candidate whose
|
||||
seed answers with a non-empty batch.
|
||||
|
||||
A non-empty seed batch is necessary but not sufficient for the *candidate*
|
||||
to actually be a live second subdevice, though: the Pattern A reporter's
|
||||
own board also has a `/device/2` -- a third, unused SmartThings slot --
|
||||
that answers with the exact same 14-href shape as the real `/device/1`
|
||||
sibling, populated with three constant/echoed/shape-only reps (a region
|
||||
code identical to every other subdevice's, an /information rep echoing the
|
||||
*same* model string as subdevice 1, and a /temperatures items[] entry with
|
||||
an id/description but no current/desired/minimum/maximum reading) and
|
||||
nothing resembling live climate state. Gating on *resource* shape/hrefs
|
||||
turned out to be the wrong layer -- it would need per-family domain
|
||||
knowledge (which hrefs mean "in use" for a washer's second drum, a
|
||||
fridge's second compartment, ...) baked into a registry field before any
|
||||
of those families could use this module at all. `discover_partitioned`
|
||||
instead gates at the *entity* layer, after discovery+flattening: a
|
||||
candidate is only kept if it produced at least one *primary* (no
|
||||
`entity_category`), non-meter bound entity whose flattened value isn't
|
||||
`None` -- e.g. the Pattern A reporter's /device/2 does flatten to an
|
||||
`alarm_code` value, but that entity is diagnostic-category and derived from
|
||||
an empty /alarms/vs/2, so it doesn't count. This reuses the same
|
||||
primary/config/diagnostic taxonomy every registry already declares (see the
|
||||
adding-device-support skill's entity-taxonomy section) instead of adding a
|
||||
second, parallel domain-knowledge mechanism.
|
||||
|
||||
The meter carve-out is issue #214, and it's the same "an unused slot still
|
||||
answers *something*" problem one layer further in: that reporter's
|
||||
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
|
||||
all empty {} -- the /device/2 shape above -- but which also reports a
|
||||
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
|
||||
that materialized the slot as a phantom second air conditioner. See
|
||||
A non-empty seed batch is necessary but not sufficient for a candidate to
|
||||
be a real second subdevice: an unused SmartThings slot (e.g. the Pattern A
|
||||
reporter's own `/device/2`) answers the same shape with constant/echoed
|
||||
reps and no live state. Gating on resource shape would need per-family
|
||||
domain knowledge, so `discover_partitioned` instead gates at the *entity*
|
||||
layer: a candidate is only materialized if it produces at least one live,
|
||||
non-`None`, primary (no `entity_category`), non-meter bound entity. The
|
||||
meter exclusion (issue #214) covers a second failure mode: an unused slot
|
||||
reporting a populated whole-appliance energy counter, which is the
|
||||
appliance's own bookkeeping, not evidence of a second indoor unit -- see
|
||||
`_has_live_primary_entity`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -102,18 +49,15 @@ from .by_type._base import DeviceRegistry
|
||||
_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>).
|
||||
# discovery signal (issue #241).
|
||||
_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
|
||||
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
|
||||
# including every reconnect, for information enumeration only needs once).
|
||||
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
|
||||
# guess the write-contract 'don't guess' rule is about. Widen only if a real
|
||||
# board ever turns out to need more than two siblings.
|
||||
# second subdevice's Collection (moved here from identity.py, issue #177,
|
||||
# since the old read_identity fired these on every _connect_session
|
||||
# including reconnects, when enumeration only needs to run once). A plain
|
||||
# tolerated-404 RETRIEVE, not the kind of guess the write-contract
|
||||
# 'don't guess' rule is about. Widen only if a board needs more siblings.
|
||||
_SPECULATIVE_DEVICE_INDICES = (1, 2)
|
||||
|
||||
|
||||
@@ -122,22 +66,20 @@ class Subdevice:
|
||||
"""One logical indoor subdevice reachable over a single physical
|
||||
connection.
|
||||
|
||||
`kind='main'` is the subdevice this config entry actually connects to and
|
||||
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
|
||||
identity transform, so every existing single-subdevice device keeps
|
||||
behaving exactly as it did before this module existed. `'indexed'`/
|
||||
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
|
||||
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
|
||||
Collection href (as path segments) whose batch response
|
||||
enumerates/refreshes that subdevice.
|
||||
`kind='main'` is the subdevice this config entry actually connects to
|
||||
and always exists (see MAIN below) -- its `to_actual`/`to_canonical`
|
||||
are the identity transform, so a single-subdevice device behaves
|
||||
exactly as before this module existed. `'indexed'`/`'prefixed'` are
|
||||
Pattern A/B above; `key` is the trailing index string ('1', '2', ...)
|
||||
or the full subdevice UUID, and `seed_path` is the Collection href (as
|
||||
path segments) whose batch enumerates/refreshes that subdevice.
|
||||
|
||||
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
|
||||
expose its own Collection at `seed_path` (issue #205 -- not even
|
||||
TP2X_FAC_BORA_21K, the board this pattern was built against, always
|
||||
does). When set, `seed_path` is meaningless (left as `()`) and this
|
||||
subdevice's state comes from GETting each of these canonical hrefs
|
||||
individually under its prefix instead of one Collection batch -- see
|
||||
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
|
||||
`flat_hrefs` is non-empty only for a 'prefixed' subdevice with no
|
||||
Collection at `seed_path` (issue #205). When set, `seed_path` is
|
||||
meaningless (left as `()`) and this subdevice's state comes from
|
||||
GETting each of these canonical hrefs individually under its prefix
|
||||
instead -- see enumerate_subdevices' fallback and
|
||||
coordinator._poll_subdevice_seed.
|
||||
"""
|
||||
|
||||
kind: str # 'main' | 'indexed' | 'prefixed'
|
||||
@@ -150,13 +92,11 @@ class Subdevice:
|
||||
on-the-wire href for this subdevice."""
|
||||
if self.kind == "indexed":
|
||||
head, sep, tail = canonical.rpartition("/")
|
||||
# Only the index-0 trailing segment is ours to rewrite --
|
||||
# deliberately not a "replace any trailing digit" rule, which
|
||||
# would misread a genuine multi-instance resource (the fridge's
|
||||
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
|
||||
# registry declares a non-zero trailing index today and no
|
||||
# fixture in the corpus contains one (verified across the whole
|
||||
# corpus), so the strict rule costs nothing.
|
||||
# Only the index-0 trailing segment is ours to rewrite -- not a
|
||||
# "replace any trailing digit" rule, which would misread a
|
||||
# genuine multi-instance resource (e.g. the fridge's
|
||||
# '/door/vs/1') as a subdevice's. No registry declares a
|
||||
# non-zero trailing index today.
|
||||
if tail == "0":
|
||||
return f"{head}{sep}{self.key}"
|
||||
return canonical
|
||||
@@ -179,25 +119,24 @@ class Subdevice:
|
||||
return actual
|
||||
|
||||
def owns(self, actual: str) -> bool:
|
||||
"""True if `actual` belongs to this subdevice's namespace. MAIN never
|
||||
"owns" anything by this definition -- it gets whatever's left after
|
||||
every other subdevice's hrefs are excluded (see canonical_view)."""
|
||||
"""True if `actual` belongs to this subdevice's namespace. MAIN
|
||||
never "owns" anything by this definition -- it gets whatever's
|
||||
left after every other subdevice's hrefs are excluded (see
|
||||
canonical_view)."""
|
||||
if self.kind == "main":
|
||||
return False
|
||||
return self.to_canonical(actual) is not None
|
||||
|
||||
@property
|
||||
def key_prefix(self) -> str:
|
||||
"""Prefix that guarantees a unique entity key/unique_id (see
|
||||
adapter._key). '' for MAIN -- the master's flattened state
|
||||
keys must stay byte-identical to every device this integration
|
||||
shipped before issue #177, so no golden file changes. The full
|
||||
subdevice UUID is used verbatim (non-alphanumerics stripped, not
|
||||
truncated or replaced with an ordinal) because it's device-reported
|
||||
and stable across reconnects/restarts, unlike an ordinal assigned by
|
||||
enumeration order -- and it never appears in a user-visible string
|
||||
(see DESIGN-177.md section 6): HA derives the visible entity_id from
|
||||
the device name + entity name, not from unique_id.
|
||||
"""Prefix guaranteeing a unique entity key/unique_id (see
|
||||
adapter._key). '' for MAIN, so the master's flattened state keys
|
||||
stay byte-identical to every device shipped before issue #177. The
|
||||
full subdevice UUID is used verbatim (non-alphanumerics stripped)
|
||||
rather than an enumeration-order ordinal, since it's device-reported
|
||||
and stable across reconnects; it never appears in a user-visible
|
||||
string, since HA derives entity_id from device+entity name, not
|
||||
unique_id.
|
||||
"""
|
||||
if self.kind == "indexed":
|
||||
return f"subdevice{self.key}_"
|
||||
@@ -219,16 +158,15 @@ def canonical_view(
|
||||
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
|
||||
and friends are written against.
|
||||
|
||||
For MAIN this is the snapshot *minus* every href owned by one of the
|
||||
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1`
|
||||
would leak into the master's view under the same canonical key
|
||||
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
|
||||
silently mixing two subdevices' state together. For an indexed/prefixed
|
||||
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
|
||||
back through `to_canonical`.
|
||||
For MAIN this is the snapshot minus every href owned by one of the
|
||||
other subdevices in `subdevices` -- otherwise a sibling's own
|
||||
`/mode/vs/1` would leak into the master's view under the canonical key
|
||||
('/mode/vs/0') the master's own resource also maps to. For an
|
||||
indexed/prefixed subdevice it's the reverse: only the hrefs that
|
||||
subdevice owns, rewritten back through `to_canonical`.
|
||||
|
||||
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
|
||||
False, so including it is harmless.
|
||||
`subdevices` may or may not include MAIN itself -- MAIN.owns() is
|
||||
always False, so including it is harmless.
|
||||
"""
|
||||
if subdevice.kind == "main":
|
||||
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
|
||||
@@ -245,11 +183,9 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
|
||||
normalized so every href actually carries this subdevice's prefix/index.
|
||||
|
||||
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
|
||||
href in its own `/device/<n>` batch (confirmed against the Pattern A
|
||||
reporter's dump). A prefixed subdevice's batch entries may or may not
|
||||
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
|
||||
reporter's board was never probed live before the subdevice id was
|
||||
known), so it's added when missing.
|
||||
href in its own `/device/<n>` batch. A prefixed subdevice's batch
|
||||
entries may or may not already carry the `/<id>` prefix (unconfirmed),
|
||||
so it's added when missing.
|
||||
"""
|
||||
if subdevice.kind != "prefixed":
|
||||
return batch
|
||||
@@ -265,9 +201,8 @@ def _iter_oic_res_hrefs(oic_res):
|
||||
|
||||
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
|
||||
-- see identity.py's read_identity/_get_links), so that's the shape
|
||||
handled here. Tolerant of a flat link-list too (nothing in the OCF spec
|
||||
rules it out, and _get_links' own posture already treats any list-shaped
|
||||
body as possible) and of anything else by yielding nothing.
|
||||
handled here. Tolerant of a flat link-list too, and of anything else by
|
||||
yielding nothing.
|
||||
"""
|
||||
for entry in oic_res or []:
|
||||
if not isinstance(entry, dict):
|
||||
@@ -287,13 +222,12 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
|
||||
return "/" + "/".join(path_segs)
|
||||
|
||||
|
||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0):
|
||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||
shared tolerated-absence posture for both callers below, which differ
|
||||
only in which body shape they accept."""
|
||||
shared tolerated-absence posture for both callers below."""
|
||||
try:
|
||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
||||
code, pl = sess.get(list(path_segs), timeout=timeout)
|
||||
if code == 0x45 and pl:
|
||||
return cbor2.loads(pl)
|
||||
except Exception:
|
||||
@@ -301,21 +235,29 @@ def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
return None
|
||||
|
||||
|
||||
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
||||
def _get_batch(
|
||||
sess,
|
||||
path_segs: tuple[str, ...],
|
||||
timeout: float = 10.0,
|
||||
) -> dict[str, dict]:
|
||||
"""GET a Samsung Collection resource and parse it the same way
|
||||
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
||||
{href, rep}, ...] CBOR list, not a bare Property map."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
body = _get_raw(sess, path_segs, timeout)
|
||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
|
||||
|
||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
||||
def _get_property(
|
||||
sess,
|
||||
path_segs: tuple[str, ...],
|
||||
timeout: float = 10.0,
|
||||
) -> dict:
|
||||
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
||||
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
||||
`/oic/res` on the Pattern A reporter's board but absent from
|
||||
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
||||
single Property map, not a [devcol-rep, ...] list."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
body = _get_raw(sess, path_segs, timeout)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
@@ -324,40 +266,69 @@ def enumerate_subdevices(
|
||||
resources: dict[str, dict],
|
||||
oic_res_links,
|
||||
probe_log: Callable[[str, bool], None] | None = None,
|
||||
*,
|
||||
preferred_hrefs: Sequence[str] = (),
|
||||
time_budget: float | None = None,
|
||||
collection_timeout: float = 10.0,
|
||||
property_timeout: float = 10.0,
|
||||
) -> tuple[list[Subdevice], dict[str, dict]]:
|
||||
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
||||
connection.
|
||||
|
||||
Runs once, at first discovery, in an executor, under the coordinator's
|
||||
session lock -- every GET here is a plain RETRIEVE (the write-contract
|
||||
'don't guess' rule doesn't apply to reading an extra resource to find
|
||||
out whether it's there). Returns the *candidate* subdevices and the resources
|
||||
already fetched while probing them (already normalized to real hrefs),
|
||||
so the coordinator's first discovery poll doesn't need to re-poll them.
|
||||
session lock -- every GET here is a plain RETRIEVE. Returns the
|
||||
*candidate* subdevices and the resources already fetched while probing
|
||||
them (normalized to real hrefs), so the coordinator's first discovery
|
||||
poll doesn't need to re-poll them.
|
||||
|
||||
`probe_log(seed_href, found)` fires for every seed attempted, whether or
|
||||
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
|
||||
can tell "checked, nothing there" apart from "never checked", the same
|
||||
posture the speculative-probe code this replaces used to document in
|
||||
identity.py.
|
||||
`probe_log(seed_href, found)` fires for every seed attempted, whether
|
||||
or not it answered, so diagnostics can tell "checked, nothing there"
|
||||
apart from "never checked".
|
||||
|
||||
`preferred_hrefs` only changes the order of the flat Property fallback;
|
||||
it never filters the device's resource surface. When `time_budget` is
|
||||
supplied, probes are bounded by one shared monotonic deadline and this
|
||||
returns every candidate/resource confirmed before it. This makes first
|
||||
setup finite even when firmware silently drops unknown paths instead of
|
||||
returning 4.04.
|
||||
|
||||
Every candidate whose seed answers with a non-empty batch is returned
|
||||
here -- this function has no way to tell a real sibling from an unused
|
||||
SmartThings slot that merely answers the same shape (the Pattern A
|
||||
reporter's `/device/2`); that requires discovering+flattening the
|
||||
candidate's own entities first, which is `discover_partitioned`'s job,
|
||||
not this one's.
|
||||
See this module's docstring.
|
||||
here -- this function can't tell a real sibling from an unused
|
||||
SmartThings slot that answers the same shape; that requires
|
||||
discovering+flattening the candidate's own entities first, which is
|
||||
`discover_partitioned`'s job. See this module's docstring.
|
||||
"""
|
||||
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.
|
||||
# casing, and probing it twice would materialize the same physical
|
||||
# subdevice as two Subdevice candidates.
|
||||
probed_ids: set[str] = set()
|
||||
deadline = time.monotonic() + max(0.0, time_budget) if time_budget is not None else None
|
||||
budget_exhausted = False
|
||||
|
||||
def _next_timeout(maximum: float) -> float | None:
|
||||
"""Clamp one probe to the remaining enumeration wall-clock budget."""
|
||||
nonlocal budget_exhausted
|
||||
if deadline is None:
|
||||
return maximum
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
budget_exhausted = True
|
||||
return None
|
||||
return min(maximum, remaining)
|
||||
|
||||
def _flat_probe_hrefs():
|
||||
"""Preferred live-state hrefs first, then every remaining master href."""
|
||||
seen = set()
|
||||
for href in preferred_hrefs:
|
||||
if href in resources and href not in seen:
|
||||
seen.add(href)
|
||||
yield href
|
||||
for href in sorted(resources):
|
||||
if href not in seen:
|
||||
yield href
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
@@ -372,44 +343,45 @@ def enumerate_subdevices(
|
||||
return
|
||||
probed_ids.add(sub_id.lower())
|
||||
seed = (sub_id, "device", "0")
|
||||
batch = _get_batch(sess, seed)
|
||||
timeout = _next_timeout(collection_timeout)
|
||||
if timeout is None:
|
||||
return
|
||||
batch = _get_batch(sess, seed, timeout)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if batch:
|
||||
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
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
|
||||
# has one" doesn't hold even on the reference hardware. With no
|
||||
# Collection to seed from and no per-UUID entry in `/oic/res` to
|
||||
# Fallback (issue #205): even the reference TP2X_FAC_BORA_21K board
|
||||
# doesn't always expose its own `/<uuid>/device/0` Collection. With
|
||||
# no Collection to seed from and no per-UUID entry in /oic/res to
|
||||
# enumerate hrefs from, the only signal left is that a composite
|
||||
# device's siblings are the same physical board family as the
|
||||
# subdevice this config entry already talks to -- so probe every
|
||||
# href the master itself answered this cycle, individually, under
|
||||
# this UUID's prefix, and keep whichever ones answer. Each is a
|
||||
# plain tolerated-404 RETRIEVE, same posture as every other probe in
|
||||
# this function.
|
||||
# this UUID's prefix, and keep whichever ones answer before the
|
||||
# optional enumeration deadline. Each is a plain tolerated-404
|
||||
# RETRIEVE, same posture as every other probe in this function.
|
||||
#
|
||||
# Known gap, not yet guarded against: a firmware that answers *any*
|
||||
# request under an unrecognized prefix (echoing the master's own
|
||||
# state back rather than 4.04ing) would pass every one of these
|
||||
# probes and, if the echoed state also clears discover_partitioned's
|
||||
# liveness gate, materialize a phantom duplicate of the master
|
||||
# rather than a real sibling. Every board seen so far genuinely
|
||||
# 4.04s on paths it doesn't own (issue #205's own unit answered only
|
||||
# 1 of 31 probes), so this hasn't been built -- the one place it
|
||||
# could hook in later is comparing a candidate's confirmed reps
|
||||
# against the master's own values for those same canonical hrefs.
|
||||
# Known gap: a firmware that echoes the master's own state back
|
||||
# under an unrecognized prefix, rather than 4.04ing, would pass
|
||||
# every probe here and could materialize a phantom duplicate. Every
|
||||
# board seen so far genuinely 4.04s on paths it doesn't own (issue
|
||||
# #205's unit answered only 1 of 31 probes), so this hasn't been
|
||||
# guarded against -- the fix would compare a candidate's confirmed
|
||||
# reps against the master's own values for the same hrefs.
|
||||
flat_hrefs = []
|
||||
first = True
|
||||
for href in sorted(resources):
|
||||
for href in _flat_probe_hrefs():
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
timeout = _next_timeout(property_timeout)
|
||||
if timeout is None:
|
||||
break
|
||||
actual = f"/{sub_id}{href}"
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")), timeout)
|
||||
_probed(actual, rep)
|
||||
if rep:
|
||||
flat_hrefs.append(href)
|
||||
@@ -427,45 +399,44 @@ def enumerate_subdevices(
|
||||
|
||||
# --- 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.
|
||||
# Tolerate anything but a list of strings -- this field is
|
||||
# redaction-prone (matches redact.py's 'deviceid' rule) and a shipped
|
||||
# fixture carries the literal string 'REDACTED' there. That must yield
|
||||
# zero subdevices, not a crash -- issue #177 is additive and 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)
|
||||
if budget_exhausted:
|
||||
break
|
||||
|
||||
# --- 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.
|
||||
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
|
||||
# /subdevices/vs/0 and /device/<n> 404s; the only trace of the sibling
|
||||
# is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice
|
||||
# link). Its own tree answers a full Collection at /<uuid>/device/0,
|
||||
# exactly Pattern B's transform, so a UUID prefix attached to that
|
||||
# resource type is treated as a candidate. Other UUID-prefixed links are
|
||||
# not evidence of a sibling: some single-unit AC boards advertise only
|
||||
# per-prefix file-transfer resources, and probing those prefixes against
|
||||
# every master href needlessly burns the setup timeout budget.
|
||||
# _probe_prefixed's probed_ids
|
||||
# guard (not a set difference against `listed`) is what keeps an id
|
||||
# already named by subdeviceIdList from being probed twice, since the
|
||||
# two sources can disagree on 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
|
||||
if m and "x.com.samsung.da.multidevice" in (link.get("rt") or ())
|
||||
}
|
||||
)
|
||||
for sub_id in linked:
|
||||
_probe_prefixed(sub_id)
|
||||
if budget_exhausted:
|
||||
break
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted(
|
||||
@@ -477,14 +448,16 @@ def enumerate_subdevices(
|
||||
}
|
||||
)
|
||||
if not indices:
|
||||
# A board that hides its whole tree from /oic/res (Pattern B's
|
||||
# reporter board does this too, but it has no /device/<n> to find
|
||||
# regardless) gives us nothing to enumerate from -- fall back to the
|
||||
# bounded speculative probe this replaces from identity.py.
|
||||
# A board that hides its whole tree from /oic/res gives us nothing
|
||||
# to enumerate from -- fall back to the bounded speculative probe
|
||||
# this replaces from identity.py.
|
||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||
for n in indices:
|
||||
timeout = _next_timeout(collection_timeout)
|
||||
if timeout is None:
|
||||
break
|
||||
seed = ("device", str(n))
|
||||
batch = _get_batch(sess, seed)
|
||||
batch = _get_batch(sess, seed, timeout)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
continue
|
||||
@@ -506,33 +479,33 @@ def enumerate_subdevices(
|
||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||
# not this function's.
|
||||
multidevice_seed = ("multidevice", "vs", "0")
|
||||
multidevice = _get_property(sess, multidevice_seed)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched["/multidevice/vs/0"] = multidevice
|
||||
timeout = _next_timeout(property_timeout)
|
||||
if timeout is not None:
|
||||
multidevice = _get_property(sess, multidevice_seed, timeout)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched["/multidevice/vs/0"] = multidevice
|
||||
|
||||
return subdevices, fetched
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkippedSubdevice:
|
||||
"""A candidate `enumerate_subdevices` found whose seed answered, but that
|
||||
`discover_partitioned`'s entity-level liveness gate rejected -- an
|
||||
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
|
||||
real second subdevice. Kept around (rather than silently dropped) so a
|
||||
"""A candidate `enumerate_subdevices` found whose seed answered, but
|
||||
that `discover_partitioned`'s entity-level liveness gate rejected -- an
|
||||
unused SmartThings slot, not a real second subdevice. Kept around so a
|
||||
caller can log/report what was skipped and why."""
|
||||
|
||||
subdevice: Subdevice
|
||||
hrefs: tuple[str, ...]
|
||||
|
||||
|
||||
# Sensor kinds whose value is a running total the *appliance* keeps rather
|
||||
# Sensor kinds whose value is a running total the appliance keeps rather
|
||||
# than a reading of the subdevice's own hardware -- excluded from the
|
||||
# liveness gate below (issue #214). HA's own running-total state classes
|
||||
# cover most of them; the consumption device classes catch the rest, since a
|
||||
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
|
||||
# monthly totals reset at each billing boundary, so they aren't
|
||||
# `total_increasing`).
|
||||
# liveness gate below (issue #214). HA's running-total state classes cover
|
||||
# most of them; the consumption device classes catch the rest (a descriptor
|
||||
# may deliberately declare no state_class, e.g. common.ENERGY_METER's
|
||||
# monthly totals that reset at each billing boundary).
|
||||
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
|
||||
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
|
||||
|
||||
@@ -549,35 +522,26 @@ def _is_meter(desc) -> bool:
|
||||
|
||||
def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||
"""True if flattening `bound` (one candidate subdevice's BoundEntity
|
||||
list) produced at least one non-`None` value for a *primary* entity --
|
||||
`entity_category` unset, HA's own "the user acts on or watches this"
|
||||
tier (see the adding-device-support skill's entity-taxonomy section) --
|
||||
that isn't a cumulative meter (`_is_meter`).
|
||||
list) produced at least one non-`None` value for a primary entity
|
||||
(`entity_category` unset) that isn't a cumulative meter (`_is_meter`).
|
||||
|
||||
This is the materialization gate itself (see this module's docstring).
|
||||
Two exclusions, both for the same reason -- the question this answers is
|
||||
"is a physical subdevice installed at this slot?", and neither kind of
|
||||
value can speak to it:
|
||||
Two exclusions, both because the question this answers is "is a
|
||||
physical subdevice installed at this slot?", and neither kind of value
|
||||
can speak to it:
|
||||
|
||||
- **Non-primary entities.** The Pattern A reporter's `/device/2` does
|
||||
flatten to one non-`None` value (`alarm_code`), but that entity is
|
||||
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a
|
||||
config/diagnostic entity reading "something" proves nothing about
|
||||
whether hardware is there.
|
||||
- **Cumulative meters** (issue #214). An unused slot on the issue #214
|
||||
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
|
||||
populated `cumulativePower` while every operational rep on it
|
||||
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
|
||||
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
|
||||
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
|
||||
counter. That counter got the slot materialized as a phantom second
|
||||
air conditioner. A single-split AC has one compressor and one energy
|
||||
meter, so a whole-appliance total showing up under a second index is
|
||||
the appliance's own bookkeeping, not evidence of a second indoor unit.
|
||||
A genuinely installed subdevice reports its own operational state too
|
||||
(the Pattern A reporter's real `/device/1` reports power, mode, both
|
||||
temperatures and airflow), and that state is what still passes this
|
||||
gate.
|
||||
- **Non-primary entities.** An unused slot can still flatten to a
|
||||
diagnostic-category value derived from an empty resource (e.g. a
|
||||
formatted `alarm_code` off an empty `/alarms/vs/2`) -- that proves
|
||||
nothing about whether hardware is there.
|
||||
- **Cumulative meters** (issue #214). An unused slot has been seen
|
||||
reporting a populated whole-appliance `cumulativePower` while every
|
||||
operational rep on it is empty `{}`. A single-split AC has one
|
||||
compressor and one energy meter, so a whole-appliance total showing
|
||||
up under a second index is the appliance's own bookkeeping, not
|
||||
evidence of a second indoor unit. A genuinely installed subdevice
|
||||
reports its own operational state too, and that is what still passes
|
||||
this gate.
|
||||
"""
|
||||
from .adapter import _key # see discover_partitioned's deferred-import note
|
||||
|
||||
@@ -596,64 +560,53 @@ def discover_partitioned(
|
||||
tier_log: Callable[[str, str], None] | 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
|
||||
subdevice owns it.
|
||||
"""Bind every href in `resources` (the merged, real-href snapshot --
|
||||
main plus every enumerated subdevice's seed) to entities, partitioned
|
||||
by which subdevice owns it.
|
||||
|
||||
Main pass runs over hrefs owned by no subdevice -- otherwise every
|
||||
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
|
||||
device's registry claims that literal href) and raise a spurious
|
||||
coverage-gap repair. Then one pass per *candidate* subdevice over its own
|
||||
`/mode/vs/1` would land in `unbound_hrefs` too and raise a spurious
|
||||
coverage-gap repair. Then one pass per candidate subdevice over its own
|
||||
canonical view, resolving that subdevice's own device type from its own
|
||||
`/information/vs/0` when it reports one (e.g. the Pattern B reporter's
|
||||
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
|
||||
airconditioner), falling back to the master's registry otherwise --
|
||||
every AC family shares the same resource surface, and a sibling that
|
||||
fails to answer its own identity resource is still the same appliance
|
||||
type as the subdevice this config entry was set up against.
|
||||
`/information/vs/0` when it reports one, falling back to the master's
|
||||
registry otherwise -- a sibling that fails to answer its own identity
|
||||
resource is still treated as the same appliance type as the master.
|
||||
|
||||
Each candidate is discovered and flattened *twice*: once silently to
|
||||
evaluate `_has_live_primary_entity` (this module's materialization
|
||||
gate -- see its docstring and this module's own), and, only if that
|
||||
passes, a second time with `log`/`tier_log` wired so its coverage gaps
|
||||
and poll tiers actually count. A candidate that fails the gate
|
||||
contributes nothing at all -- no bound entities, no unbound-href
|
||||
report, no hot/warm href -- as if it had never answered its seed.
|
||||
Discovering an unused slot's small, fixed resource set twice at
|
||||
first-discovery time only is a non-issue; getting a phantom subdevice
|
||||
silently counted into unbound_hrefs or hot/warm tiers is not.
|
||||
Each candidate is discovered and flattened twice: once silently to
|
||||
evaluate `_has_live_primary_entity`, and, only if that passes, a second
|
||||
time with `log`/`tier_log` wired so its coverage gaps and poll tiers
|
||||
actually count. A candidate that fails the gate contributes nothing at
|
||||
all, as if it had never answered its seed.
|
||||
|
||||
`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.
|
||||
`oic_device_types` (from the master's own `/oic/d`) is passed only to
|
||||
the master's resolution -- subdevices resolve from their own
|
||||
`/information/vs/0` or fall back to the master's whole registry, and
|
||||
blindly applying the master's OCF device type to every subdevice would
|
||||
be wrong the moment a composite appliance pairs two different device
|
||||
types under one connection.
|
||||
|
||||
Returns `(bound, device_type_name, materialized, skipped)`:
|
||||
- `bound`: the concatenated BoundEntity list (main + every materialized
|
||||
subdevice).
|
||||
- `device_type_name`: the *master's* resolved device type (used for
|
||||
logging/device naming; each subdevice's own resolved type only affects
|
||||
which capabilities bind its hrefs, not this).
|
||||
- `materialized`: the subset of `subdevices` that passed the gate, in the
|
||||
same order -- what the caller should keep as its live subdevice roster
|
||||
going forward (poll seeds, canonical_resources, device_info_for, ...).
|
||||
- `device_type_name`: the master's resolved device type (used for
|
||||
logging/device naming; each subdevice's own resolved type only
|
||||
affects which capabilities bind its hrefs).
|
||||
- `materialized`: the subset of `subdevices` that passed the gate, in
|
||||
the same order -- what the caller should keep as its live subdevice
|
||||
roster going forward (poll seeds, canonical_resources,
|
||||
device_info_for, ...).
|
||||
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
||||
"""
|
||||
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
|
||||
# module scope, so importing discover() back here at module scope would
|
||||
# be a circular import. By the time this function actually runs both
|
||||
# modules are fully loaded. adapter.py imports discovery.py, so the same
|
||||
# applies to flatten()/_key().
|
||||
# Deferred import: discovery.py imports Subdevice/MAIN from this module
|
||||
# at module scope, so importing discover() back here at module scope
|
||||
# would be circular. By the time this function runs both modules are
|
||||
# fully loaded; adapter.py imports discovery.py, so the same applies to
|
||||
# flatten()/_key().
|
||||
from .adapter import flatten
|
||||
from .discovery import discover
|
||||
|
||||
# Same computation canonical_view does for MAIN (snapshot minus every
|
||||
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
|
||||
# owned_elsewhere here too.
|
||||
# Same computation canonical_view does for MAIN -- reuse it rather than
|
||||
# re-deriving owned_elsewhere here too.
|
||||
main_view = canonical_view(MAIN, resources, subdevices)
|
||||
|
||||
reg = resolve_registry(main_view, device_types=oic_device_types)
|
||||
|
||||
@@ -49,40 +49,39 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
return snake if snake in known else None
|
||||
|
||||
|
||||
def _display(value, translation_key: str | None):
|
||||
def _display(value, translation_key: str | None, fallback_fn=None):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
`translation_key` is the entity's already-resolved key (SelectDesc.
|
||||
translation_key can itself be a callable -- see entities.py -- so
|
||||
callers pass the resolved value, e.g. self.translation_key, not
|
||||
the raw descriptor field).
|
||||
`translation_key` is the entity's already-resolved key (it can itself
|
||||
be a callable -- see entities.py -- so callers pass the resolved
|
||||
value, not the raw descriptor field).
|
||||
|
||||
An entity with a translation_key looks its state up in the shipped
|
||||
translation catalog, whose state keys are lowercase -- so those values
|
||||
must be lowercased exactly to match, and the device still expects
|
||||
that same raw casing back on write (callers map the displayed value
|
||||
back to raw via _raw_options()).
|
||||
|
||||
Everything else has no catalog lookup, so there's no reason to
|
||||
destroy the device's own casing. Only two cosmetic fixups apply: a
|
||||
fully lowercase device-native token (e.g. "voice") is title-cased,
|
||||
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
|
||||
the case boundary ("Extra High"). A value that's already
|
||||
human-friendly (e.g. "AI Wash") matches neither pattern and passes
|
||||
through unchanged.
|
||||
translation catalog, whose state keys are lowercase, and the device
|
||||
still expects that same raw casing back on write (mapped back via
|
||||
_raw_options()). Everything else has no catalog lookup, so there's no
|
||||
reason to destroy the device's own casing: only two cosmetic fixups
|
||||
apply, title-casing a fully lowercase token ("voice") and spacing a
|
||||
PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value
|
||||
("AI Wash") matches neither and passes through unchanged.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if translation_key:
|
||||
known = translated_states("select", translation_key)
|
||||
if not known:
|
||||
# No state table for this key: either the entity isn't translated
|
||||
# at all, or its name is translated but its options deliberately
|
||||
# aren't (an unrecognized course table, say). Either way the
|
||||
# opaque device value is the best thing to show.
|
||||
return value
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
known = translated_states("select", translation_key) if translation_key else frozenset()
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
|
||||
return fallback
|
||||
if translation_key and not known:
|
||||
# No state table for this key: either the entity isn't translated at
|
||||
# all, or its name is translated but its options deliberately aren't
|
||||
# (an unrecognized course table, say). Nothing named this value, so
|
||||
# the raw device value is the best choice -- the cosmetic reshaping
|
||||
# below would only mangle an opaque code, turning a course '0E' into
|
||||
# '0 E'. Reached only when the fallback *declined* the value, not
|
||||
# merely when none was supplied: cycle_select always supplies one now
|
||||
# (it labels cloud programs) and returns None for everything else.
|
||||
return value
|
||||
if value.islower():
|
||||
return value.replace("_", " ").title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(" ", value)
|
||||
@@ -93,18 +92,25 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc = cast(SelectDesc, bound.desc)
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
|
||||
self._attr_options = [self._display_option(o) for o in desc.options]
|
||||
|
||||
def _display_option(self, value):
|
||||
"""Normalize both current state and options through one path."""
|
||||
display_fn = cast(SelectDesc, self._bound.desc).display_fn
|
||||
fallback_fn = (
|
||||
(lambda raw: display_fn(raw, self._resources)) if display_fn is not None else None
|
||||
)
|
||||
return _display(value, self.translation_key, fallback_fn)
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc = cast(SelectDesc, self._bound.desc)
|
||||
if callable(desc.options):
|
||||
# Per-device option list computed from the full resource
|
||||
# snapshot (not just this entity's own href) -- e.g. a course
|
||||
# list decoded from a sibling resource. There is no static
|
||||
# fallback: when that resource isn't populated the callable
|
||||
# returns [] and the entity's exists_fn suppresses it entirely.
|
||||
# This entity's own subdevice's canonical view (issue #177), not
|
||||
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
|
||||
# snapshot -- e.g. a course list decoded from a sibling
|
||||
# resource. No static fallback: when unpopulated, the callable
|
||||
# returns [] and exists_fn suppresses the entity entirely. Uses
|
||||
# this subdevice's canonical view (issue #177), not the raw
|
||||
# snapshot -- see LocalThingsEntity._resources.
|
||||
return list(desc.options(self._resources) or [])
|
||||
if desc.options_field:
|
||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||
@@ -115,17 +121,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc = cast(SelectDesc, self._bound.desc)
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
||||
return [self._display_option(o) for o in self._raw_options()]
|
||||
return self._attr_options
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
return _display(raw, self.translation_key)
|
||||
return self._display_option(raw)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
raw = next(
|
||||
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
|
||||
(o for o in self._raw_options() if self._display_option(o) == option),
|
||||
option,
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
@@ -51,6 +52,9 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
if desc.options:
|
||||
self._attr_options = list(desc.options)
|
||||
self._hysteresis_value = None
|
||||
self._sticky_value = None
|
||||
self._sticky_until: float | None = None
|
||||
self._sticky_spent = False
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
@@ -63,10 +67,65 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
def native_value(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
desc = cast(SensorDesc, self._bound.desc)
|
||||
if desc.sticky_fn is not None:
|
||||
raw = self._apply_sticky(raw, desc)
|
||||
if not desc.hysteresis:
|
||||
return raw
|
||||
return self._apply_hysteresis(raw)
|
||||
|
||||
def _apply_sticky(self, raw, desc: SensorDesc):
|
||||
"""Freeze this entity at a value for up to `desc.sticky_seconds`
|
||||
after `desc.sticky_fn` next stops matching this href's live rep
|
||||
(issue #345 -- see operational.py's `_just_finished` for the
|
||||
motivating case). Entity-instance state only, exactly like
|
||||
`_hysteresis_value` above -- never written back to the coordinator
|
||||
cache, so write_fn, diagnostics, and the observe-mode sweep
|
||||
comparison keep seeing real device data throughout.
|
||||
|
||||
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
|
||||
than the already-computed `raw`, so they can key on fields rep_fn
|
||||
has collapsed away -- but they never compute a value. `raw` and
|
||||
the frozen `sticky_value` are the only things returned here, so a
|
||||
held entity and a free-running one agree on what "live" means; a
|
||||
hook that broke that rule caused issue #358.
|
||||
|
||||
At most one window per `sticky_bypass_fn` cycle: arming marks the
|
||||
hold spent, and only the bypass clears that. So a `sticky_fn` that
|
||||
keeps matching (firmware leaving the field stuck -- the quirk
|
||||
`_completion_minutes` works around) can't extend the window, and
|
||||
one flapping in and out can't restart it either, before or after
|
||||
expiry. Expiry alone doesn't re-open the door: without something
|
||||
the calibre of "a new cycle is actually running" in between, a
|
||||
second Finish is the same Finish, and re-arming on it would strobe
|
||||
the entity between held and live once per window -- exactly the
|
||||
repeated announcements #345 and #358 are about.
|
||||
|
||||
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
|
||||
sticky right now" is ambiguous between "went idle, honor the hold"
|
||||
and "genuinely moved on to new data". It is both the early release
|
||||
and the only re-arm, so it should demand positive evidence of that
|
||||
move; when unsure, letting the window run out is the cheaper
|
||||
mistake.
|
||||
"""
|
||||
assert desc.sticky_fn is not None # native_value only calls this when set
|
||||
rep = self.coordinator.resource(self._bound.href)
|
||||
now = time.monotonic()
|
||||
|
||||
if desc.sticky_fn(rep):
|
||||
if not self._sticky_spent:
|
||||
self._sticky_value = (
|
||||
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
|
||||
)
|
||||
self._sticky_until = now + desc.sticky_seconds
|
||||
self._sticky_spent = True
|
||||
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
|
||||
self._sticky_until = None
|
||||
self._sticky_spent = False
|
||||
return raw
|
||||
|
||||
holding = self._sticky_until is not None and now < self._sticky_until
|
||||
return self._sticky_value if holding else 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
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Home Assistant services for direct OCF resource read/write access
|
||||
(issue #300): a raw-transport escape hatch for reverse-engineering a
|
||||
device's write contract -- an ordered multi-write sequence with settle
|
||||
delays and a delayed re-read, which the single-write options-flow debug
|
||||
panel can't express. Both sit on the same coordinator primitives the panel
|
||||
now calls too (config_flow.py), so there is exactly one code path that
|
||||
performs a raw write.
|
||||
|
||||
Kept thin on purpose: session/lock ownership lives on the coordinator
|
||||
(coordinator.py). This module only resolves the service call's device
|
||||
target to a `(coordinator, subdevice)` pair, translates canonical hrefs
|
||||
through that subdevice, and shapes the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
|
||||
from .coordinator import LocalThingsCoordinator, normalize_href
|
||||
from .registry.subdevices import MAIN, Subdevice
|
||||
|
||||
ATTR_HREF = "href"
|
||||
ATTR_PAYLOAD = "payload"
|
||||
ATTR_SETTLE = "settle"
|
||||
ATTR_WRITES = "writes"
|
||||
ATTR_VERIFY_AFTER = "verify_after"
|
||||
ATTR_HOLD_SESSION_LOCK = "hold_session_lock"
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
|
||||
_WRITE_ITEM_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_HREF): str,
|
||||
# Not `dict` here: a non-dict payload must fail the same way an
|
||||
# empty one does -- coordinator.async_raw_write_sequence's
|
||||
# ServiceValidationError -- not a raw schema vol.Invalid, so every
|
||||
# caller sees one consistent error shape regardless of which rule
|
||||
# a bad payload tripped.
|
||||
vol.Required(ATTR_PAYLOAD): object,
|
||||
vol.Optional(ATTR_SETTLE): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
# Structural validation only (types, and unwrapping a bare dict into a
|
||||
# one-item list) -- the semantic checks (non-empty payload, non-root href,
|
||||
# the 1..10/settle/verify_after ranges) live on
|
||||
# LocalThingsCoordinator.async_raw_write_sequence, so every caller gets the
|
||||
# same ServiceValidationError + translation key regardless of whether it
|
||||
# reached the primitive through this service, the options-flow panel, or a
|
||||
# future caller.
|
||||
_WRITE_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Required(ATTR_WRITES): vol.All(cv.ensure_list, [_WRITE_ITEM_SCHEMA]),
|
||||
vol.Optional(ATTR_VERIFY_AFTER): vol.Coerce(float),
|
||||
vol.Optional(ATTR_HOLD_SESSION_LOCK): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
_READ_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Optional(ATTR_HREF): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_target(
|
||||
hass: HomeAssistant, call: ServiceCall
|
||||
) -> tuple[LocalThingsCoordinator, Subdevice, str]:
|
||||
"""The one `(coordinator, subdevice, device_id)` a service call's
|
||||
device target names.
|
||||
|
||||
Deliberately strict about count, not just presence: the `target:
|
||||
device:` selector in services.yaml still lets a user pick an area or
|
||||
label in the picker, and the frontend expands that into a `device_id`
|
||||
list before the call reaches here -- more than one entry means an
|
||||
area/label fanned this out across several appliances, which a raw
|
||||
debug write must never do silently (issue #300).
|
||||
"""
|
||||
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID) or [])
|
||||
if len(device_ids) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_target_invalid",
|
||||
)
|
||||
device_id = device_ids[0]
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get(device_id)
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_found",
|
||||
)
|
||||
|
||||
for coordinator in hass.data.get(DOMAIN, {}).values():
|
||||
if device.identifiers & coordinator.device_info.get("identifiers", set()):
|
||||
return coordinator, MAIN, device_id
|
||||
for sub in coordinator.subdevices:
|
||||
if device.identifiers & coordinator.device_info_for(sub).get("identifiers", set()):
|
||||
return coordinator, sub, device_id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_loaded",
|
||||
)
|
||||
|
||||
|
||||
async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, device_id = _resolve_target(hass, call)
|
||||
writes_in: list[dict[str, Any]] = call.data[ATTR_WRITES]
|
||||
|
||||
# Canonical -> actual translation happens here, not in the coordinator
|
||||
# (issue #177), whose raw-write primitive has no notion of subdevices --
|
||||
# identity transform for MAIN. Normalized first, or a trailing slash
|
||||
# slips past to_actual onto the master (see coordinator.normalize_href).
|
||||
canonicals = [normalize_href(w[ATTR_HREF]) for w in writes_in]
|
||||
raw_writes = [
|
||||
{
|
||||
"href": subdevice.to_actual(canonical),
|
||||
"payload": w.get(ATTR_PAYLOAD),
|
||||
"settle": w.get(ATTR_SETTLE, 0.0),
|
||||
}
|
||||
for canonical, w in zip(canonicals, writes_in, strict=True)
|
||||
]
|
||||
sequence = await coordinator.async_raw_write_sequence(
|
||||
raw_writes,
|
||||
verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0),
|
||||
hold_session_lock=call.data.get(ATTR_HOLD_SESSION_LOCK, True),
|
||||
)
|
||||
|
||||
results = [
|
||||
{
|
||||
"href": canonical,
|
||||
"actual_href": result["href"],
|
||||
"code": result["code"],
|
||||
"raw_code": result["raw_code"],
|
||||
"accepted": result["accepted"],
|
||||
"before": result["before"],
|
||||
"after": result["after"],
|
||||
"changed": result["changed"],
|
||||
}
|
||||
for canonical, result in zip(canonicals, sequence["results"], strict=True)
|
||||
]
|
||||
response: dict[str, Any] = {"device_id": device_id, "results": results}
|
||||
if "verified" in sequence:
|
||||
# Keyed off the same normalized canonicals the sequence was built
|
||||
# from, so the lookup can't miss and return an actual href where the
|
||||
# contract promises a canonical one.
|
||||
canonical_by_actual = {subdevice.to_actual(c): c for c in canonicals}
|
||||
response["verified"] = {
|
||||
canonical_by_actual.get(actual_href, actual_href): verified
|
||||
for actual_href, verified in sequence["verified"].items()
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, _device_id = _resolve_target(hass, call)
|
||||
href = call.data.get(ATTR_HREF)
|
||||
if not href:
|
||||
# No href -> the cached snapshot, not a live sweep of every known
|
||||
# href: lets a user enumerate what exists without hammering the
|
||||
# device (see this module's docstring and the coordinator's
|
||||
# canonical_resources).
|
||||
# device_resources, not canonical_resources: this response is what
|
||||
# the appliance reported, without the fields this integration merges
|
||||
# on for its own use (see coordinator.entity_resources).
|
||||
snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)}
|
||||
return cast(ServiceResponse, snapshot)
|
||||
|
||||
# Same normalize-before-translate order as the write path above.
|
||||
canonical = normalize_href(href)
|
||||
actual_href = subdevice.to_actual(canonical)
|
||||
code, rep = await coordinator.async_raw_read(actual_href)
|
||||
read_result: dict[str, Any] = {
|
||||
"href": canonical,
|
||||
"actual_href": actual_href,
|
||||
"code": f"{code >> 5}.{code & 0x1F:02d}",
|
||||
"raw_code": code,
|
||||
"rep": rep,
|
||||
}
|
||||
return cast(ServiceResponse, read_result)
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the write_resource/read_resource services (issue #300).
|
||||
|
||||
Called once from `async_setup`, not per config entry: services are
|
||||
process-global, and `hass.services.async_register` on an
|
||||
already-registered name just replaces the handler, so re-registering
|
||||
on every entry setup would silently rebind to whichever entry loaded
|
||||
last. `async_unload_entry` must never call the inverse of this.
|
||||
"""
|
||||
|
||||
async def _handle_write(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_write_resource(hass, call)
|
||||
|
||||
async def _handle_read(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_read_resource(hass, call)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
_handle_write,
|
||||
schema=_WRITE_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
_handle_read,
|
||||
schema=_READ_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
write_resource:
|
||||
name: Write resource
|
||||
description: >-
|
||||
Send one or more raw partial-rep writes straight to a device's OCF
|
||||
resources, in order, with an optional settle delay between steps and a
|
||||
delayed re-read at the end -- for reverse-engineering a device's write
|
||||
contract (issue #300), not day-to-day control. This bypasses the
|
||||
remote-control-off block and every write_fn/validate_fn a normal entity
|
||||
write goes through, and sends exactly the fields you give it verbatim:
|
||||
it can misconfigure your appliance. Prefer a real entity, or the Debug
|
||||
write panel in the integration's Configure menu, for anything this
|
||||
integration already models.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The appliance to write to, or one of its subdevices.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: localthings
|
||||
writes:
|
||||
name: Writes
|
||||
description: >-
|
||||
1-10 writes to perform in order. Each item needs href (the
|
||||
canonical resource, e.g. /mode/vs/0) and payload (a non-empty
|
||||
object sent verbatim as a partial-rep POST); settle is how many
|
||||
seconds to wait after that write before starting the next one
|
||||
(0-30, default 0).
|
||||
required: true
|
||||
example: >-
|
||||
[{"href": "/mode/vs/0", "payload": {"x.com.samsung.da.modes":
|
||||
["Bake"]}, "settle": 3}]
|
||||
selector:
|
||||
object:
|
||||
hold_session_lock:
|
||||
name: Hold the session for the whole sequence
|
||||
description: >-
|
||||
Keep the device session for the entire sequence, settle delays
|
||||
included, so nothing else -- a routine poll, another entity's write
|
||||
-- can land between two steps and blur which write the appliance
|
||||
was reacting to. On by default. Turning it off takes the session
|
||||
per write and frees it across the waits, which lets entities keep
|
||||
updating during a long sequence at the cost of that certainty.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
verify_after:
|
||||
name: Verify after
|
||||
description: >-
|
||||
Seconds to wait after the whole sequence finishes before
|
||||
re-reading every href touched, to see whether the values held or
|
||||
were reverted by the board. 0 (default) skips verification.
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 60
|
||||
step: 0.5
|
||||
unit_of_measurement: seconds
|
||||
mode: box
|
||||
|
||||
read_resource:
|
||||
name: Read resource
|
||||
description: >-
|
||||
Read a device's OCF resources directly, bypassing this integration's
|
||||
entity model. Give an href for a live GET straight from the device --
|
||||
deliberately not the cache, which can be up to a poll interval stale --
|
||||
or omit it to get the cached snapshot of every resource this
|
||||
integration currently tracks on that device.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The appliance to read from, or one of its subdevices.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: localthings
|
||||
href:
|
||||
name: Resource href
|
||||
description: >-
|
||||
Canonical resource href to read (e.g. /mode/vs/0). Omit to get the
|
||||
cached snapshot of every tracked resource instead of a live GET.
|
||||
required: false
|
||||
example: /mode/vs/0
|
||||
selector:
|
||||
text:
|
||||
@@ -117,6 +117,12 @@
|
||||
"diagnosis_start": {
|
||||
"name": "Spustit diagnostiku"
|
||||
},
|
||||
"auto_clean_stop": {
|
||||
"name": "Zastavit samočištění"
|
||||
},
|
||||
"filter_time_reset": {
|
||||
"name": "Vynulovat počítadlo filtru"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pozastavit"
|
||||
},
|
||||
@@ -146,11 +152,13 @@
|
||||
"smart": "Chytrý",
|
||||
"speed": "Rychlý",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Spánek",
|
||||
"nanosleep": "WindFree spánek",
|
||||
"longwind": "Dlouhý vánek",
|
||||
"motionindirect": "Nepřímý vzduch při pohybu",
|
||||
"motiondirect": "Přímý vzduch při pohybu",
|
||||
"drycomfort": "Komfortní sušení",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2stupňový"
|
||||
}
|
||||
}
|
||||
@@ -191,6 +199,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Požadovaná teplota"
|
||||
},
|
||||
"sensing_interval": {
|
||||
"name": "Interval AI Purify"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Požadovaná teplota"
|
||||
},
|
||||
@@ -214,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Práh upozornění na filtr"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Časovač automatického otevírání dvířek"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Režim zóny nápojů",
|
||||
"state": {
|
||||
@@ -286,7 +300,16 @@
|
||||
"07": "Předoplach",
|
||||
"8d": "Hrnce a pánve",
|
||||
"8e": "Plast",
|
||||
"8f": "Dětské potřeby"
|
||||
"8f": "Dětské potřeby",
|
||||
"82": "Automatický",
|
||||
"8a": "Normální",
|
||||
"a7": "Intenzivní",
|
||||
"a8": "Expresní",
|
||||
"8c": "Extra tichý",
|
||||
"88": "Samočištění",
|
||||
"85": "Jemné",
|
||||
"0c": "Express",
|
||||
"0d": "Samočištění"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -301,6 +324,22 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"9c": "Intenzivní",
|
||||
"a5": "Ložní prádlo",
|
||||
"9e": "Nežehlivé prádlo",
|
||||
"9b": "Parní dezinfekce+",
|
||||
"27": "Osvěžení",
|
||||
"a0": "Provětrání",
|
||||
"a4": "Časové sušení",
|
||||
"a6": "Rychlé sušení",
|
||||
"a3": "Sportovní oblečení",
|
||||
"a2": "Jemné prádlo"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
@@ -328,7 +367,9 @@
|
||||
"4c": "Osvěžení vzduchem",
|
||||
"51": "Eko bavlna",
|
||||
"53": "AI sušení+",
|
||||
"4e": "Automatické sušení"
|
||||
"4e": "Automatické sušení",
|
||||
"26": "Provětrání",
|
||||
"2a": "Hygienické sušení+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -450,9 +491,11 @@
|
||||
"kimchi_storage_crunfch": "Křupavé kimči",
|
||||
"kimchi_storage_buy": "Kupované kimči",
|
||||
"storage_fridge_normal": "Chlazení",
|
||||
"storage_fridge": "Chlazení",
|
||||
"storage_fridge_cold": "Chlazení, silné",
|
||||
"storage_fridge_warm": "Chlazení, slabé",
|
||||
"storage_freezer_normal": "Mražení",
|
||||
"storage_freezer": "Mražení",
|
||||
"storage_freezer_cold": "Mražení, silné",
|
||||
"storage_freezer_warm": "Mražení, slabé",
|
||||
"kimchi_ripe_low_temp": "Zrání kimči, nízká teplota",
|
||||
@@ -465,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Obiloviny",
|
||||
"storage_fridge_drink": "Nápoje",
|
||||
"storage_fresh_wine": "Víno",
|
||||
"storage_fresh_potato_banana": "Brambory a banány"
|
||||
"storage_fresh_potato_banana": "Brambory a banány",
|
||||
"newmode_kimchi_0000": "Nový režim"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -476,6 +520,16 @@
|
||||
"fdr_drinks": "Nápoje"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Režim spíže",
|
||||
"state": {
|
||||
"processed_meat": "Zpracované maso",
|
||||
"cheese": "Sýr",
|
||||
"nuts": "Ořechy",
|
||||
"fruit": "Ovoce",
|
||||
"wine": "Víno"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Výkon hořáku {number}",
|
||||
"state": {
|
||||
@@ -494,6 +548,14 @@
|
||||
"rinse_cycles": {
|
||||
"name": "Počet máchání"
|
||||
},
|
||||
"sensing_mode": {
|
||||
"name": "Akce AI Purify",
|
||||
"state": {
|
||||
"off": "Pouze měření",
|
||||
"airpurify": "Automatické čištění",
|
||||
"alarm": "Dostat upozornění"
|
||||
}
|
||||
},
|
||||
"softener_concentration": {
|
||||
"name": "Koncentrace aviváže",
|
||||
"state": {
|
||||
@@ -519,6 +581,9 @@
|
||||
"mute": "Ztlumeno"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Režim úspory energie"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Zvukový režim",
|
||||
"state": {
|
||||
@@ -555,11 +620,29 @@
|
||||
"extra_hot": "Extra horká"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"70": "Intenzivní",
|
||||
"55": "Bílé prádlo",
|
||||
"71": "Ložní prádlo",
|
||||
"72": "Dezinfekce",
|
||||
"77": "Nežehlivé prádlo",
|
||||
"57": "Samočištění+",
|
||||
"73": "Máchání + odstřeďování",
|
||||
"74": "Sportovní oblečení",
|
||||
"75": "Jemné prádlo",
|
||||
"78": "Rychlé praní"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"04": "Rychlé praní",
|
||||
"06": "XXL prádlo",
|
||||
"08": "Máchání+odstřeďování",
|
||||
"17": "Stažený program",
|
||||
"1b": "Bavlna",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -570,7 +653,7 @@
|
||||
"21": "Barevné prádlo",
|
||||
"22": "Vlna",
|
||||
"23": "Outdoor",
|
||||
"24": "Ručníky",
|
||||
"24": "Ložní prádlo",
|
||||
"25": "Syntetika",
|
||||
"26": "Jemné prádlo",
|
||||
"27": "Máchání+odstřeďování",
|
||||
@@ -583,7 +666,7 @@
|
||||
"2f": "Sportovní oblečení",
|
||||
"30": "Zataženo",
|
||||
"32": "Košile",
|
||||
"33": "Ložní prádlo",
|
||||
"33": "Ručníky",
|
||||
"34": "Smíšené",
|
||||
"36": "Praní+sušení",
|
||||
"37": "Sušení vzduchem",
|
||||
@@ -599,14 +682,34 @@
|
||||
"60": "Samočištění+",
|
||||
"65": "Barevné prádlo",
|
||||
"66": "Džíny",
|
||||
"69": "AI praní",
|
||||
"6a": "Vlna",
|
||||
"6b": "Džíny",
|
||||
"6c": "Halenky",
|
||||
"6d": "Jemné prádlo",
|
||||
"6e": "Sportovní oblečení",
|
||||
"6f": "Ložní prádlo",
|
||||
"70": "Ručníky",
|
||||
"71": "Rychlé praní",
|
||||
"72": "Košile",
|
||||
"73": "Dezinfekce",
|
||||
"74": "Čištění bubnu",
|
||||
"75": "Outdoor",
|
||||
"76": "Dětské potřeby",
|
||||
"77": "Bavlna",
|
||||
"78": "Máchání + odstřeďování",
|
||||
"79": "Pouze odstřeďování",
|
||||
"7c": "Bílé prádlo",
|
||||
"7d": "Ložní prádlo/nepromokavé",
|
||||
"7e": "Samočištění",
|
||||
"7f": "Vlna/jemné",
|
||||
"86": "Hloubkové praní",
|
||||
"87": "Stažený program",
|
||||
"88": "Péče o domácí mazlíčky",
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken"
|
||||
"96": "Méně mikrovláken",
|
||||
"a0": "15min rychlé praní",
|
||||
"35": "Eko bavlna"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -646,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Teplota mrazicí zóny"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Režim okrajového osvětlení",
|
||||
"state": {
|
||||
"smart": "Chytrý",
|
||||
"high": "Vysoký",
|
||||
"low": "Nízký"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Barva okrajového osvětlení",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Režim kontrolky",
|
||||
"state": {
|
||||
"smart": "Chytrý",
|
||||
"high": "Vysoký",
|
||||
"low": "Nízký"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Režim",
|
||||
"state": {
|
||||
"purification": "Čištění",
|
||||
"ventilation": "Větrání",
|
||||
"smartventilation": "Chytré větrání"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -661,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Hodiny využití filtru"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Využití filtru"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Norma kvality vzduchu"
|
||||
},
|
||||
@@ -768,6 +906,12 @@
|
||||
"indirect": "Nepřímý"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Stav úspory energie"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Provozní stav úspory energie"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Teplota"
|
||||
},
|
||||
@@ -1021,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Úspora energie při nepřítomnosti aktivní"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Čištění při nepřítomnosti"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Vyhýbání se proudu vzduchu při pohybu aktivní"
|
||||
},
|
||||
@@ -1030,6 +1177,12 @@
|
||||
"display": {
|
||||
"name": "Displej"
|
||||
},
|
||||
"periodic_air_sensing": {
|
||||
"name": "AI Purify"
|
||||
},
|
||||
"periodic_sensing_skip_status": {
|
||||
"name": "Přeskočení měření AI Purify"
|
||||
},
|
||||
"pet_filter_activation": {
|
||||
"name": "Aktivace filtru pro domácí mazlíčky"
|
||||
},
|
||||
@@ -1048,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Automatické otevírání dvířek"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Zvuk automatického otevírání dvířek"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Hlasové ovládání automatického otevírání dvířek"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Automatické uvolnění při sušení"
|
||||
},
|
||||
@@ -1179,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Alarm větrání"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Okrajové osvětlení"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Kontrolka"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Režim Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Noční režim"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1199,6 +1370,12 @@
|
||||
},
|
||||
"night_start": {
|
||||
"name": "Začátek nočního osvětlení"
|
||||
},
|
||||
"sensing_skip_end": {
|
||||
"name": "Konec přeskočení měření AI Purify"
|
||||
},
|
||||
"sensing_skip_start": {
|
||||
"name": "Začátek přeskočení měření AI Purify"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
@@ -1261,17 +1438,24 @@
|
||||
"title": "Možnosti LocalThings",
|
||||
"menu_options": {
|
||||
"settings": "Nastavení zápisu pro dálkové ovládání",
|
||||
"cloud_courses": "Stažené cykly",
|
||||
"forget_learned_modes": "Zapomenout zapamatované režimy",
|
||||
"debug_write": "Ladění: zápis do prostředku"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Nastavení zápisu pro dálkové ovládání",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté",
|
||||
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)"
|
||||
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)",
|
||||
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Zapomenout zapamatované režimy",
|
||||
"description": "Aktuálně zapamatováno: {codes}\n\nJde o režimy, ve kterých se toto zařízení samo hlásilo, aniž by je uvádělo mezi podporovanými; jsou uchovány, aby zůstaly volitelné. Zapomenutí je řešením, pokud se některý ukázal jako chybný – cokoli, co zařízení skutečně znovu nahlásí, si systém opět zapamatuje, pokud zároveň nevypnete volbu „Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované“ v nastavení."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Ladění: zápis do prostředku",
|
||||
"description": "Nástroj pro pokročilé uživatele k odhalení chování zápisu specifického pro dané zařízení. Vyberte prostředek (href), do kterého chcete zapisovat, nebo zadejte vlastní, který není uveden v seznamu. Toto obchází blokování při vypnutém dálkovém ovládání a odesílá přesně ta pole, která zadáte -- může to špatně nakonfigurovat váš spotřebič, používejte tedy záměrně.",
|
||||
@@ -1293,20 +1477,63 @@
|
||||
"debug_write": "Zapsat do dalšího prostředku",
|
||||
"finish": "Dokončit"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
|
||||
"data": {
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Průvodce nastavením vás provede zařízením: na zařízení vyberte stažený cyklus a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Průvodce nastavením",
|
||||
"cloud_manual": "Upravit názvy"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Stažené cykly"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Pojmenujte tento cyklus",
|
||||
"description": "Zařízení přepnulo na stažený cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na zařízení vyberte další cyklus. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
|
||||
"data": {
|
||||
"name": "Název",
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nebyl vybrán žádný cyklus",
|
||||
"description": "Na zařízení nebylo nic vybráno. Ujistěte se, že je nastaveno na Stažený program, a poté postupně procházejte stažené programy.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Počkat znovu",
|
||||
"cloud_finish": "Dokončit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Zadejte alespoň jedno pole k zápisu.",
|
||||
"write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant."
|
||||
"write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant.",
|
||||
"cloud_course_name_duplicate": "Dva cykly mají stejný název. Názvy musí být jedinečné.",
|
||||
"cloud_course_unknown_course": "Tento program zařízení nenabízí. Vyberte jej ze seznamu."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Nyní na zařízení vyberte stažený cyklus.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Neúplné pokrytí funkcí pro {device_name}",
|
||||
"description": "Toto zařízení nemá úplné pokrytí funkcí. Buď nebyl rozpoznán jeho typ spotřebiče, nebo některé jím poskytované prostředky ještě nejsou namodelovány. Bude i nadále fungovat se vším, co je již podporováno. Podporu můžete pomoci rozšířit tak, že přejdete do Nastavení > Zařízení a služby > {device_name} > nabídka (vpravo nahoře) > Stáhnout diagnostiku a poté ji vložíte do odkazované šablony issue."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Stažené cykly nejsou nastaveny pro {device_name}",
|
||||
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1327,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intenzivní není u vybraného cyklu k dispozici."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Příkaz pro {href} selhal i po opětovném připojení: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Zadejte 1 až 10 zápisů."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "Hodnota settle musí být mezi 0 a 30 sekundami."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "Hodnota verify_after musí být mezi 0 a 60 sekundami."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Tato služba vyžaduje přesně jedno cílové zařízení."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Pro tento cíl nebylo nalezeno žádné odpovídající zařízení."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,12 @@
|
||||
"diagnosis_start": {
|
||||
"name": "Start diagnosis"
|
||||
},
|
||||
"auto_clean_stop": {
|
||||
"name": "Stop auto clean"
|
||||
},
|
||||
"filter_time_reset": {
|
||||
"name": "Reset filter time"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pause"
|
||||
},
|
||||
@@ -146,11 +152,13 @@
|
||||
"smart": "Smart",
|
||||
"speed": "Speed",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sleep",
|
||||
"nanosleep": "WindFree sleep",
|
||||
"longwind": "Long wind",
|
||||
"motionindirect": "Motion indirect",
|
||||
"motiondirect": "Motion direct",
|
||||
"drycomfort": "Dry comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
@@ -191,6 +199,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
"sensing_interval": {
|
||||
"name": "AI Purify interval"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
@@ -214,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Filter alarm threshold"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Auto door open timer"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Beverage zone mode",
|
||||
"state": {
|
||||
@@ -285,8 +299,17 @@
|
||||
"0e": "AI Wash",
|
||||
"07": "Pre blast",
|
||||
"8d": "Pots and pans",
|
||||
"85": "Delicate",
|
||||
"0c": "Express",
|
||||
"0d": "Self clean",
|
||||
"8e": "Plastic",
|
||||
"8f": "Baby Care"
|
||||
"8f": "Baby Care",
|
||||
"82": "Auto",
|
||||
"8a": "Normal",
|
||||
"a7": "Heavy",
|
||||
"a8": "Express",
|
||||
"8c": "Extra Silence",
|
||||
"88": "Self Clean"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -301,6 +324,22 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"9c": "Heavy Duty",
|
||||
"a5": "Bedding",
|
||||
"9e": "Perm Press",
|
||||
"9b": "Steam Sanitize+",
|
||||
"27": "Refresh",
|
||||
"a0": "Air Fluff",
|
||||
"a4": "Time Dry",
|
||||
"a6": "Quick Dry",
|
||||
"a3": "Active Wear",
|
||||
"a2": "Delicates"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
@@ -316,8 +355,10 @@
|
||||
"23": "Quick Dry 35'",
|
||||
"24": "Cool air",
|
||||
"25": "Warm air",
|
||||
"26": "Air wash",
|
||||
"27": "Time dry",
|
||||
"29": "AI Dry",
|
||||
"2a": "Hygiene Care+",
|
||||
"1a": "Wool",
|
||||
"1b": "Bedding",
|
||||
"1c": "Shirts",
|
||||
@@ -452,9 +493,11 @@
|
||||
"storage_fridge_normal": "Fridge",
|
||||
"storage_fridge_cold": "Fridge, strong",
|
||||
"storage_fridge_warm": "Fridge, weak",
|
||||
"storage_fridge": "Fridge",
|
||||
"storage_freezer_normal": "Freezer",
|
||||
"storage_freezer_cold": "Freezer, strong",
|
||||
"storage_freezer_warm": "Freezer, weak",
|
||||
"storage_freezer": "Freezer",
|
||||
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
|
||||
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
|
||||
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
|
||||
@@ -465,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Grains",
|
||||
"storage_fridge_drink": "Beverages",
|
||||
"storage_fresh_wine": "Wine",
|
||||
"storage_fresh_potato_banana": "Potato & banana"
|
||||
"storage_fresh_potato_banana": "Potato & banana",
|
||||
"newmode_kimchi_0000": "New mode"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -476,6 +520,16 @@
|
||||
"fdr_drinks": "Drinks"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Pantry zone mode",
|
||||
"state": {
|
||||
"processed_meat": "Processed meat",
|
||||
"cheese": "Cheese",
|
||||
"nuts": "Nuts",
|
||||
"fruit": "Fruit",
|
||||
"wine": "Wine"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Burner {number} power level",
|
||||
"state": {
|
||||
@@ -494,6 +548,14 @@
|
||||
"rinse_cycles": {
|
||||
"name": "Rinse cycles"
|
||||
},
|
||||
"sensing_mode": {
|
||||
"name": "AI Purify action",
|
||||
"state": {
|
||||
"off": "Sensing only",
|
||||
"airpurify": "Auto clean",
|
||||
"alarm": "Get notified"
|
||||
}
|
||||
},
|
||||
"softener_concentration": {
|
||||
"name": "Softener concentration",
|
||||
"state": {
|
||||
@@ -519,6 +581,9 @@
|
||||
"mute": "Mute"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Energy saving mode"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Sound mode",
|
||||
"state": {
|
||||
@@ -555,11 +620,29 @@
|
||||
"extra_hot": "Extra hot"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"70": "Heavy Duty",
|
||||
"55": "Whites",
|
||||
"71": "Bedding",
|
||||
"72": "Sanitize",
|
||||
"77": "Perm Press",
|
||||
"57": "Self Clean+",
|
||||
"73": "Rinse + Spin",
|
||||
"74": "Active Wear",
|
||||
"75": "Delicates",
|
||||
"78": "Quick Wash"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"04": "Quick Wash",
|
||||
"06": "XXL Laundry",
|
||||
"08": "Rinse+Spin",
|
||||
"17": "Downloaded",
|
||||
"1b": "Cotton",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -570,7 +653,7 @@
|
||||
"21": "Colors",
|
||||
"22": "Wool",
|
||||
"23": "Outdoor",
|
||||
"24": "Towels",
|
||||
"24": "Bedding",
|
||||
"25": "Synthetics",
|
||||
"26": "Delicates",
|
||||
"27": "Rinse+Spin",
|
||||
@@ -583,8 +666,9 @@
|
||||
"2f": "Activewear",
|
||||
"30": "Cloudy Day",
|
||||
"32": "Shirts",
|
||||
"33": "Bedding",
|
||||
"33": "Towels",
|
||||
"34": "Mixed",
|
||||
"35": "E Cotton",
|
||||
"36": "Wash+Dry",
|
||||
"37": "Air Wash",
|
||||
"38": "Cotton Dry",
|
||||
@@ -599,14 +683,33 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Colors",
|
||||
"66": "Denim",
|
||||
"69": "AI Wash",
|
||||
"6a": "Wool",
|
||||
"6b": "Denim",
|
||||
"6c": "Blouses",
|
||||
"6d": "Delicates",
|
||||
"6e": "Active Wear",
|
||||
"6f": "Bedding",
|
||||
"70": "Towels",
|
||||
"71": "Quick Wash",
|
||||
"72": "Shirts",
|
||||
"73": "Sanitize",
|
||||
"74": "Drum Clean",
|
||||
"75": "Outdoor",
|
||||
"76": "Baby Care",
|
||||
"77": "Cottons",
|
||||
"78": "Rinse + Spin",
|
||||
"79": "Spin Only",
|
||||
"7c": "Whites",
|
||||
"7d": "Bedding/Waterproof",
|
||||
"7e": "Self-Clean",
|
||||
"7f": "Wool/Delicate",
|
||||
"86": "Deep Wash",
|
||||
"87": "Download",
|
||||
"88": "Pet Care",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
"96": "Less Microfiber",
|
||||
"a0": "15' Quick Wash"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -646,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Freezer temperature"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Edge lighting mode",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "High",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Edge lighting color",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Indicator light mode",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "High",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"purification": "Purification",
|
||||
"ventilation": "Ventilation",
|
||||
"smartventilation": "Smart Ventilation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -661,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filter usage hours"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Filter usage"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Air quality standard"
|
||||
},
|
||||
@@ -768,6 +906,12 @@
|
||||
"indirect": "Indirect"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Energy saving state"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Energy saving operating status"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
@@ -1021,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Absence power saving active"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Absence clean"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Motion-detect wind avoidance active"
|
||||
},
|
||||
@@ -1030,6 +1177,12 @@
|
||||
"display": {
|
||||
"name": "Display"
|
||||
},
|
||||
"periodic_air_sensing": {
|
||||
"name": "AI Purify"
|
||||
},
|
||||
"periodic_sensing_skip_status": {
|
||||
"name": "AI Purify sensing skip"
|
||||
},
|
||||
"pet_filter_activation": {
|
||||
"name": "Pet filter activation"
|
||||
},
|
||||
@@ -1048,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Auto door opener"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Auto door sound"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Auto door voice control"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Auto release dry"
|
||||
},
|
||||
@@ -1179,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilation alarm"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Edge lighting"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Indicator light"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Wind-Free mode"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Sleep mode"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1199,6 +1370,12 @@
|
||||
},
|
||||
"night_start": {
|
||||
"name": "Night light start"
|
||||
},
|
||||
"sensing_skip_end": {
|
||||
"name": "AI Purify sensing skip end"
|
||||
},
|
||||
"sensing_skip_start": {
|
||||
"name": "AI Purify sensing skip start"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
@@ -1261,17 +1438,24 @@
|
||||
"title": "LocalThings options",
|
||||
"menu_options": {
|
||||
"settings": "Device settings",
|
||||
"cloud_courses": "Download cycles",
|
||||
"forget_learned_modes": "Forget remembered modes",
|
||||
"debug_write": "Debug: write to a resource"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"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.",
|
||||
"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.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
|
||||
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
|
||||
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
|
||||
"learn_device_modes": "Remember modes the device reports but doesn't advertise"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Forget remembered modes",
|
||||
"description": "Currently remembered: {codes}\n\nThese are modes this device reported itself in without listing them as supported, kept so they stay selectable. Forgetting them is the fix if one turned out to be bogus -- anything the device genuinely reports again will simply be remembered again, unless you also turn off \"Remember modes the device reports but doesn't advertise\" in Device settings."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Debug: write to a resource",
|
||||
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
|
||||
@@ -1293,20 +1477,63 @@
|
||||
"debug_write": "Write another resource",
|
||||
"finish": "Finish"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Download cycles",
|
||||
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||
"data": {
|
||||
"download_course": "Download cycle course code"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"download_course": "Download cycle course code"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Enter at least one field to write.",
|
||||
"write_failed": "The write failed. Check the Home Assistant logs for details."
|
||||
"write_failed": "The write failed. Check the Home Assistant logs for details.",
|
||||
"cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.",
|
||||
"cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Incomplete capability coverage for {device_name}",
|
||||
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Downloaded cycles not set up for {device_name}",
|
||||
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1327,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensive isn't available on the selected cycle."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "The command to {href} failed even after reconnecting: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Provide between 1 and 10 writes."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle must be between 0 and 30 seconds."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after must be between 0 and 60 seconds."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "This service requires exactly one target device."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No matching device was found for that target."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "This device isn't connected yet. Try again once it has loaded."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,17 +53,24 @@
|
||||
"title": "Opciones de LocalThings",
|
||||
"menu_options": {
|
||||
"settings": "Ajustes del dispositivo",
|
||||
"cloud_courses": "Ciclos descargados",
|
||||
"forget_learned_modes": "Olvidar los modos recordados",
|
||||
"debug_write": "Depuración: escribir en un recurso"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del dispositivo",
|
||||
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.",
|
||||
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.\n\nAlgunos modelos notifican un modo que nunca incluyen entre los admitidos: por ejemplo, un aire acondicionado en modo Quiet que solo ofrece Off/Sleep/Speed. LocalThings recuerda cualquier modo así que detecte y lo sigue ofreciendo, de modo que quede seleccionable en cuanto el dispositivo haya estado en él al menos una vez. Desactívalo para ofrecer solo lo que el dispositivo anuncia; usa «Olvidar los modos recordados» en la pantalla anterior para borrar lo ya recordado.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Permitir escrituras incluso cuando el control remoto se notifica como desactivado",
|
||||
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)"
|
||||
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)",
|
||||
"learn_device_modes": "Recordar los modos que el dispositivo notifica pero no anuncia"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Olvidar los modos recordados",
|
||||
"description": "Recordados actualmente: {codes}\n\nSon modos en los que este dispositivo se notificó a sí mismo sin incluirlos entre los admitidos; se conservan para que sigan siendo seleccionables. Olvidarlos es la solución si alguno resultó ser erróneo: cualquier modo que el dispositivo vuelva a notificar de verdad se recordará otra vez, salvo que además desactives «Recordar los modos que el dispositivo notifica pero no anuncia» en los ajustes del dispositivo."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Depuración: escribir en un recurso",
|
||||
"description": "Herramienta para usuarios avanzados para precisar el comportamiento de escritura específico del dispositivo. Elige el recurso (href) sobre el que quieres escribir, o escribe uno personalizado que no esté en la lista. Esto omite el bloqueo de control remoto desactivado y envía exactamente los campos que proporciones; puede desconfigurar tu electrodoméstico, así que úsalo deliberadamente.",
|
||||
@@ -85,20 +92,63 @@
|
||||
"debug_write": "Escribir otro recurso",
|
||||
"finish": "Finalizar"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Ciclos descargados",
|
||||
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Código de programa de Descarga de Programas"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Ciclos descargados",
|
||||
"description": "La configuración guiada te acompaña con el dispositivo: selecciona un ciclo descargado en el dispositivo y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Configuración guiada",
|
||||
"cloud_manual": "Editar nombres"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Ciclos descargados"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Nombra este ciclo",
|
||||
"description": "El dispositivo cambió a un ciclo descargado (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego selecciona el siguiente en el dispositivo. Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.",
|
||||
"data": {
|
||||
"name": "Nombre",
|
||||
"download_course": "Código de programa de Descarga de Programas"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No se seleccionó ningún ciclo",
|
||||
"description": "No se seleccionó nada en el dispositivo. Asegúrate de que esté puesto en Descarga de Programas y luego ve pasando por los programas descargados.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Esperar de nuevo",
|
||||
"cloud_finish": "Finalizar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Introduce al menos un campo para escribir.",
|
||||
"write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles."
|
||||
"write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles.",
|
||||
"cloud_course_name_duplicate": "Dos ciclos tienen el mismo nombre. Los nombres deben ser únicos.",
|
||||
"cloud_course_unknown_course": "Ese programa no es uno que ofrezca este dispositivo. Elige uno de la lista."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selecciona ahora un ciclo descargado en el dispositivo.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Cobertura de capacidades incompleta para {device_name}",
|
||||
"description": "A este dispositivo le falta cobertura completa de capacidades. O su tipo de electrodoméstico no fue reconocido, o algunos de los recursos que expone aún no están modelados. Seguirá funcionando con lo que ya está soportado. Puedes ayudar a ampliar el soporte yendo a Ajustes > Dispositivos y servicios > {device_name} > el menú (arriba a la derecha) > Descargar diagnósticos, y reportándolo con la plantilla de incidencia enlazada."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Ciclos descargados sin configurar para {device_name}",
|
||||
"description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -119,6 +169,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "El modo intensivo no está disponible en el ciclo seleccionado."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "El comando para {href} falló incluso después de reconectar: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Proporciona entre 1 y 10 escrituras."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle debe estar entre 0 y 30 segundos."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after debe estar entre 0 y 60 segundos."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Este servicio requiere exactamente un dispositivo de destino."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No se encontró ningún dispositivo coincidente para ese destino."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Este dispositivo aún no está conectado. Vuelve a intentarlo cuando se haya cargado."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -239,6 +310,12 @@
|
||||
"diagnosis_start": {
|
||||
"name": "Iniciar diagnóstico"
|
||||
},
|
||||
"auto_clean_stop": {
|
||||
"name": "Detener la autolimpieza"
|
||||
},
|
||||
"filter_time_reset": {
|
||||
"name": "Restablecer el contador del filtro"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pausar"
|
||||
},
|
||||
@@ -268,11 +345,13 @@
|
||||
"smart": "Inteligente",
|
||||
"speed": "Rápido",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sueño",
|
||||
"nanosleep": "WindFree sueño",
|
||||
"longwind": "Viento prolongado",
|
||||
"motionindirect": "Indirecto al movimiento",
|
||||
"motiondirect": "Directo al movimiento",
|
||||
"drycomfort": "Confort seco",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 pasos"
|
||||
}
|
||||
}
|
||||
@@ -313,6 +392,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Punto de ajuste"
|
||||
},
|
||||
"sensing_interval": {
|
||||
"name": "Intervalo de AI Purify"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Punto de ajuste"
|
||||
},
|
||||
@@ -336,6 +418,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Umbral de alarma del filtro"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Temporizador de apertura automática de puerta"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modo de zona de bebidas",
|
||||
"state": {
|
||||
@@ -408,7 +493,16 @@
|
||||
"07": "Prelavado intenso",
|
||||
"8d": "Ollas y sartenes",
|
||||
"8e": "Plástico",
|
||||
"8f": "Cuidado del bebé"
|
||||
"8f": "Cuidado del bebé",
|
||||
"82": "Automático",
|
||||
"8a": "Normal",
|
||||
"a7": "Intenso",
|
||||
"a8": "Express",
|
||||
"8c": "Extra silencioso",
|
||||
"88": "Autolimpieza",
|
||||
"85": "Delicado",
|
||||
"0c": "Express",
|
||||
"0d": "Autolimpieza"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -423,6 +517,22 @@
|
||||
"4": "Alarma 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"9c": "Servicio intensivo",
|
||||
"a5": "Ropa de cama",
|
||||
"9e": "Planchado fácil",
|
||||
"9b": "Desinfección por vapor+",
|
||||
"27": "Renovar",
|
||||
"a0": "Aireación",
|
||||
"a4": "Secado por tiempo",
|
||||
"a6": "Secado rápido",
|
||||
"a3": "Ropa deportiva",
|
||||
"a2": "Delicados"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
@@ -450,7 +560,9 @@
|
||||
"4c": "Renovación de aire",
|
||||
"51": "Eco algodón",
|
||||
"53": "Secado IA+",
|
||||
"4e": "Autosecado"
|
||||
"4e": "Autosecado",
|
||||
"26": "Aireación",
|
||||
"2a": "Cuidado higiénico+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -572,9 +684,11 @@
|
||||
"kimchi_storage_crunfch": "Kimchi crujiente",
|
||||
"kimchi_storage_buy": "Kimchi comprado",
|
||||
"storage_fridge_normal": "Frigorífico",
|
||||
"storage_fridge": "Frigorífico",
|
||||
"storage_fridge_cold": "Frigorífico, fuerte",
|
||||
"storage_fridge_warm": "Frigorífico, suave",
|
||||
"storage_freezer_normal": "Congelador",
|
||||
"storage_freezer": "Congelador",
|
||||
"storage_freezer_cold": "Congelador, fuerte",
|
||||
"storage_freezer_warm": "Congelador, suave",
|
||||
"kimchi_ripe_low_temp": "Maduración kimchi, baja temperatura",
|
||||
@@ -587,7 +701,8 @@
|
||||
"storage_fresh_cereal": "Cereales",
|
||||
"storage_fridge_drink": "Bebidas",
|
||||
"storage_fresh_wine": "Vino",
|
||||
"storage_fresh_potato_banana": "Patata y plátano"
|
||||
"storage_fresh_potato_banana": "Patata y plátano",
|
||||
"newmode_kimchi_0000": "Modo nuevo"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -598,6 +713,16 @@
|
||||
"fdr_drinks": "Bebidas"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Modo de zona de despensa",
|
||||
"state": {
|
||||
"processed_meat": "Embutidos",
|
||||
"cheese": "Queso",
|
||||
"nuts": "Frutos secos",
|
||||
"fruit": "Fruta",
|
||||
"wine": "Vino"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Nivel de potencia del fuego {number}",
|
||||
"state": {
|
||||
@@ -616,6 +741,14 @@
|
||||
"rinse_cycles": {
|
||||
"name": "Aclarados"
|
||||
},
|
||||
"sensing_mode": {
|
||||
"name": "Acción de AI Purify",
|
||||
"state": {
|
||||
"off": "Solo medición",
|
||||
"airpurify": "Limpieza automática",
|
||||
"alarm": "Recibir notificación"
|
||||
}
|
||||
},
|
||||
"softener_concentration": {
|
||||
"name": "Concentración del suavizante",
|
||||
"state": {
|
||||
@@ -641,6 +774,9 @@
|
||||
"mute": "Silencio"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Modo de ahorro de energía"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Modo de sonido",
|
||||
"state": {
|
||||
@@ -677,11 +813,29 @@
|
||||
"extra_hot": "Muy caliente"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"70": "Servicio intensivo",
|
||||
"55": "Blancos",
|
||||
"71": "Ropa de cama",
|
||||
"72": "Desinfección",
|
||||
"77": "Planchado fácil",
|
||||
"57": "Autolimpieza+",
|
||||
"73": "Aclarar + Centrifugar",
|
||||
"74": "Ropa deportiva",
|
||||
"75": "Delicados",
|
||||
"78": "Lavado rápido"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"04": "Lavado rápido",
|
||||
"06": "Colada XXL",
|
||||
"08": "Aclarar + Centrifugar",
|
||||
"17": "Descargado",
|
||||
"1b": "Algodón",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -692,7 +846,7 @@
|
||||
"21": "Color",
|
||||
"22": "Lana",
|
||||
"23": "Exterior",
|
||||
"24": "Toallas",
|
||||
"24": "Ropa de cama",
|
||||
"25": "Sintéticos",
|
||||
"26": "Delicados",
|
||||
"27": "Aclarar + Centrifugar",
|
||||
@@ -705,7 +859,7 @@
|
||||
"2f": "Ropa deportiva",
|
||||
"30": "Día nublado",
|
||||
"32": "Camisas",
|
||||
"33": "Ropa de cama",
|
||||
"33": "Toallas",
|
||||
"34": "Mezcla",
|
||||
"36": "Lavado + Secado",
|
||||
"37": "Lavado con aire",
|
||||
@@ -728,7 +882,27 @@
|
||||
"86": "Lavado profundo",
|
||||
"87": "Descarga de Programas",
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras"
|
||||
"96": "Menos microfibras",
|
||||
"a0": "Lavado rápido 15'",
|
||||
"69": "Lavado IA",
|
||||
"6a": "Lana",
|
||||
"6b": "Denim",
|
||||
"6c": "Blusas",
|
||||
"6d": "Delicados",
|
||||
"6e": "Ropa deportiva",
|
||||
"6f": "Ropa de cama",
|
||||
"70": "Toallas",
|
||||
"71": "Lavado rápido",
|
||||
"72": "Camisas",
|
||||
"73": "Desinfección",
|
||||
"74": "Limpieza de Tambor",
|
||||
"75": "Exterior",
|
||||
"76": "Cuidado del bebé",
|
||||
"77": "Algodón",
|
||||
"78": "Aclarar + Centrifugar",
|
||||
"79": "Solo centrifugar",
|
||||
"88": "Cuidado de mascotas",
|
||||
"35": "Algodón E"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -768,6 +942,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatura del congelador"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Modo de iluminación perimetral",
|
||||
"state": {
|
||||
"smart": "Inteligente",
|
||||
"high": "Alto",
|
||||
"low": "Bajo"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Color de iluminación perimetral",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Modo de luz indicadora",
|
||||
"state": {
|
||||
"smart": "Inteligente",
|
||||
"high": "Alto",
|
||||
"low": "Bajo"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modo",
|
||||
"state": {
|
||||
"purification": "Purificación",
|
||||
"ventilation": "Ventilación",
|
||||
"smartventilation": "Ventilación inteligente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -780,6 +986,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Horas de uso del filtro"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Uso del filtro"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Estándar de calidad del aire"
|
||||
},
|
||||
@@ -887,6 +1096,12 @@
|
||||
"indirect": "Indirecto"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Estado de ahorro de energía"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Estado de funcionamiento del ahorro de energía"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatura"
|
||||
},
|
||||
@@ -1143,6 +1358,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Ahorro por ausencia activo"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Limpieza por ausencia"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Evitación de viento por detección de movimiento activa"
|
||||
},
|
||||
@@ -1152,6 +1370,12 @@
|
||||
"display": {
|
||||
"name": "Pantalla"
|
||||
},
|
||||
"periodic_air_sensing": {
|
||||
"name": "AI Purify"
|
||||
},
|
||||
"periodic_sensing_skip_status": {
|
||||
"name": "Omisión de medición de AI Purify"
|
||||
},
|
||||
"pet_filter_activation": {
|
||||
"name": "Activación de filtro para mascotas"
|
||||
},
|
||||
@@ -1170,6 +1394,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Apertura automática de puerta"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Sonido de apertura automática de puerta"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Control por voz de apertura automática de puerta"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Liberación automática de secado"
|
||||
},
|
||||
@@ -1301,6 +1531,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Alarma de ventilación"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Iluminación perimetral"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Luz indicadora"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Modo Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Modo nocturno"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1321,6 +1563,12 @@
|
||||
},
|
||||
"night_start": {
|
||||
"name": "Inicio de la luz nocturna"
|
||||
},
|
||||
"sensing_skip_end": {
|
||||
"name": "Fin de la omisión de medición de AI Purify"
|
||||
},
|
||||
"sensing_skip_start": {
|
||||
"name": "Inicio de la omisión de medición de AI Purify"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
|
||||
@@ -117,6 +117,12 @@
|
||||
"diagnosis_start": {
|
||||
"name": "Avvia diagnosi"
|
||||
},
|
||||
"auto_clean_stop": {
|
||||
"name": "Arresta autopulizia"
|
||||
},
|
||||
"filter_time_reset": {
|
||||
"name": "Azzera il contatore del filtro"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pausa"
|
||||
},
|
||||
@@ -146,11 +152,13 @@
|
||||
"smart": "Smart",
|
||||
"speed": "Veloce",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sonno",
|
||||
"nanosleep": "WindFree sonno",
|
||||
"longwind": "Vento prolungato",
|
||||
"motionindirect": "Indiretto al movimento",
|
||||
"motiondirect": "Diretto al movimento",
|
||||
"drycomfort": "Comfort asciugatura",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 fasi"
|
||||
}
|
||||
}
|
||||
@@ -191,6 +199,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Valore impostato"
|
||||
},
|
||||
"sensing_interval": {
|
||||
"name": "Intervallo AI Purify"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Valore impostato"
|
||||
},
|
||||
@@ -214,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Soglia allarme filtro"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Timer apertura automatica sportello"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modalità zona bevande",
|
||||
"state": {
|
||||
@@ -286,7 +300,16 @@
|
||||
"07": "Prelavaggio intenso",
|
||||
"8d": "Pentole e padelle",
|
||||
"8e": "Plastica",
|
||||
"8f": "Bambini"
|
||||
"8f": "Bambini",
|
||||
"82": "Automatico",
|
||||
"8a": "Normale",
|
||||
"a7": "Intenso",
|
||||
"a8": "Express",
|
||||
"8c": "Extra silenzioso",
|
||||
"88": "Autopulizia",
|
||||
"85": "Delicato",
|
||||
"0c": "Express",
|
||||
"0d": "Autopulizia"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -301,6 +324,22 @@
|
||||
"4": "Allarme 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"9c": "Intenso",
|
||||
"a5": "Biancheria da letto",
|
||||
"9e": "Pronto da stirare",
|
||||
"9b": "Igienizzante a vapore+",
|
||||
"27": "Rinfresca",
|
||||
"a0": "Arieggiatura",
|
||||
"a4": "Asciugatura a tempo",
|
||||
"a6": "Asciugatura rapida",
|
||||
"a3": "Abbigliamento sportivo",
|
||||
"a2": "Delicati"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
@@ -328,7 +367,9 @@
|
||||
"4c": "Rinfresca",
|
||||
"51": "Eco cotone",
|
||||
"53": "Asciugatura AI+",
|
||||
"4e": "Autoasciugatura"
|
||||
"4e": "Autoasciugatura",
|
||||
"26": "Arieggiatura",
|
||||
"2a": "Cura igienica+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -450,9 +491,11 @@
|
||||
"kimchi_storage_crunfch": "Kimchi croccante",
|
||||
"kimchi_storage_buy": "Kimchi acquistato",
|
||||
"storage_fridge_normal": "Frigorifero",
|
||||
"storage_fridge": "Frigorifero",
|
||||
"storage_fridge_cold": "Frigorifero, forte",
|
||||
"storage_fridge_warm": "Frigorifero, debole",
|
||||
"storage_freezer_normal": "Congelatore",
|
||||
"storage_freezer": "Congelatore",
|
||||
"storage_freezer_cold": "Congelatore, forte",
|
||||
"storage_freezer_warm": "Congelatore, debole",
|
||||
"kimchi_ripe_low_temp": "Maturazione kimchi, bassa temperatura",
|
||||
@@ -465,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Cereali",
|
||||
"storage_fridge_drink": "Bevande",
|
||||
"storage_fresh_wine": "Vino",
|
||||
"storage_fresh_potato_banana": "Patate e banane"
|
||||
"storage_fresh_potato_banana": "Patate e banane",
|
||||
"newmode_kimchi_0000": "Nuova modalità"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -476,6 +520,16 @@
|
||||
"fdr_drinks": "Bevande"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Modalità zona dispensa",
|
||||
"state": {
|
||||
"processed_meat": "Salumi",
|
||||
"cheese": "Formaggio",
|
||||
"nuts": "Frutta secca",
|
||||
"fruit": "Frutta",
|
||||
"wine": "Vino"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Livello potenza fornello {number}",
|
||||
"state": {
|
||||
@@ -494,6 +548,14 @@
|
||||
"rinse_cycles": {
|
||||
"name": "Risciacquo"
|
||||
},
|
||||
"sensing_mode": {
|
||||
"name": "Azione AI Purify",
|
||||
"state": {
|
||||
"off": "Solo rilevamento",
|
||||
"airpurify": "Pulizia automatica",
|
||||
"alarm": "Ricevi notifica"
|
||||
}
|
||||
},
|
||||
"softener_concentration": {
|
||||
"name": "Concentrazione ammorbidente",
|
||||
"state": {
|
||||
@@ -519,6 +581,9 @@
|
||||
"mute": "Muto"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Modalità risparmio energetico"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Modalità audio",
|
||||
"state": {
|
||||
@@ -555,22 +620,40 @@
|
||||
"extra_hot": "Extra caldo"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"70": "Intenso",
|
||||
"55": "Bianchi",
|
||||
"71": "Biancheria da letto",
|
||||
"72": "Igienizzante",
|
||||
"77": "Pronto da stirare",
|
||||
"57": "Self Clean+",
|
||||
"73": "Risciacquo+Centrifuga",
|
||||
"74": "Abbigliamento sportivo",
|
||||
"75": "Delicati",
|
||||
"78": "Lavaggio rapido"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"04": "Lavaggio rapido",
|
||||
"06": "Bucato XXL",
|
||||
"08": "Risciacquo+Centrifuga",
|
||||
"17": "Scaricato",
|
||||
"1b": "Cotone",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
"1e": "Rapido 15'",
|
||||
"1f": "Intenseo a freddo",
|
||||
"1f": "Intenso a freddo",
|
||||
"20": "Vapore igienizzante",
|
||||
"21": "Colorati",
|
||||
"22": "Lana",
|
||||
"23": "Capi outdoor",
|
||||
"24": "Asciugamani",
|
||||
"24": "Biancheria da letto",
|
||||
"25": "Sintetici",
|
||||
"26": "Delicati",
|
||||
"27": "Risciacquo+Centrifuga",
|
||||
@@ -583,7 +666,7 @@
|
||||
"2f": "Abbigliamento sportivo",
|
||||
"30": "Giornata nuvolosa",
|
||||
"32": "Camicie",
|
||||
"33": "Biancheria da letto",
|
||||
"33": "Asciugamani",
|
||||
"34": "Misti",
|
||||
"36": "Lavaggio+Asciugatura",
|
||||
"37": "Lavaggio ad aria",
|
||||
@@ -606,7 +689,27 @@
|
||||
"86": "Lavaggio profondo",
|
||||
"87": "Scaricato",
|
||||
"8f": "Intenso a freddo",
|
||||
"96": "Riduci microfibre"
|
||||
"96": "Riduci microfibre",
|
||||
"69": "Lavaggio AI",
|
||||
"6a": "Lana",
|
||||
"6b": "Jeans",
|
||||
"6c": "Camicette",
|
||||
"6d": "Delicati",
|
||||
"6e": "Abbigliamento sportivo",
|
||||
"6f": "Biancheria da letto",
|
||||
"70": "Asciugamani",
|
||||
"71": "Lavaggio rapido",
|
||||
"72": "Camicie",
|
||||
"73": "Igienizzante",
|
||||
"74": "Pulizia cestello",
|
||||
"75": "Capi outdoor",
|
||||
"76": "Bambini",
|
||||
"77": "Cotone",
|
||||
"78": "Risciacquo+Centrifuga",
|
||||
"79": "Solo centrifuga",
|
||||
"88": "Cura animali",
|
||||
"35": "Cotone E",
|
||||
"a0": "Rapido 15'"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -646,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatura freezer"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Modalità illuminazione perimetrale",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "Alta",
|
||||
"low": "Bassa"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Colore illuminazione perimetrale",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Modalità spia luminosa",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "Alta",
|
||||
"low": "Bassa"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modalità",
|
||||
"state": {
|
||||
"purification": "Purificazione",
|
||||
"ventilation": "Ventilazione",
|
||||
"smartventilation": "Ventilazione intelligente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -661,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Ore di utilizzo filtro"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Utilizzo filtro"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Standard qualità aria"
|
||||
},
|
||||
@@ -768,6 +906,12 @@
|
||||
"indirect": "Indiretto"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Stato risparmio energetico"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Stato di funzionamento del risparmio energetico"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatura"
|
||||
},
|
||||
@@ -1021,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Risparmio energetico in assenza attivo"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Pulizia in assenza"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Elusione flusso d'aria a rilevamento movimento attiva"
|
||||
},
|
||||
@@ -1030,6 +1177,12 @@
|
||||
"display": {
|
||||
"name": "Display"
|
||||
},
|
||||
"periodic_air_sensing": {
|
||||
"name": "AI Purify"
|
||||
},
|
||||
"periodic_sensing_skip_status": {
|
||||
"name": "Esclusione rilevamento AI Purify"
|
||||
},
|
||||
"pet_filter_activation": {
|
||||
"name": "Attivazione filtro animali"
|
||||
},
|
||||
@@ -1048,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Apertura automatica sportello"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Suono apertura automatica sportello"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Controllo vocale apertura automatica sportello"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Apertura automatica per asciugatura"
|
||||
},
|
||||
@@ -1179,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Allarme ventilazione"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Illuminazione perimetrale"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Spia luminosa"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Modalità Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Modalità notte"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1199,6 +1370,12 @@
|
||||
},
|
||||
"night_start": {
|
||||
"name": "Inizio luce notturna"
|
||||
},
|
||||
"sensing_skip_end": {
|
||||
"name": "Fine esclusione rilevamento AI Purify"
|
||||
},
|
||||
"sensing_skip_start": {
|
||||
"name": "Inizio esclusione rilevamento AI Purify"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
@@ -1261,17 +1438,24 @@
|
||||
"title": "Opzioni LocalThings",
|
||||
"menu_options": {
|
||||
"settings": "Impostazioni dispositivo",
|
||||
"cloud_courses": "Cicli scaricati",
|
||||
"forget_learned_modes": "Dimentica le modalità memorizzate",
|
||||
"debug_write": "Debug: scrivi su una risorsa"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni dispositivo",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Consenti la scrittura anche quando il controllo remoto risulta disattivato.",
|
||||
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)"
|
||||
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)",
|
||||
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Dimentica le modalità memorizzate",
|
||||
"description": "Attualmente memorizzate: {codes}\n\nSono modalità in cui questo dispositivo si è dichiarato senza elencarle tra quelle supportate; vengono conservate perché restino selezionabili. Dimenticarle è la soluzione se una si è rivelata errata: qualsiasi modalità che il dispositivo segnali davvero di nuovo verrà memorizzata un'altra volta, a meno che non si disattivi anche «Memorizza le modalità che il dispositivo segnala ma non dichiara supportate» nelle impostazioni del dispositivo."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Debug: scrivi su una risorsa",
|
||||
"description": "Strumento avanzato per definire con precisione il comportamento di scrittura specifico del dispositivo. Seleziona la risorsa (href) su cui desideri scrivere oppure digitane una personalizzata non presente nell'elenco. Questo bypassa il blocco di disattivazione del controllo remoto e invia esattamente i campi specificati; tuttavia, potrebbe causare una configurazione errata del dispositivo, quindi utilizzalo con cautela.",
|
||||
@@ -1293,20 +1477,63 @@
|
||||
"debug_write": "Scrivi un'altra risorsa",
|
||||
"finish": "Fine"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "La configurazione guidata accompagna l'utente al dispositivo: selezionare un ciclo scaricato sul dispositivo e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Configurazione guidata",
|
||||
"cloud_manual": "Modifica nomi"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Cicli scaricati"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Assegna un nome a questo ciclo",
|
||||
"description": "Il dispositivo è passato a un ciclo scaricato (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi selezionare il successivo sul dispositivo. Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
|
||||
"data": {
|
||||
"name": "Nome",
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nessun ciclo selezionato",
|
||||
"description": "Non è stato selezionato nulla sul dispositivo. Assicurarsi che sia impostato sul ciclo Scaricato, quindi scorrere i programmi scaricati.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Attendi di nuovo",
|
||||
"cloud_finish": "Fine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Inserisci almeno un campo da scrivere.",
|
||||
"write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli."
|
||||
"write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli.",
|
||||
"cloud_course_name_duplicate": "Due cicli hanno lo stesso nome. I nomi devono essere univoci.",
|
||||
"cloud_course_unknown_course": "Quel programma non è tra quelli offerti da questo dispositivo. Selezionarne uno dall'elenco."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selezionare ora un ciclo scaricato sul dispositivo.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Copertura delle funzionalità incompleta per {device_name}",
|
||||
"description": "Questo dispositivo non è completamente supportato. Il tipo di dispositivo non è stato riconosciuto oppure alcune delle risorse che espone non sono ancora state modellate. Continuerò a funzionare con le funzionalità già supportate. Puoi contribuire ad ampliare il supporto andando su Impostazioni > Dispositivi e servizi > {device_name} > il menu (in alto a destra) > Scarica diagnostica, quindi inviando una segnalazione tramite il modulo di segnalazione collegato."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Cicli scaricati non configurati per {device_name}",
|
||||
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1327,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensivo non è disponibile per il ciclo selezionato."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Il comando per {href} è fallito anche dopo la riconnessione: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Specificare da 1 a 10 scritture."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle deve essere compreso tra 0 e 30 secondi."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after deve essere compreso tra 0 e 60 secondi."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Questo servizio richiede esattamente un dispositivo di destinazione."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Nessun dispositivo corrispondente trovato per quella destinazione."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Questo dispositivo non è ancora connesso. Riprova una volta caricato."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,12 @@
|
||||
"diagnosis_start": {
|
||||
"name": "Diagnose starten"
|
||||
},
|
||||
"auto_clean_stop": {
|
||||
"name": "Zelfreiniging stoppen"
|
||||
},
|
||||
"filter_time_reset": {
|
||||
"name": "Filterteller op nul zetten"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pauzeren"
|
||||
},
|
||||
@@ -146,11 +152,13 @@
|
||||
"smart": "Slim",
|
||||
"speed": "Snel",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Slaap",
|
||||
"nanosleep": "WindFree-slaap",
|
||||
"longwind": "Lange wind",
|
||||
"motionindirect": "Beweging indirect",
|
||||
"motiondirect": "Beweging direct",
|
||||
"drycomfort": "Droog comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
@@ -191,6 +199,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Instelpunt"
|
||||
},
|
||||
"sensing_interval": {
|
||||
"name": "AI Purify-interval"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Instelpunt"
|
||||
},
|
||||
@@ -214,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Filteralarmdrempel"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Timer automatische deuropener"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modus drankenzone",
|
||||
"state": {
|
||||
@@ -286,7 +300,16 @@
|
||||
"07": "Voorspoelen",
|
||||
"8d": "Potten en pannen",
|
||||
"8e": "Kunststof",
|
||||
"8f": "Babyverzorging"
|
||||
"8f": "Babyverzorging",
|
||||
"82": "Auto",
|
||||
"8a": "Normaal",
|
||||
"a7": "Intensief",
|
||||
"a8": "Express",
|
||||
"8c": "Extra stil",
|
||||
"88": "Zelfreiniging",
|
||||
"85": "Delicaat",
|
||||
"0c": "Express",
|
||||
"0d": "Zelfreiniging"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -301,6 +324,22 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"9c": "Intensief",
|
||||
"a5": "Beddengoed",
|
||||
"9e": "Strijkvrij",
|
||||
"9b": "Stoomhygiëne+",
|
||||
"27": "Opfrissen",
|
||||
"a0": "Luchtdrogen",
|
||||
"a4": "Tijdprogramma",
|
||||
"a6": "Snel drogen",
|
||||
"a3": "Sportkleding",
|
||||
"a2": "Fijne was"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
@@ -328,7 +367,9 @@
|
||||
"4c": "Opfrissen",
|
||||
"51": "Eco katoen",
|
||||
"53": "AI drogen+",
|
||||
"4e": "Zelf drogen"
|
||||
"4e": "Zelf drogen",
|
||||
"26": "Luchtverfrissing",
|
||||
"2a": "Hygiënische verzorging+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -450,9 +491,11 @@
|
||||
"kimchi_storage_crunfch": "Knapperige kimchi",
|
||||
"kimchi_storage_buy": "Gekochte kimchi",
|
||||
"storage_fridge_normal": "Koelkast",
|
||||
"storage_fridge": "Koelkast",
|
||||
"storage_fridge_cold": "Koelkast, sterk",
|
||||
"storage_fridge_warm": "Koelkast, zwak",
|
||||
"storage_freezer_normal": "Vriezer",
|
||||
"storage_freezer": "Vriezer",
|
||||
"storage_freezer_cold": "Vriezer, sterk",
|
||||
"storage_freezer_warm": "Vriezer, zwak",
|
||||
"kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
|
||||
@@ -465,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Granen",
|
||||
"storage_fridge_drink": "Dranken",
|
||||
"storage_fresh_wine": "Wijn",
|
||||
"storage_fresh_potato_banana": "Aardappelen en bananen"
|
||||
"storage_fresh_potato_banana": "Aardappelen en bananen",
|
||||
"newmode_kimchi_0000": "Nieuwe modus"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -476,6 +520,16 @@
|
||||
"fdr_drinks": "Dranken"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Modus voorraadzone",
|
||||
"state": {
|
||||
"processed_meat": "Vleeswaren",
|
||||
"cheese": "Kaas",
|
||||
"nuts": "Noten",
|
||||
"fruit": "Fruit",
|
||||
"wine": "Wijn"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Vermogensniveau brander {number}",
|
||||
"state": {
|
||||
@@ -494,6 +548,14 @@
|
||||
"rinse_cycles": {
|
||||
"name": "Spoelcycli"
|
||||
},
|
||||
"sensing_mode": {
|
||||
"name": "AI Purify-actie",
|
||||
"state": {
|
||||
"off": "Alleen meten",
|
||||
"airpurify": "Automatisch reinigen",
|
||||
"alarm": "Melding ontvangen"
|
||||
}
|
||||
},
|
||||
"softener_concentration": {
|
||||
"name": "Concentratie wasverzachter",
|
||||
"state": {
|
||||
@@ -519,6 +581,9 @@
|
||||
"mute": "Stil"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Energiebesparingsmodus"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
@@ -555,11 +620,29 @@
|
||||
"extra_hot": "Extra heet"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"70": "Intensief",
|
||||
"55": "Witte was",
|
||||
"71": "Beddengoed",
|
||||
"72": "Hygiëne",
|
||||
"77": "Strijkvrij",
|
||||
"57": "Self Clean+",
|
||||
"73": "Spoelen + centrifugeren",
|
||||
"74": "Sportkleding",
|
||||
"75": "Fijne was",
|
||||
"78": "Snelle was"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"04": "Snelle was",
|
||||
"06": "XXL was",
|
||||
"08": "Spoelen+centrifugeren",
|
||||
"17": "Gedownload",
|
||||
"1b": "Katoen",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -570,7 +653,7 @@
|
||||
"21": "Bonte was",
|
||||
"22": "Wol",
|
||||
"23": "Outdoor",
|
||||
"24": "Handdoeken",
|
||||
"24": "Beddengoed",
|
||||
"25": "Synthetisch",
|
||||
"26": "Fijne was",
|
||||
"27": "Spoelen+centrifugeren",
|
||||
@@ -583,7 +666,7 @@
|
||||
"2f": "Sportkleding",
|
||||
"30": "Bewolkte dag",
|
||||
"32": "Overhemden",
|
||||
"33": "Beddengoed",
|
||||
"33": "Handdoeken",
|
||||
"34": "Gemengd",
|
||||
"36": "Wassen+drogen",
|
||||
"37": "Air Wash",
|
||||
@@ -599,14 +682,34 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Bonte was",
|
||||
"66": "Spijkergoed",
|
||||
"69": "AI wassen",
|
||||
"6a": "Wol",
|
||||
"6b": "Spijkergoed",
|
||||
"6c": "Blouses",
|
||||
"6d": "Fijne was",
|
||||
"6e": "Sportkleding",
|
||||
"6f": "Beddengoed",
|
||||
"70": "Handdoeken",
|
||||
"71": "Snelle was",
|
||||
"72": "Overhemden",
|
||||
"73": "Hygiëne",
|
||||
"74": "Trommel reinigen",
|
||||
"75": "Outdoor",
|
||||
"76": "Babyverzorging",
|
||||
"77": "Katoen",
|
||||
"78": "Spoelen + centrifugeren",
|
||||
"79": "Alleen centrifugeren",
|
||||
"7c": "Witte was",
|
||||
"7d": "Beddengoed/waterdicht",
|
||||
"7e": "Self Clean",
|
||||
"7f": "Wol/fijne was",
|
||||
"86": "Diep wassen",
|
||||
"87": "Gedownload",
|
||||
"88": "Huisdierverzorging",
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels"
|
||||
"96": "Minder microvezels",
|
||||
"a0": "15' Snelle was",
|
||||
"35": "Eco katoen"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -646,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatuur vriesgedeelte"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Randverlichtingsmodus",
|
||||
"state": {
|
||||
"smart": "Slim",
|
||||
"high": "Hoog",
|
||||
"low": "Laag"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Randverlichtingskleur",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Indicatorlampjemodus",
|
||||
"state": {
|
||||
"smart": "Slim",
|
||||
"high": "Hoog",
|
||||
"low": "Laag"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modus",
|
||||
"state": {
|
||||
"purification": "Zuivering",
|
||||
"ventilation": "Ventilatie",
|
||||
"smartventilation": "Slimme ventilatie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -661,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filterverbruik (uren)"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Luchtkwaliteitsnorm"
|
||||
},
|
||||
@@ -768,6 +906,12 @@
|
||||
"indirect": "Indirect"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Energiebesparing status"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Bedrijfsstatus energiebesparing"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatuur"
|
||||
},
|
||||
@@ -1021,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Energiebesparing bij afwezigheid actief"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Reiniging bij afwezigheid"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Bewegingsdetectie luchtstroomvermijding actief"
|
||||
},
|
||||
@@ -1030,6 +1177,12 @@
|
||||
"display": {
|
||||
"name": "Display"
|
||||
},
|
||||
"periodic_air_sensing": {
|
||||
"name": "AI Purify"
|
||||
},
|
||||
"periodic_sensing_skip_status": {
|
||||
"name": "AI Purify meting overslaan"
|
||||
},
|
||||
"pet_filter_activation": {
|
||||
"name": "Huisdierfilter actief"
|
||||
},
|
||||
@@ -1048,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Automatische deuropener"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Geluid automatische deuropener"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Spraakbediening automatische deuropener"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Auto Release Dry"
|
||||
},
|
||||
@@ -1179,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilatiealarm"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Randverlichting"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Indicatorlampje"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Wind-Free modus"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Slaapmodus"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1199,6 +1370,12 @@
|
||||
},
|
||||
"night_start": {
|
||||
"name": "Start nachtverlichting"
|
||||
},
|
||||
"sensing_skip_end": {
|
||||
"name": "Einde AI Purify meting overslaan"
|
||||
},
|
||||
"sensing_skip_start": {
|
||||
"name": "Start AI Purify meting overslaan"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
@@ -1261,17 +1438,24 @@
|
||||
"title": "LocalThings-opties",
|
||||
"menu_options": {
|
||||
"settings": "Apparaatinstellingen",
|
||||
"cloud_courses": "Gedownloade programma's",
|
||||
"forget_learned_modes": "Onthouden modi vergeten",
|
||||
"debug_write": "Foutopsporing: naar een resource schrijven"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"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.",
|
||||
"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.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
|
||||
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
|
||||
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
|
||||
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Onthouden modi vergeten",
|
||||
"description": "Nu onthouden: {codes}\n\nDit zijn modi waarin dit apparaat zichzelf meldde zonder ze als ondersteund op te geven; ze worden bewaard zodat ze selecteerbaar blijven. Vergeten is de oplossing als er een onterecht tussen staat: alles wat het apparaat echt opnieuw meldt, wordt gewoon opnieuw onthouden, tenzij je ook \"Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft\" bij Apparaatinstellingen uitzet."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Foutopsporing: naar een resource schrijven",
|
||||
"description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.",
|
||||
@@ -1293,20 +1477,63 @@
|
||||
"debug_write": "Naar een andere resource schrijven",
|
||||
"finish": "Voltooien"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||
"data": {
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Begeleide installatie loodst je stap voor stap langs het apparaat: selecteer op het apparaat een gedownload programma en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Begeleide installatie",
|
||||
"cloud_manual": "Namen bewerken"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Gedownloade programma's"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Geef dit programma een naam",
|
||||
"description": "Het apparaat is overgeschakeld naar een gedownload programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en selecteer daarna het volgende op het apparaat. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
|
||||
"data": {
|
||||
"name": "Naam",
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Geen programma geselecteerd",
|
||||
"description": "Er is niets geselecteerd op het apparaat. Zorg dat het is ingesteld op het programma \"Gedownload\" en doorloop dan de gedownloade programma's.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Opnieuw wachten",
|
||||
"cloud_finish": "Voltooien"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Voer ten minste één veld in om te schrijven.",
|
||||
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
||||
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie.",
|
||||
"cloud_course_name_duplicate": "Twee programma's hebben dezelfde naam. Namen moeten uniek zijn.",
|
||||
"cloud_course_unknown_course": "Dat programma biedt dit apparaat niet aan. Kies er een uit de lijst."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selecteer nu een gedownload programma op het apparaat.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}",
|
||||
"description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
|
||||
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1327,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Geef tussen de 1 en 10 schrijfacties op."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle moet tussen 0 en 30 seconden liggen."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after moet tussen 0 en 60 seconden liggen."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Deze service vereist precies één doelapparaat."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Er is geen bijpassend apparaat gevonden voor dat doel."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,29 @@
|
||||
"""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.
|
||||
docstring for the general pattern): 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) 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.
|
||||
|
||||
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.
|
||||
Writes go through `coordinator.async_send_command`: DHW's `write_fn`
|
||||
(ehs._dhw_write) maps each `(kind, value)` payload to the right
|
||||
`(path_segs, body)`, applying the optimistic value/settle guard to that
|
||||
resource's own href rather than the bound `/mode/dhw/vs/0` href.
|
||||
|
||||
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).
|
||||
Power) map onto HA's own standard water_heater states, the same mapping
|
||||
HA core's `smartthings` integration uses for this exact Samsung capability
|
||||
(`samsungce.ehsThermostat`), just title-cased to match this OCF resource's
|
||||
spelling. Reusing HA's standard states means no state translation catalog
|
||||
entry is needed for them.
|
||||
|
||||
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'`
|
||||
Naming differs from climate.py's: the AC *is* the device, so its card takes
|
||||
the bare device name. An EHS unit has two loops, and DHW isn't "the
|
||||
device" (siblings are named "Zone Mode"/"Zone Target Temperature"), so this
|
||||
entity is named through the catalog via `translation_key='dhw'`
|
||||
(entity.water_heater.dhw.name -> "Hot water").
|
||||
"""
|
||||
|
||||
@@ -73,8 +64,7 @@ _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 mode <-> HA water_heater operation state -- see module docstring.
|
||||
_DEVICE_TO_STATE: dict[str, str] = {
|
||||
"Eco": STATE_ECO,
|
||||
"Std": STATE_HEAT_PUMP,
|
||||
@@ -83,12 +73,10 @@ _DEVICE_TO_STATE: dict[str, str] = {
|
||||
}
|
||||
_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.
|
||||
# Read-side lookup, case-folded: this map is bijective (unlike climate.py's
|
||||
# 'Wind'/'Fan' -> FAN_ONLY), so the write side uses _STATE_TO_DEVICE
|
||||
# directly; only the read side needs to absorb a board spelling the same
|
||||
# code differently ('eco'/'ECO').
|
||||
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
|
||||
|
||||
|
||||
@@ -132,21 +120,18 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
||||
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.
|
||||
# two-loop device and takes a catalog name through translation_key.
|
||||
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.
|
||||
# Raw device codes already logged by _warn_unmapped -- read on every
|
||||
# refresh, so un-deduped would spam the log for an unrecognized code.
|
||||
self._warned_unmapped: set[str] = set()
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
"""`href` is one of this module's canonical HREF_* constants --
|
||||
"""`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 {}
|
||||
@@ -188,14 +173,10 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
||||
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.
|
||||
"""
|
||||
"""The device's own (minimum, maximum) pair, or None. Both ends
|
||||
together or neither -- same rule as climate._range(); a board
|
||||
reporting only minimum would otherwise pair it with HA's own
|
||||
default maximum, silently wrong."""
|
||||
rep = self._rep(TEMPERATURE_HREF)
|
||||
lo = _num(rep.get("x.com.samsung.da.minimum"))
|
||||
hi = _num(rep.get("x.com.samsung.da.maximum"))
|
||||
@@ -213,8 +194,7 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float:
|
||||
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0
|
||||
# into the fallback.
|
||||
# `is None`, not `or` -- `or` would collapse a genuine 0 (issue #160).
|
||||
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
|
||||
return 0.5 if step is None else step
|
||||
|
||||
@@ -245,13 +225,11 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
||||
# -- 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).
|
||||
# HA's water_heater.set_temperature service can carry an optional
|
||||
# operation_mode; honor it, setting the mode first (which also
|
||||
# powers the loop on) so a dashboard "boost to 55" button that
|
||||
# carries a mode actually changes mode, not just the setpoint. Same
|
||||
# fix as climate.async_set_temperature.
|
||||
operation_mode = kwargs.get("operation_mode")
|
||||
if operation_mode is not None:
|
||||
await self.async_set_operation_mode(operation_mode)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# AC filter-time counter reset: solved
|
||||
|
||||
`registry/capabilities/airconditioner.py`'s `filter_time` sensor
|
||||
(`FilterTime_<N>` option token, tenths of an hour) has a reset entity now
|
||||
(`filter_time_reset`, issue-tracked as PR #289): a single-token options write
|
||||
of `FilterCleanAlarm_Clear` to `/mode/vs/0`, the same merge every other
|
||||
setting on that href uses. Measured on an ARTIK051_KRAC_18K: `FilterTime_95`
|
||||
(9h30m) → `FilterTime_0`, still zero on a fresh DTLS session and every poll
|
||||
after; none of the other 17 tokens moved and the `/alarms/vs/0` entries
|
||||
stayed `Deleted`.
|
||||
|
||||
The rest of this file is kept as-is: the failed attempts below are still the
|
||||
best record of what *doesn't* work on this generation, and the reasoning
|
||||
that follows them explains why the reset looked cloud-only for as long as it
|
||||
did — a genuine trap worth knowing about before the next reset-adjacent
|
||||
mystery on this board family.
|
||||
|
||||
## What the reset actually is
|
||||
|
||||
A **command**, not a value write. Samsung's cloud models it as capability
|
||||
`custom.dustFilter`, command `resetDustFilter`, no arguments (implemented in
|
||||
several SmartThings HA forks; not in the core integration) — that command
|
||||
name is real, but see below for why POSTing it directly went nowhere.
|
||||
|
||||
## Tried, all against a live unit, all failed (before the token above was found)
|
||||
|
||||
- `FilterTime_0` via the single-token options merge that works for every
|
||||
other setting on this href — accepted with no error, then discarded. Tried
|
||||
on two units in opposite power states to rule out the obvious confound:
|
||||
5595 → back to 5595 after 69s (powered off, alarm active), 1925 → back to
|
||||
1925 after 65s (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` (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, <...>"`. Guessed action names were deliberately
|
||||
not enumerated 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
|
||||
|
||||
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_` is written through the very same options
|
||||
merge and kept. Writes work; this one value just isn't driven that way.
|
||||
|
||||
## The token, and why the dead ends below missed it
|
||||
|
||||
`FilterCleanAlarm_Clear` is not derived from anything in this file's earlier
|
||||
attempts — how it was originally identified isn't recorded here. What is
|
||||
recorded is why the standard technique (diff the appliance's reported state
|
||||
before/after triggering the action in Samsung's app) couldn't have found it
|
||||
on its own: the token is a trigger, never stored and never echoed back in
|
||||
`x.com.samsung.da.options`, so a before/after diff of stored state shows
|
||||
only the *effects* (counter zeroing, alarm clearing) and never the token
|
||||
that caused them.
|
||||
|
||||
## Dead ends tried before the token was known (kept for the next unrelated mystery)
|
||||
|
||||
- The `/actions/vs/0` action vocabulary from an independent source (a
|
||||
firmware image, or a capture of what the cloud sends the device).
|
||||
- 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.
|
||||
|
||||
## Evidence for the counter's direction and scale
|
||||
|
||||
Confirmed counting *up* (running time since last reset, not remaining time):
|
||||
token 1710 matched the Samsung app's "171 hours 0 minutes" for the same
|
||||
filter (pins the tenths-of-an-hour scale); seen rising while the unit ran
|
||||
(171.0 → 171.5); and across two units on one site the `/alarms/vs/0` filter
|
||||
alarm tracks the counter in the right direction — live (`FilterAlarm`,
|
||||
`Created`) at `FilterTime_5595`, still the `FilterAlarm_OFF`/`Deleted`
|
||||
placeholder at `FilterTime_1915`, matching the app's own 500-hour threshold
|
||||
behavior. `FilterAlarmTime_` in the same options blob is that threshold (500
|
||||
on every unit on record).
|
||||
|
||||
The entity key stays `filter_time` rather than `filter_time_elapsed`:
|
||||
renaming it would change every existing unit's `entity_id`/`unique_id` for a
|
||||
wording improvement only.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Laundry cloud "Download" cycles: solved, with one dead end
|
||||
|
||||
`registry/capabilities/laundry.py`'s cycle select offers a washer's
|
||||
downloaded ("Download" / "Downloaded") programs alongside its ordinary
|
||||
courses now, driven by the store in `cloudcourse.py` (issue #342). This file
|
||||
records the byte-level work behind it, including a decode that fit one
|
||||
device perfectly and collapsed on the second — the reason nothing in the
|
||||
shipped code interprets a program payload at all.
|
||||
|
||||
Four devices in the corpus carry these tokens (a survey of every laundry
|
||||
diagnostics dump attached to an issue turned up 14 devices; the other 10 have
|
||||
no cloud tokens at all, so this is a minority feature):
|
||||
|
||||
| dump | model | slots advertised | payloads seen | blob width |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes |
|
||||
| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes |
|
||||
| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 (but see below) | **0** | — |
|
||||
| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes |
|
||||
|
||||
Three things follow immediately from that table:
|
||||
|
||||
- **`CloudExtraCourse_` does not mean the same thing on every family.** On
|
||||
the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that
|
||||
dishwasher's *own* course list, three already translated (Plastic, Pots and
|
||||
pans, Baby Care). There it tags which ordinary courses came from the cloud;
|
||||
they select with a plain `Course_` write and need no payload — consistent
|
||||
with it carrying no payload token at all. It also has a
|
||||
`DownloadCourseList_8F` token the washers lack.
|
||||
On both washers the slots share **zero** overlap with the course list and a
|
||||
payload is required. Subtracting the course list is what tells the two
|
||||
apart (`cloudcourse.cloud_slots`), so the feature engages on the washers
|
||||
and correctly does nothing on the dishwasher.
|
||||
- **A device can advertise a slot it has never loaded.** True on the washers
|
||||
too — nothing about a program is learnable until its owner runs it, which
|
||||
is what the Repairs issue exists to explain.
|
||||
- **Both WW5000C units advertise the byte-identical slot list**
|
||||
(`0A5C286B2D0C55301A`, same nine slots in the same order) despite different
|
||||
firmware builds. Either the set is a factory/regional default rather than
|
||||
something each owner curates, or the two dumps share an owner — unresolved,
|
||||
but worth knowing before assuming a user picked their own programs.
|
||||
|
||||
## The three tokens
|
||||
|
||||
All on `/course/vs/0`'s `x.com.samsung.da.options` array, same
|
||||
prefix-match/replace merge as every other token there.
|
||||
|
||||
- `CloudExtraCourse_<slot><slot>…` — the device's own list of downloaded
|
||||
program slots, one byte each. The cloud counterpart of `EditCourseList_`.
|
||||
- `CloudCourse_<blob>` — the persisted default program.
|
||||
- `OneTimeCloudCourse_<blob>` — a this-run-only override.
|
||||
|
||||
### `CloudExtraCourse_` is an enumeration, and byte 2 of a blob is its slot
|
||||
|
||||
The WW5000C reports `CloudExtraCourse_0A5C286B2D0C55301A` — nine bytes for
|
||||
its nine downloaded programs. Byte 2 of each of the nine blobs its owner
|
||||
captured is exactly one of those nine, no repeats, sets equal:
|
||||
|
||||
```
|
||||
blob byte2 program
|
||||
00 21 55 04 49 28 4D 13 4A A0 4C 00 … 55 Sports
|
||||
00 20 28 04 49 00 4D 00 4A B8 4C 00 … 28 Spin only
|
||||
00 02 5C 04 49 28 4D 13 4A B0 4C 00 … 5C Outdoor
|
||||
00 1F 6B 04 49 28 4D 13 4A A0 4C 00 … 6B Jeans
|
||||
00 2E 2D 04 49 30 4D 12 4A A0 4C 00 … 2D Super quiet
|
||||
00 2F 0C 04 49 58 4D 14 4A B8 4C 00 … 0C Baby care intensive
|
||||
00 0D 30 04 49 30 4D 12 4A B8 4C 00 … 30 Cloudy heaven
|
||||
00 30 1A 04 49 28 4D 12 4A A0 4C 00 … 1A Shirts
|
||||
00 04 0A 04 49 40 4D 13 4A B8 4C 00 … 0A Towels
|
||||
|
||||
CloudExtraCourse_ 0A 5C 28 6B 2D 0C 55 30 1A
|
||||
```
|
||||
|
||||
Confirmed independently on the WA55A7700AV: `CloudExtraCourse_5958`, and its
|
||||
`CloudCourse` blob `00 1C 59 05 …` has byte 2 = `59`. Its
|
||||
`OneTimeCloudCourse` is `FF FF 01 …` — byte 2 = `01`, which is *not* an
|
||||
advertised slot, and the `FFFF` prefix marks it as "nothing loaded" rather
|
||||
than naming a program. That sentinel is why `cloudcourse.is_loaded` exists.
|
||||
|
||||
This is what makes "3 of 9 discovered" answerable, and it is why no catalog
|
||||
of program ids is hardcoded anywhere: the appliance already knows which
|
||||
programs it has.
|
||||
|
||||
## Writing: the two-token rule
|
||||
|
||||
Confirmed on hardware by the issue #342 reporter. Writing
|
||||
`OneTimeCloudCourse_<blob>` alone while some other course is selected is
|
||||
accepted at the protocol level (no error) and then silently ignored by the
|
||||
machine. It takes effect only when the same write also switches `Course_` to
|
||||
the Download course — which is what `laundry._cloud_cycle_write` does, and
|
||||
the only two-token options write in the codebase:
|
||||
|
||||
```yaml
|
||||
x.com.samsung.da.options:
|
||||
- Course_87
|
||||
- OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00
|
||||
```
|
||||
|
||||
`CloudCourse_` was separately confirmed writable on its own: set while on
|
||||
Download it changes the running program; set from another course it becomes
|
||||
what gets preselected the next time Download is chosen. The integration
|
||||
doesn't write it today — a "default download cycle" control is a possible
|
||||
follow-up, deliberately left out of the first pass.
|
||||
|
||||
### There is no single "Download" course code
|
||||
|
||||
The WW5000C's Download is `Course_87`; the WA55A7700AV's is `17`
|
||||
("Downloaded" in `washer_cycle_table_02`). **Same course table, different
|
||||
code.** Any per-table lookup of "the Download code" would have been wrong on
|
||||
one of the only two devices available to check it against, which is why the
|
||||
code is learned by observation and confirmed by the user in the options flow
|
||||
instead of tabled.
|
||||
|
||||
The observation signal is "whatever `Course_` reads at the moment a
|
||||
non-sentinel `OneTimeCloudCourse_` *appears or changes*" — a transition that
|
||||
was actually watched, not a state. Tokens in this array are replaced by
|
||||
prefix and never evicted, so a payload merely sitting there says nothing
|
||||
about when it got there; on the first rep after a restart it is equally
|
||||
consistent with "just loaded" and "left over from last week". Believing it
|
||||
would propose whatever ordinary course the appliance happens to be sitting
|
||||
on, and accepting that prefill starts a real wash cycle. Even a genuine
|
||||
transition is only ever a *candidate*, confirmed by the user before use.
|
||||
|
||||
Both dumps in the corpus taken while off the Download course
|
||||
(`washer_wa55a7700av` on `Course_01`, the `_B048` washer on `Course_1C`)
|
||||
show the appliance clearing its one-time token to the `FFFF` sentinel, so
|
||||
the saved default persists but the one-shot does not. That makes the stale
|
||||
case unlikely on these boards — which is a reason to expect it to behave,
|
||||
not a reason to depend on it.
|
||||
|
||||
## The dead end: bytes 5/7/9 do not decode portably
|
||||
|
||||
With the nine WW5000C programs and their app-reported settings side by side,
|
||||
three of the varying bytes fit perfectly:
|
||||
|
||||
| byte | meaning | formula | fit |
|
||||
| --- | --- | --- | --- |
|
||||
| 5 | wash temperature | `(b - 0x10) / 0.8` °C, `0x00` = n/a | 9/9 |
|
||||
| 7 | rinse count | `b - 0x10`, `0x00` = off | 9/9 |
|
||||
| 9 | spin level | `(b - 0x90) / 8` | 9/9 |
|
||||
|
||||
Nine for nine, including the internally consistent case: "Spin only" is the
|
||||
only program with `0x00` in *both* byte 5 and byte 7, matching a cycle that
|
||||
skips washing entirely while still reporting a spin level.
|
||||
|
||||
It does not survive the second device. Against the WA55A7700AV's
|
||||
`CloudCourse` blob `00 1C 59 05 49 16 4D 11 4A 22 4C 20 37 F0 AC 22`:
|
||||
|
||||
- temperature: `(0x16 - 0x10) / 0.8` = **7.5 °C**
|
||||
- spin: `(0x22 - 0x90) / 8` = **negative**
|
||||
- rinse: `0x11 - 0x10` = 1 — the only plausible one
|
||||
|
||||
### What *does* survive is the grammar
|
||||
|
||||
The two boards' payloads are different lengths (20 vs 16 bytes) but not a
|
||||
different format — same header, same leading fields, two fewer optional
|
||||
trailing ones:
|
||||
|
||||
```
|
||||
WW5000C 00 | 2155 | 04 | 49:28 4D:13 4A:A0 4C:00 | 35:F0 04:F0 05:F0 | AC:00
|
||||
WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC:22
|
||||
```
|
||||
|
||||
- `00`, then the 2-byte program id, then one byte (`04` vs `05`) — identical
|
||||
layout on both.
|
||||
- Then a tag/value stream whose **first four tags are the same, in the same
|
||||
order, at the same offsets**: `49`, `4D`, `4A`, `4C`. These are exactly the
|
||||
four whose values vary per program.
|
||||
- Then a fixed tail, terminated on both by an `AC:<value>` pair. The entire
|
||||
width difference is two trailing pairs the WA55 doesn't carry.
|
||||
|
||||
The tail is not program data. Across all nine WW5000C programs bytes 12–19
|
||||
are byte-identical — every trailing pair carries value `F0` except the `AC`
|
||||
terminator, and `35` vs `37` looks like a board or profile marker rather than
|
||||
a field. (It is not a field count either: the board with the *higher* leading
|
||||
byte has *fewer* pairs.)
|
||||
|
||||
### Byte 3 is not part of a program's identity
|
||||
|
||||
Worth its own heading, because it is the single strongest argument against
|
||||
ever shipping a table of payloads. The second WW5000C (issues #259/#343,
|
||||
firmware `_B048`) has its saved `CloudCourse` set to the same program as the
|
||||
first one's "Towels" capture — and the two payloads differ at exactly one
|
||||
byte:
|
||||
|
||||
```
|
||||
_B06C "Towels" 00 04 0A 04 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
|
||||
_B048 CloudCourse 00 04 0A 06 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
|
||||
^^
|
||||
```
|
||||
|
||||
Same program id, same slot, same values on all four varying tags, same tail.
|
||||
Only byte 3 moves, `04` → `06`. So it is neither a per-board constant (both
|
||||
are WW5000C) nor a property of the program (identical in every other
|
||||
respect) — most likely a download revision or sequence counter.
|
||||
|
||||
A hardcoded catalog keyed on program id would therefore have shipped one
|
||||
unit's byte 3 to the other unit. Whether the appliance would reject that, or
|
||||
accept it and do something unintended, is untested and does not need to be:
|
||||
every payload is learned from the device it will be replayed to.
|
||||
|
||||
### Sentinels
|
||||
|
||||
The `FFFF` "nothing loaded" payload takes its board's own width (16 bytes on
|
||||
the WA55, 20 on the WW5000C `_B048`) and always carries byte 3 = `00`. Its
|
||||
byte 2 is *not* reliably meaningful: it equals the currently selected course
|
||||
on the WA55 (`01`, on `Course_01`) and does not on the `_B048` (`1B`, on
|
||||
`Course_1C`). Nothing keys off it — a sentinel is rejected on its `FFFF`
|
||||
prefix, and its byte 2 is not an advertised slot in either dump anyway.
|
||||
|
||||
So the payload is tag/value, not fixed offsets — but knowing the grammar
|
||||
doesn't recover the values. The same four tags carry non-overlapping ranges
|
||||
between the two boards:
|
||||
|
||||
| tag | WW5000C (9 programs) | WA55 |
|
||||
| --- | --- | --- |
|
||||
| `49` | `00, 28, 30, 40, 58` | `16` |
|
||||
| `4D` | `00, 12, 13, 14` | `11` |
|
||||
| `4A` | `A0, B0, B8` | `22` |
|
||||
| `4C` | `00` (all nine) | `20` |
|
||||
|
||||
Same field, board-specific encoding. Decoding it properly needs a third
|
||||
device; one device's fit is a coincidence-shaped hypothesis, not a format.
|
||||
|
||||
**A trap for whoever picks this up:** the WA55's `/washer/vs/0` reads
|
||||
Warm / High / 1, which looks like it could confirm a decode of that unit's
|
||||
`CloudCourse`. It can't — that appliance is sitting on `Course_01` (Normal),
|
||||
not on its cloud course, so those values describe the local cycle it has
|
||||
selected, not the saved cloud program. A cross-check like this is only
|
||||
evidence when the machine is actually loaded with the program being decoded.
|
||||
|
||||
So the shipped code never interprets a payload: a blob is recorded whole and
|
||||
replayed byte-for-byte, exactly as the device reported it, and never
|
||||
decomposed or rebuilt. The read-only "loaded program's temperature/spin"
|
||||
sensors this decode would have enabled were dropped for the same reason.
|
||||
|
||||
## What is deliberately not done
|
||||
|
||||
- **No hardcoded program catalog.** Blobs are cloud-assigned per
|
||||
account/region. One owner's captured payload is not evidence about anyone
|
||||
else's appliance, and a table of them would offer options that write
|
||||
another household's wash settings.
|
||||
- **No invented names.** The appliance reports an opaque slot id and nothing
|
||||
else. Names come from the user in the options flow, the same rule that
|
||||
stops an unrecognized local course code from getting a made-up English
|
||||
label (PR #251 review).
|
||||
- **No blob synthesis.** Even with the byte 5/7/9 decode in hand, nothing
|
||||
builds a payload from parts — the device was never tested with one, and a
|
||||
fabricated blob is an untested write to a wash cycle.
|
||||
|
||||
## Open questions for the next dump
|
||||
|
||||
1. Does `OneTimeCloudCourse_` clear itself when a cycle finishes, or when the
|
||||
course changes? Behavior suggests the appliance falls back to
|
||||
`CloudCourse_` when Download is re-entered, but the token's own lifecycle
|
||||
is unconfirmed. `laundry.cloud_current` is written to be correct either
|
||||
way.
|
||||
2. What does byte 1 mean? It is distinct per program and *sometimes*
|
||||
coincides with a plausible local course code for that program (`2E` Baby
|
||||
Care for "Baby care intensive", `30` Cloudy Day for "Cloudy heaven") and
|
||||
sometimes doesn't (`21` Colors for "Sports"). Probably a base-course
|
||||
reference; not reliable enough to use.
|
||||
3. What does byte 3 count? It moves between two units holding the identical
|
||||
program (`04` vs `06`) and is `00` on every sentinel. A revision or
|
||||
download counter is the obvious guess; a dump taken before and after
|
||||
re-downloading the same program would confirm it.
|
||||
4. **The value encoding is still open, and a third *washer* won't
|
||||
necessarily settle it.** The survey found one, but its saved program is a
|
||||
duplicate of one already captured, so it adds no new tag values. What is
|
||||
actually needed is a dump from a board whose `/washer/vs/0` speaks in
|
||||
named levels (Cold/Warm/Hot, Low/High) *while that unit is sitting on a
|
||||
downloaded program* — then the payload's `49`/`4A` values can be read
|
||||
against settings that describe the same program. The WA55 is such a
|
||||
board but was captured on a local course, which is why it can't be used
|
||||
(see the trap above).
|
||||
5. The DW5000C's four slots (`8E 8D 8F 02`) are in the same numeric range as
|
||||
dishwasher course codes (its selected course is `86`), unlike the
|
||||
washers' slots. One payload from that machine would show whether slot ids
|
||||
are drawn from the course-code space on some boards.
|
||||
+8
-1
@@ -70,7 +70,7 @@ class FakeCoapSession:
|
||||
pass
|
||||
|
||||
|
||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list], device_types=()):
|
||||
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
||||
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
||||
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
||||
@@ -78,6 +78,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
it. See the adding-device-support skill's section 2 for the plain
|
||||
(non-subdevice) equivalent this extends.
|
||||
|
||||
`device_types` is the master's own /oic/d `rt` (see
|
||||
discover_partitioned's `oic_device_types` param) -- only needed for a
|
||||
board with no /information/vs/0 at all to route from (issue #324's
|
||||
range, whose modelNum-based fallback has nothing to read), so it
|
||||
defaults to () for every fixture that resolves by board token instead.
|
||||
|
||||
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
||||
- `bound`: every BoundEntity, main + every materialized subdevice.
|
||||
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
||||
@@ -101,6 +107,7 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
candidates,
|
||||
resolve,
|
||||
CAPABILITIES,
|
||||
oic_device_types=device_types,
|
||||
)
|
||||
return bound, materialized, skipped, full_resources, device_type_name
|
||||
|
||||
|
||||
+1056
File diff suppressed because it is too large
Load Diff
+297
@@ -0,0 +1,297 @@
|
||||
{
|
||||
"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.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": "DishA_Disable",
|
||||
"x.com.samsung.da.triggeredTime": "2022-07-28T13:45:57",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0812",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"CourseDefaultTimeSet_008B008E009A006D003C000A006600A50059009D",
|
||||
"CourseDefaultTempSet_3C41363F41413737373700003C3C464649493C3C",
|
||||
"CourseDefaultFahrenheitSet_8C95819195958383838300008C8C9E9EA3A38C8C",
|
||||
"Course_86",
|
||||
"DetergentOnce_0",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_0",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_0",
|
||||
"DetergentTotal_0",
|
||||
"ProgressTimeSet_420622820456B20384",
|
||||
"SendToDevice_Off",
|
||||
"GMT_F2",
|
||||
"SavingModeCondition_01010207828485868E8D020300",
|
||||
"SavingMode_Off",
|
||||
"DownloadCourseList_8F",
|
||||
"StormWashZone_Off",
|
||||
"AutoDoorRelease_On",
|
||||
"Sound_On",
|
||||
"WaterLevelSet_050404050302010205010400",
|
||||
"CloudExtraCourse_8E8D8F02",
|
||||
"EnergyLevelSet_050403050202010305040300",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_Empty",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"482830CB002C002D00283830CB002C002D00284830CB002C002D00285830CB000C000D00086830CB002C002D002908308B000C000D0008E830CB000C000D0008D830CB002C002D0028F8308B000C000D00002830CB002C002D002"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Run",
|
||||
"x.com.samsung.da.remainingTime": "00:44:00",
|
||||
"x.com.samsung.da.progressPercentage": "28",
|
||||
"x.com.samsung.da.delayStartTime": "00:00:00",
|
||||
"x.com.samsung.da.progress": "Wash",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Predrain",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Drying",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Predrain",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Drying",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "Wash",
|
||||
"remainingTime": "00:44:00",
|
||||
"progressPercentage": "28"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
|
||||
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON_DW5000C/DD92-0010741_0001",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"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": "WD0",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "00081A230213(A214)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB_30010741230602142FFFFFFFFFFFFFFFFFFFFFFFFFFE(081230010741FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "23060214,FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dishwasher/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.highTemperatureDry": "Off",
|
||||
"x.com.samsung.da.sanitize": "Off",
|
||||
"x.com.samsung.da.selectedZone": "ON_ON",
|
||||
"x.com.samsung.da.rinseLevel": "0",
|
||||
"x.com.samsung.da.supportedSelectedZone": [
|
||||
"OFF_ON",
|
||||
"ON_ON"
|
||||
],
|
||||
"x.com.samsung.da.supportedSanitize": [
|
||||
"Off",
|
||||
"On"
|
||||
],
|
||||
"x.com.samsung.da.supportedHighTemperatureDry": [
|
||||
"Off",
|
||||
"On"
|
||||
],
|
||||
"x.com.samsung.da.supportedRinseLevel": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "CA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/waterfilter/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"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": "/water/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeWater": "2662000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/submode/vs/0",
|
||||
"rep": {
|
||||
"setTemperatureUnit": "F"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"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": "CT_E",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-06T14:24:02"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bluetooth/hood/status/vs/0",
|
||||
"rep": {
|
||||
"connectionState": "disconnected",
|
||||
"micomModelId": "",
|
||||
"firmwareVersion": "",
|
||||
"power": "off",
|
||||
"fanSpeed": 0,
|
||||
"lampState": "off",
|
||||
"timer": {},
|
||||
"rt": [
|
||||
"bluetoothHoodStatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.connected"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NV8000T-/KO0",
|
||||
"Pause_Off",
|
||||
"SyncFlex_Off",
|
||||
"FlexCoil_0",
|
||||
"MainTimerCurrent_0",
|
||||
"MainTimerSet_0",
|
||||
"MainTimerState_Ready",
|
||||
"IndependentTimerCheck_Enable",
|
||||
"OperationState0_Ready",
|
||||
"HotSurface0_Normal",
|
||||
"PowerLevel0_0",
|
||||
"OperationState1_Ready",
|
||||
"HotSurface1_Normal",
|
||||
"PowerLevel1_0",
|
||||
"OperationState2_Ready",
|
||||
"HotSurface2_Normal",
|
||||
"PowerLevel2_0",
|
||||
"OperationState3_Ready",
|
||||
"HotSurface3_Normal",
|
||||
"PowerLevel3_0",
|
||||
"OperationState4_Ready",
|
||||
"HotSurface4_Normal",
|
||||
"PowerLevel4_0",
|
||||
"OperationState5_Ready",
|
||||
"HotSurface5_Normal",
|
||||
"PowerLevel5_0"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"clean_level",
|
||||
"device_active",
|
||||
@@ -10,9 +11,17 @@
|
||||
"firmware_update",
|
||||
"hepa_filter_status",
|
||||
"hepa_filter_usage",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"mute_once",
|
||||
"odor",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"power_switch",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"super_fine_dust",
|
||||
"wind_strength_fan"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"clean_level",
|
||||
@@ -14,11 +15,18 @@
|
||||
"firmware_update",
|
||||
"hepa_filter_status",
|
||||
"hepa_filter_usage",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"mute_once",
|
||||
"odor",
|
||||
"panel_status",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"pet_filter_activation",
|
||||
"power_switch",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"sound_volume",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"clean_level",
|
||||
"device_active",
|
||||
@@ -9,8 +10,16 @@
|
||||
"firmware_update",
|
||||
"hepa_filter_status",
|
||||
"hepa_filter_usage",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"odor",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"power_switch",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"super_fine_dust",
|
||||
"wind_strength_fan"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"absence_clean",
|
||||
"ai_energy_level",
|
||||
"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_temperature_c",
|
||||
"display",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_saving_mode",
|
||||
"energy_saving_operating_status",
|
||||
"energy_saving_state",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"tropical_night_mode",
|
||||
"uv_led",
|
||||
"ventilation_alarm"
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"auto_clean_progress_legacy",
|
||||
"beep",
|
||||
"clean_level",
|
||||
"climate",
|
||||
@@ -18,6 +19,7 @@
|
||||
"super_fine_dust",
|
||||
"subdevice1_alarm_code",
|
||||
"subdevice1_auto_clean_legacy",
|
||||
"subdevice1_auto_clean_progress_legacy",
|
||||
"subdevice1_beep",
|
||||
"subdevice1_climate",
|
||||
"subdevice1_current_temperature_c",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"auto_clean_progress_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"auto_clean_progress_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"auto_clean_progress_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
+18
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"absence_clean",
|
||||
"absence_power_saving_active",
|
||||
"absence_power_saving_mode",
|
||||
"air_filter_pm1_status",
|
||||
@@ -9,6 +10,7 @@
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
@@ -17,20 +19,36 @@
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"dust",
|
||||
"edge_lighting",
|
||||
"edge_lighting_color",
|
||||
"edge_lighting_mode",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"indicator_light",
|
||||
"indicator_light_mode",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"motion_detect_wind_active",
|
||||
"motion_detect_wind_mode",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"sound_volume",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode",
|
||||
"uv_led"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
@@ -19,10 +20,18 @@
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"mute_once",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_release_dry",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"energy_saved_kwh",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"heated_dry",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sanitize",
|
||||
"storm_wash",
|
||||
"water_liters"
|
||||
]
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"any_burner_active",
|
||||
"burner_0_state",
|
||||
"burner_1_state",
|
||||
"burner_2_state",
|
||||
"burner_3_state",
|
||||
"burner_4_state",
|
||||
"burner_5_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"firmware_update",
|
||||
"main_timer_current",
|
||||
"main_timer_state",
|
||||
"paired_hood_connected",
|
||||
"paired_hood_fan_speed",
|
||||
"paired_hood_firmware",
|
||||
"paired_hood_light",
|
||||
"paired_hood_model",
|
||||
"paired_hood_power",
|
||||
"power_state"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"burner_0_hot_surface",
|
||||
"burner_0_pan_detected",
|
||||
"burner_0_power_level",
|
||||
"burner_0_state",
|
||||
"burner_1_hot_surface",
|
||||
"burner_1_pan_detected",
|
||||
"burner_1_power_level",
|
||||
"burner_1_state",
|
||||
"burner_2_hot_surface",
|
||||
"burner_2_pan_detected",
|
||||
"burner_2_power_level",
|
||||
"burner_2_state",
|
||||
"cooktop_child_lock",
|
||||
"cooktop_power",
|
||||
"cooktop_safety_shutoff_enabled",
|
||||
"cooktop_state",
|
||||
"energy_kwh",
|
||||
"firmware_update"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"diagnosis_status",
|
||||
"door_open",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooktop_on_alert",
|
||||
"cooktop_running_state",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound",
|
||||
"subdevice1_cloud_connected",
|
||||
"subdevice1_cook_time",
|
||||
"subdevice1_current_temp_c",
|
||||
"subdevice1_cycle_active",
|
||||
"subdevice1_finish_time",
|
||||
"subdevice1_machine_state",
|
||||
"subdevice1_operation_time_minutes",
|
||||
"subdevice1_oven_mode",
|
||||
"subdevice1_oven_setpoint",
|
||||
"subdevice1_oven_state",
|
||||
"subdevice1_progress_percentage",
|
||||
"subdevice1_sound",
|
||||
"warming_center_state"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"brightness_level",
|
||||
"cabinet_light_dim",
|
||||
"cabinet_light_switch",
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"day_brightness",
|
||||
"door_alert",
|
||||
"door_cooler_open",
|
||||
"door_freezer_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"fridge_sound",
|
||||
"ice_night_mode",
|
||||
"icemaker_one_enabled",
|
||||
"icemaker_one_making_status",
|
||||
"night_end",
|
||||
"night_start",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"auto_door_timer",
|
||||
"auto_door_voice_control",
|
||||
"defrost_active",
|
||||
"door_onedoorfreezer_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"fridge_sound",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"auto_door_timer",
|
||||
"auto_door_voice_control",
|
||||
"door_onedoorkimchi_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"fridge_sound",
|
||||
"onedoor_mode",
|
||||
"onedoor_rack_count",
|
||||
"onedoor_ripening_remaining",
|
||||
"onedoor_ripening_status",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"auto_door_sound_control",
|
||||
"auto_door_timer",
|
||||
"auto_door_voice_control",
|
||||
"cabinet_light_dim",
|
||||
"cabinet_light_switch",
|
||||
"deodor_filter_status",
|
||||
"deodor_filter_usage",
|
||||
"door_winecellar_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"fridge_sound",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"winecellar_bottom_setpoint",
|
||||
"winecellar_pantry_zone_mode",
|
||||
"winecellar_top_setpoint"
|
||||
]
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"drum_clean_last_cleaned",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_sound",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"intensive",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"pre_wash",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"softener_low",
|
||||
"spin_speed",
|
||||
"wash_temperature",
|
||||
"water_liters"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"meta": {
|
||||
"note": "Samsung NV9000D induction cooktop (NV9000D-/KO2). Scrubbed real /device/0 dump captured for compatibility regression coverage. This firmware starts directly with /connectionconfig/vs/0 instead of a device-level collection representation.",
|
||||
"model": "TP1X_DA-KS-COOKTOP-01011",
|
||||
"product": "NV9000D-/KO2"
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"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": "/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",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnStatus": "None",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AKS-WW-TP1-21-COOKTOP-2",
|
||||
"versions": [
|
||||
"40260330"
|
||||
],
|
||||
"visVersion": "260330"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "051140474341FFFFFFFF",
|
||||
"versions": [
|
||||
"26032400",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260324"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "CT_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "**REDACTED**"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cooktop/spec/vs/0",
|
||||
"rep": {
|
||||
"deviceType": "cooktop",
|
||||
"micomModelId": "NV9000D-/KO2",
|
||||
"supportedFeatureList": [
|
||||
""
|
||||
],
|
||||
"numberOfBurners": 3,
|
||||
"supportedBurnerList": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"supportedPowerLevelList": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"boost",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cooktop/status/vs/0",
|
||||
"rep": {
|
||||
"sequenceNumber": 485,
|
||||
"power": "off",
|
||||
"childLock": "off",
|
||||
"operationState": "ready",
|
||||
"additionalStateList": [
|
||||
""
|
||||
],
|
||||
"burnerList": [
|
||||
{
|
||||
"burnerNumber": 0,
|
||||
"mode": "normal",
|
||||
"operationState": "ready",
|
||||
"powerLevel": "0",
|
||||
"hotSurfaceState": "normal",
|
||||
"panDetection": true,
|
||||
"timer": {
|
||||
"operationState": "ready",
|
||||
"cookingTime": 0,
|
||||
"remainingTime": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"burnerNumber": 1,
|
||||
"mode": "normal",
|
||||
"operationState": "ready",
|
||||
"powerLevel": "0",
|
||||
"hotSurfaceState": "normal",
|
||||
"panDetection": true,
|
||||
"timer": {
|
||||
"operationState": "ready",
|
||||
"cookingTime": 0,
|
||||
"remainingTime": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"burnerNumber": 2,
|
||||
"mode": "normal",
|
||||
"operationState": "ready",
|
||||
"powerLevel": "0",
|
||||
"hotSurfaceState": "normal",
|
||||
"panDetection": true,
|
||||
"timer": {
|
||||
"operationState": "ready",
|
||||
"cookingTime": 0,
|
||||
"remainingTime": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-KS-COOKTOP-01011|40474341|50000203001811000A00000000000000",
|
||||
"x.com.samsung.da.description": "NV9000D-/KO2",
|
||||
"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": "26033000",
|
||||
"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": "04743A26032400",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"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": "KC2",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cooktop/settings/status/vs/0",
|
||||
"rep": {
|
||||
"sequenceNumber": 104,
|
||||
"safetyAlert": {
|
||||
"state": "on",
|
||||
"settingTime": 3600
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "218600",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "OV_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-05T14:20:14"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.connected"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.lock": "Unlock"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.doors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_DA-KS-WALLOVEN-000002|40441841|5002011E021011150100000000000000",
|
||||
"x.com.samsung.da.description": "NW9000KD/AA1",
|
||||
"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": "240205",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "DE92-04418A_20062500, DE92-04011A_17041300",
|
||||
"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": "610",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"ConvectionBake",
|
||||
"ConvectionRoast",
|
||||
"Bake",
|
||||
"Broil",
|
||||
"SteamBake",
|
||||
"SteamRoast",
|
||||
"Easycook3",
|
||||
"Descale",
|
||||
"PyroFree",
|
||||
"Drain",
|
||||
"SelfClean",
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NW9000KD/AA1",
|
||||
"keepWarmReservation_Off",
|
||||
"meatprobe_disconnected",
|
||||
"NoPreheat_Off",
|
||||
"waterInlet_Closed",
|
||||
"steamAddLevel_0",
|
||||
"descaleAlarm_Normal",
|
||||
"descaleNewWaterAlarm_Off",
|
||||
"descaleEmptyWaterAlarm_Off",
|
||||
"steamGeneratorLevel_Empty",
|
||||
"steamUsingTime_66",
|
||||
"drainRequired_00",
|
||||
"steamState_Standby",
|
||||
"descaleState_Standby",
|
||||
"waterTankInSwitch_On",
|
||||
"pyroFreeState_Standby",
|
||||
"waterTankOutSwitch_Off",
|
||||
"Sound_On",
|
||||
"AdjustingTemp_0",
|
||||
"Sabbath_Off",
|
||||
"EnergySaving_On"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"rt": [
|
||||
"x.com.samsung.da.oven"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false",
|
||||
"rt": [
|
||||
"x.com.samsung.da.configuration"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.increment": "0",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.increment": "0",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.temperatures"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,623 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"href": "/alarms/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On",
|
||||
"href": "/bespoke/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rep": {
|
||||
"light.control.status": "On",
|
||||
"level.brightness.daytime": "100",
|
||||
"level.brightness.nighttime": "33",
|
||||
"night.starttime": "2026-08-07T12:00:00",
|
||||
"night.duration.minute": "540",
|
||||
"timezone.offset": "+09:00",
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.light.enhanced"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.lightLevel": "100",
|
||||
"x.com.samsung.da.lightResolution": "3",
|
||||
"x.com.samsung.da.lightControl.off.include": "Off",
|
||||
"x.com.samsung.da.lightControl": "Off",
|
||||
"x.com.samsung.da.lightControl.hide": "true",
|
||||
"light.dimming.status": "On",
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.cabinetlight"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "",
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"href": "/configuration/vs/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": "/defrost/prediction/vs/0",
|
||||
"rep": {
|
||||
"ai.cooling.care": "Off",
|
||||
"href": "/defrost/prediction/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Normal",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/cooler/0",
|
||||
"rep": {
|
||||
"openState": "Close",
|
||||
"href": "/door/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/freezer/0",
|
||||
"rep": {
|
||||
"openState": "Close",
|
||||
"href": "/door/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
}
|
||||
],
|
||||
"href": "/doors/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1441",
|
||||
"x.com.samsung.da.start": "2026-08-07T00:00:41Z",
|
||||
"x.com.samsung.da.realSaving": "On",
|
||||
"href": "/drlc/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/ailevel/vs/0",
|
||||
"rep": {
|
||||
"aiLevel": "1",
|
||||
"supportedAiLevel": [
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"href": "/energy/ailevel/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "25344",
|
||||
"x.com.samsung.da.instantaneousPower": "48",
|
||||
"x.com.samsung.da.cumulativePower": "413385",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "50694",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1786020360"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1,
|
||||
"href": "/file/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"href": "/filter/airdustfilter/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/nighttime/vs/0",
|
||||
"rep": {
|
||||
"ice.night.status": "On",
|
||||
"ice.night.starttime": "2026-08-07T12:00:00",
|
||||
"ice.night.duration": "540",
|
||||
"ice.night.timezone": "+09:00",
|
||||
"href": "/icemaker/nighttime/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.ice.night"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
|
||||
"x.com.samsung.da.iceMaker.state": "On",
|
||||
"x.com.samsung.da.iceType.desired": "NORMAL",
|
||||
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
|
||||
"x.com.samsung.da.iceMaker.type": "toggle",
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.icemaker"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker": "On",
|
||||
"href": "/icemaker/status/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|70664141|0000033C011913114100000041FB5F00",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"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": "RR7",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"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": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "260618",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2605071D, 24120906, 26040701, FFFFFFFF, 25082208, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"href": "/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
],
|
||||
"href": "/mode/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "26040701",
|
||||
"x.com.samsung.da.currentVersionInfo": "10000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnTarget": "inverter",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Refrigerator",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-24-T4-RE1",
|
||||
"versions": [
|
||||
"20260618"
|
||||
],
|
||||
"visVersion": "260618"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "823070664141FFFFFFFF",
|
||||
"versions": [
|
||||
"2605071D",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260507"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "823070664041FFFFFFFF",
|
||||
"versions": [
|
||||
"24120906",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "241209"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02307066414170664041",
|
||||
"versions": [
|
||||
"2605071D",
|
||||
"24120906"
|
||||
],
|
||||
"visVersion": "260507"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "023070680641FFFFFFFF",
|
||||
"versions": [
|
||||
"26040701",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260407"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "023070668841FFFFFFFF",
|
||||
"versions": [
|
||||
"25082208",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250822"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/realtimenotiforclient/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFridge": "Off",
|
||||
"x.com.samsung.da.rapidFreezing": "Off",
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.fridge"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/rm/control/vs/0",
|
||||
"rep": {
|
||||
"minPeriod": "9000",
|
||||
"href": "/rm/control/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runningmode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0,
|
||||
"href": "/runningmode/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
],
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"ErrorCode_None"
|
||||
],
|
||||
"href": "/selfcheck/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rep": {
|
||||
"alert.door": "1",
|
||||
"supportedAlert.door": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.alert.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.device.sound": "On",
|
||||
"x.com.samsung.da.preciseCooling": "On",
|
||||
"x.com.samsung.da.doorAlarmSound": "On",
|
||||
"cleaning.status": "On",
|
||||
"cleaning.type": "SPI_AND_UV",
|
||||
"href": "/status/lock/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.lockstatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rep": {
|
||||
"temperature": 2.0,
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"temperature": -19.0,
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rep": {
|
||||
"temperature": 2.0,
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"temperature": -19.0,
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"temperature.unit.control": "true",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "-19",
|
||||
"x.com.samsung.da.current": "-19",
|
||||
"x.com.samsung.da.maximum": "-15",
|
||||
"x.com.samsung.da.minimum": "-23",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Fridge",
|
||||
"x.com.samsung.da.desired": "2",
|
||||
"x.com.samsung.da.current": "2",
|
||||
"x.com.samsung.da.maximum": "7",
|
||||
"x.com.samsung.da.minimum": "1",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
],
|
||||
"href": "/temperatures/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "eomkim_IoT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "-19",
|
||||
"x.com.samsung.da.current": "-19",
|
||||
"x.com.samsung.da.maximum": "-17",
|
||||
"x.com.samsung.da.minimum": "-23",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-17.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -19.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-17.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -19.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
],
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "41042",
|
||||
"x.com.samsung.da.cumulativePower": "466708",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "4",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "15759"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00168541|00080023001713104100000041010000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"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": "RO6",
|
||||
"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": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "250422",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24101709, 23031302, 24062000, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/onedoorfreezer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/0",
|
||||
"rep": {
|
||||
"defrost": false,
|
||||
"rapidFreeze": false,
|
||||
"rapidCool": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFreezing": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 2,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1441",
|
||||
"x.com.samsung.da.start": "2026-08-06T00:34:47Z",
|
||||
"x.com.samsung.da.realSaving": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-23-COMMON",
|
||||
"versions": [
|
||||
"20250422"
|
||||
],
|
||||
"visVersion": "250422"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02800016854100168441",
|
||||
"versions": [
|
||||
"24101709",
|
||||
"23031302"
|
||||
],
|
||||
"visVersion": "241017"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "028070655341FFFFFFFF",
|
||||
"versions": [
|
||||
"24062000",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240620"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.voicecontrol": "Off",
|
||||
"x.com.samsung.da.device.sound": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/ailevel/vs/0",
|
||||
"rep": {
|
||||
"aiLevel": "1",
|
||||
"supportedAiLevel": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/timer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.time.desired": "1",
|
||||
"x.com.samsung.da.time.supportedOptions": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/single/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.openOptions": [
|
||||
"Single"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"ble_ocf",
|
||||
null
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
],
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "53539",
|
||||
"x.com.samsung.da.cumulativePower": "174590",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "4",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"KIMCHI_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[0]",
|
||||
"KIMCHIM_BOX_COUNT_[0]",
|
||||
"KIMCHIB_BOX_COUNT_[0]",
|
||||
"KIMCHI_BOX_COUNT_[8]"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"KIMCHI_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_BUY_[0]:[0]",
|
||||
"KIMCHI_STORAGE_FRIDGE_[0]:[0]",
|
||||
"KIMCHI_STORAGE_FREEZER_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
|
||||
"KIMCHI_KIMCHI_RIPE_LOW_TEMP_[5]:[17]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"modes": [
|
||||
"KIMCHI_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[0]",
|
||||
"KIMCHIM_BOX_COUNT_[0]",
|
||||
"KIMCHIB_BOX_COUNT_[0]",
|
||||
"KIMCHI_BOX_COUNT_[8]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00168041|10010022011713004101800031010000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"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": "CO0",
|
||||
"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": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "250422",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24101006, 23031302, 24102200, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "8",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/onedoorkimchi/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 2,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1441",
|
||||
"x.com.samsung.da.start": "2026-08-06T00:34:47Z",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-23-COMMON",
|
||||
"versions": [
|
||||
"20250422"
|
||||
],
|
||||
"visVersion": "250422"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02120016804100168441",
|
||||
"versions": [
|
||||
"24101006",
|
||||
"23031302"
|
||||
],
|
||||
"visVersion": "241010"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "021270659241FFFFFFFF",
|
||||
"versions": [
|
||||
"24102200",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "241022"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.voicecontrol": "Off",
|
||||
"x.com.samsung.da.device.sound": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/onedoor/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "8",
|
||||
"x.com.samsung.da.ripeTotaltime": "0",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_CRUNFCH",
|
||||
"KIMCHI_STORAGE_BUY",
|
||||
"STORAGE_FRIDGE",
|
||||
"STORAGE_FREEZER",
|
||||
"KIMCHI_RIPE_NORMAL_TEMP",
|
||||
"KIMCHI_RIPE_LOW_TEMP",
|
||||
"NEWMODE_KIMCHI_0000",
|
||||
"NEWMODE_KIMCHI_0000",
|
||||
"NEWMODE_KIMCHI_0000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/timer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.time.desired": "1",
|
||||
"x.com.samsung.da.time.supportedOptions": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/kimchi/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.openOptions": [
|
||||
"Single"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"ble_ocf",
|
||||
null
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"href": "/alarms/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/winecellar/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.openOptions": [
|
||||
"Single"
|
||||
],
|
||||
"href": "/autodoor/winecellar/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/timer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.time.desired": "1",
|
||||
"x.com.samsung.da.time.supportedOptions": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
],
|
||||
"href": "/autodoor/timer/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On",
|
||||
"href": "/bespoke/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.lightLevel": "100",
|
||||
"x.com.samsung.da.lightResolution": "1",
|
||||
"x.com.samsung.da.lightControl.off.include": "Off",
|
||||
"x.com.samsung.da.lightControl": "Off",
|
||||
"x.com.samsung.da.timeout.desired": "0",
|
||||
"x.com.samsung.da.timeout.supportedList": [
|
||||
"0",
|
||||
"15",
|
||||
"30",
|
||||
"60"
|
||||
],
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.cabinetlight"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "",
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"href": "/configuration/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/winecellar/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"href": "/door/winecellar/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.doorwinecellar"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "9",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
}
|
||||
],
|
||||
"href": "/doors/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1345",
|
||||
"x.com.samsung.da.start": "2026-08-06T02:08:48Z",
|
||||
"x.com.samsung.da.realSaving": "On",
|
||||
"href": "/drlc/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "19073",
|
||||
"x.com.samsung.da.instantaneousPower": "5",
|
||||
"x.com.samsung.da.cumulativePower": "210483",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "15696",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"href": "/energy/consumption/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00",
|
||||
"x.com.samsung.supprtedtype": 1,
|
||||
"href": "/file/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/deodorfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "-1",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"href": "/filter/deodorfilter/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00146141|000B0020001513924100000011000000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"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": "531",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "260619",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24102313, 2201030E",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"href": "/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.voicecontrol": "Off",
|
||||
"x.com.samsung.da.ado.soundcontrol": "On",
|
||||
"href": "/status/lock/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.lockstatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"AIRFILTER_DISABLE"
|
||||
],
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"WINE_T_TOTRACK_[6]",
|
||||
"WINE_T_BOTTLE_RACK_[7]",
|
||||
"WINE_B_TOTRACK_[4]",
|
||||
"WINE_B_BOTTLE_RACK_[7]",
|
||||
"WINE_PRESENTATION_[6]",
|
||||
"WINE_BTM_BOTTLE_[20]",
|
||||
"WINE_PANTRY_BOTTLE_[5]"
|
||||
],
|
||||
"href": "/mode/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true",
|
||||
"href": "/realtimenotiforclient/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runningmode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0,
|
||||
"href": "/runningmode/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
],
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"ErrorCode_None"
|
||||
],
|
||||
"href": "/selfcheck/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/winecellar/top/vs/0",
|
||||
"rep": {
|
||||
"temperature": 13.0,
|
||||
"range": [
|
||||
4.0,
|
||||
18.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/winecellar/top/vs/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/winecellar/bottom/vs/0",
|
||||
"rep": {
|
||||
"temperature": 7.0,
|
||||
"range": [
|
||||
4.0,
|
||||
18.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/winecellar/bottom/vs/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "WineCellar-top",
|
||||
"x.com.samsung.da.desired": "13",
|
||||
"x.com.samsung.da.current": "13",
|
||||
"x.com.samsung.da.maximum": "18",
|
||||
"x.com.samsung.da.minimum": "4",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "WineCellar-bottom",
|
||||
"x.com.samsung.da.desired": "7",
|
||||
"x.com.samsung.da.current": "7",
|
||||
"x.com.samsung.da.maximum": "18",
|
||||
"x.com.samsung.da.minimum": "4",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
],
|
||||
"href": "/temperatures/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/winecellar/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.tbl.revision": "1",
|
||||
"href": "/information/winecellar/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/winecellar/pantry/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.mode": "Fruit",
|
||||
"x.com.samsung.range": [
|
||||
"4",
|
||||
"13"
|
||||
],
|
||||
"x.com.samsung.da.room": "0x10",
|
||||
"x.com.samsung.da.name": "MULTI_PANTRY",
|
||||
"x.com.samsung.da.unit": "Celsius",
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Processed_Meat",
|
||||
"Cheese",
|
||||
"Nuts",
|
||||
"Fruit",
|
||||
"Wine"
|
||||
],
|
||||
"href": "/status/winecellar/pantry/one/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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 Refrigerator",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-24-T4-RE2",
|
||||
"versions": [
|
||||
"20260619"
|
||||
],
|
||||
"visVersion": "260619"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02900014614100146441",
|
||||
"versions": [
|
||||
"24102313",
|
||||
"2201030E"
|
||||
],
|
||||
"visVersion": "241023"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "Home_IoT"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"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": "10",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "74800",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1786287600",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1786280400",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0167",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_87",
|
||||
"LaundryOutTime_0",
|
||||
"SeamlessControl_Enable",
|
||||
"KidsLockBypass_On",
|
||||
"WashingTimes_0",
|
||||
"DrumCleanProposal_40",
|
||||
"DetergentOnce_0",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_0",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_0",
|
||||
"DetergentTotal_0",
|
||||
"SoftenerOnce_0",
|
||||
"SoftenerLeft_0",
|
||||
"SoftenerBase_0",
|
||||
"SoftenerAlarm_Off",
|
||||
"SoftenerType_0",
|
||||
"SoftenerTotal_0",
|
||||
"SpecialFunction_4",
|
||||
"AvailableDelayTime_74",
|
||||
"BubbleSoak_Off",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_421C20820437A2042C",
|
||||
"SendToDevice_On",
|
||||
"GMT_04",
|
||||
"PreWashSetting_Off",
|
||||
"IntensiveSetting_Off",
|
||||
"CloudCourse_0021550449284D134AA04C0035F004F005F0AC00",
|
||||
"CloudExtraCourse_0A5C286B2D0C55301A",
|
||||
"OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00",
|
||||
"BubbleSoakSet_00F0F0F00000F0F000000000F000",
|
||||
"EnergyLevelSet_050304040501050404030201020401",
|
||||
"MostUsed_1B841E923FA67F00000000000000",
|
||||
"PreWashAvailableSet_F0F0F0F00000F0F000F0F000F000",
|
||||
"IntensiveAvailableSet_F0F0F0F00000F0F000F0F000F000",
|
||||
"TextureLevel_None",
|
||||
"SavingModeCondition_010102151C1BA0961C8F251C0A061A207F5C55656B0C2D3034030149035E2830",
|
||||
"SavingMode_Off",
|
||||
"WelcomeLighting_1",
|
||||
"SupportedWelcomeLighting_000102",
|
||||
"GeoFenceAlarm",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_2025-08-18T14:56:34|2025-11-02T21:08:39|2026-01-19T13:00:50|2026-03-23T12:10:12|2026-06-27T11:07:22|2026-08-09T10:13:44",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F088000913FA67F7485209204A5208780009000A00006841E930FA30F7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F34841E923FA67FA0811E933FA33F"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "01:14: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",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "01:14:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000",
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B06C",
|
||||
"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": "WF1",
|
||||
"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_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02986A260118(A182)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB_20348141240110090FFFFF203495412406195503FFFF(01672034814120349541_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03481A24011009,03495A24061955",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Firmware_2_DB_2025984624053003032FFFFFFFFFFFFFFFFFFFFFFFFE(016720259846FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "02598F24053003,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+02:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.waterTemperature": "30",
|
||||
"x.com.samsung.da.supportedWaterTemperature": [
|
||||
"None",
|
||||
"Cold",
|
||||
"20",
|
||||
"30",
|
||||
"40",
|
||||
"60",
|
||||
"90"
|
||||
],
|
||||
"x.com.samsung.da.spinLevel": "800",
|
||||
"x.com.samsung.da.supportedSpinLevel": [
|
||||
"RinseHold",
|
||||
"NoSpin",
|
||||
"400",
|
||||
"800",
|
||||
"1000",
|
||||
"1200",
|
||||
"1400"
|
||||
],
|
||||
"x.com.samsung.da.rinseCycles": "3",
|
||||
"x.com.samsung.da.supportedRinseCycles": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/washercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.washerMode": "Table_02_Course_87",
|
||||
"x.com.samsung.da.st.courseTable": "Table_02"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/water/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeWater": "7512800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedSetLanguage": [
|
||||
"ko_KR",
|
||||
"en_US"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "true",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
|
||||
"x.com.samsung.da.modelCode": "M(None),W(WW8XCGC04AAEEG)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-03-04",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "DA_WM_TP1_21_COMMON",
|
||||
"versions": [
|
||||
"30260118"
|
||||
],
|
||||
"visVersion": "260118"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "01672034814120349541",
|
||||
"versions": [
|
||||
"24011009",
|
||||
"24061955"
|
||||
],
|
||||
"visVersion": "240619"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "016720259846FFFFFFFF",
|
||||
"versions": [
|
||||
"24053003",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240530"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "016720259846FFFFFFFF",
|
||||
"versions": [
|
||||
"24053003",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240530"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/buzzersound/vs/0",
|
||||
"rep": {
|
||||
"supportedBuzzerSound": [
|
||||
"Volume_Off",
|
||||
"Volume_Low",
|
||||
"Volume_Med",
|
||||
"Volume_High"
|
||||
],
|
||||
"setBuzzerSound": "Volume_Low",
|
||||
"supportedFinishSound": [
|
||||
"FinishSound_1",
|
||||
"FinishSound_2",
|
||||
"FinishSound_3"
|
||||
],
|
||||
"setFinishSound": "FinishSound_2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "DE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/Berlin",
|
||||
"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**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,8 @@ from custom_components.localthings.const import (
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEARN_MODES,
|
||||
CONF_LEARNED_MODES,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -75,6 +77,41 @@ async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
|
||||
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
|
||||
|
||||
|
||||
async def test_setup_normalizes_messy_pasted_pem(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""A PEM with a leading UTF-8 BOM, CRLF line endings, and a stray blank
|
||||
line -- the kind a Windows text editor's copy produces, as opposed to a
|
||||
`type` dump (issue #291) -- must still be accepted and stored in its
|
||||
normalized form, not rejected with an opaque InvalidHeader."""
|
||||
messy_cert = "\ufeff" + MOCK_CA_CERT_PEM.replace("\n", "\r\n") + "\r\n\r\n"
|
||||
messy_key = "\ufeff" + MOCK_CA_KEY_PEM.replace("\n", "\r\n")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_CA_CERT_PEM: messy_cert,
|
||||
CONF_CA_KEY_PEM: messy_key,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
|
||||
assert result["data"][CONF_CA_KEY_PEM] == MOCK_CA_KEY_PEM
|
||||
|
||||
|
||||
def test_normalize_pem_strips_bom_crlf_and_blank_lines() -> None:
|
||||
"""Unit-level check of the helper itself, isolated from the flow."""
|
||||
from custom_components.localthings.config_flow import _normalize_pem
|
||||
|
||||
messy = "\ufeff-----BEGIN CERTIFICATE-----\r\nTEST-CA\r\n\r\n-----END CERTIFICATE-----\r\n"
|
||||
assert _normalize_pem(messy) == (
|
||||
"-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
)
|
||||
# A clean PEM (the `type`-dump case) passes through unchanged.
|
||||
clean = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
assert _normalize_pem(clean) == clean
|
||||
|
||||
|
||||
def test_order_candidates_prefers_known_ports() -> None:
|
||||
"""Live ports are ordered with the historically known DTLS ports first,
|
||||
then the rest ascending."""
|
||||
@@ -900,7 +937,11 @@ async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
|
||||
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
assert set(cast(Iterable[str], result["menu_options"])) == {"settings", "debug_write"}
|
||||
assert set(cast(Iterable[str], result["menu_options"])) == {
|
||||
"settings",
|
||||
"forget_learned_modes",
|
||||
"debug_write",
|
||||
}
|
||||
|
||||
|
||||
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
|
||||
@@ -939,6 +980,72 @@ async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
|
||||
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
|
||||
async def test_learned_modes_option_defaults_to_on(hass: HomeAssistant) -> None:
|
||||
"""Issue #327's remembering is on by default -- a device that hides a
|
||||
mode it's in should just work, not need the option found first."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "settings"}
|
||||
)
|
||||
|
||||
data_schema = result["data_schema"]
|
||||
assert data_schema is not None
|
||||
assert data_schema({})[CONF_LEARN_MODES] is True
|
||||
|
||||
|
||||
async def test_learned_modes_option_can_be_turned_off(hass: HomeAssistant) -> None:
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "settings"}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_BYPASS_REMOTE_CONTROL: False, CONF_LEARN_MODES: False},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.options[CONF_LEARN_MODES] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored", "listed"),
|
||||
[
|
||||
({"/mode/convenient/vs/0": ["Quiet"]}, "Quiet"),
|
||||
# Malformed -- nothing writes this shape, but a hand-edited
|
||||
# .storage can hold it, and this step is the one screen that can
|
||||
# clear it, so it must not be the one screen that trips over it.
|
||||
({"/mode/convenient/vs/0": None}, "(none)"),
|
||||
],
|
||||
)
|
||||
async def test_forget_learned_modes_clears_the_entry(hass: HomeAssistant, stored, listed) -> None:
|
||||
"""The reset step works on an unloaded entry too, by dropping the
|
||||
persisted copy directly -- that's all a reload would restore from."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, CONF_LEARNED_MODES: stored},
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "forget_learned_modes"}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["description_placeholders"] == {"codes": listed}
|
||||
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], user_input={})
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.data[CONF_LEARNED_MODES] == {}
|
||||
|
||||
|
||||
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
|
||||
"""Reopening the form shows the currently-saved choice as the default,
|
||||
not always False."""
|
||||
@@ -1001,9 +1108,10 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
hass: HomeAssistant,
|
||||
mock_coordinator_session,
|
||||
) -> None:
|
||||
"""Picking an href, then submitting a payload, drives
|
||||
coordinator.async_raw_write and lands on the result menu with the
|
||||
device's response."""
|
||||
"""Picking an href, then submitting a payload, calls the write_resource
|
||||
service (issue #300) -- which drives
|
||||
coordinator.async_raw_write_sequence -- and lands on the result menu
|
||||
with the device's response."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
@@ -1021,8 +1129,20 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
assert result["step_id"] == "debug_edit"
|
||||
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
@@ -1088,8 +1208,20 @@ async def test_options_flow_finish_preserves_existing_options(
|
||||
user_input={"href": "/washer/vs/0"},
|
||||
)
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -21,6 +22,7 @@ from custom_components.localthings.const import (
|
||||
SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from custom_components.localthings.coordinator import (
|
||||
_RECOVERY_RETRY_S,
|
||||
LocalThingsCoordinator,
|
||||
_local_source_port,
|
||||
)
|
||||
@@ -30,7 +32,7 @@ from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, FakeObserveSession
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
|
||||
@@ -478,6 +480,57 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
|
||||
assert coordinator._observe.mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_total_poll_failure_downgrades_observe_mode_to_poll(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
|
||||
) -> None:
|
||||
"""A device that drops off the network entirely -- both the poll and its
|
||||
reconnect retry fail -- must not leave the connection-mode sensor
|
||||
reporting 'Push' forever (issue #287). Only the *successful* reconnect
|
||||
branch used to touch observe mode (see
|
||||
test_reconnect_while_observe_mode_downgrades_to_poll); this covers the
|
||||
branch where the device stays unreachable."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
last_notify_ts = coordinator._observe._last_notify_ts
|
||||
assert last_notify_ts is not None
|
||||
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
side_effect=[RuntimeError("connection lost"), RuntimeError("still lost")],
|
||||
),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
await coordinator.async_request_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The update still "succeeds" with the last-known snapshot (issue #254's
|
||||
# degraded-data path) -- but the connection mode must reflect reality
|
||||
# now, not the stale OBSERVE state from before the outage.
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
|
||||
async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -691,6 +744,149 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_discards_stale_commit_after_session_swap(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""A reconnect (the poll path's own, or a command's retry) can swap
|
||||
self._session while this attempt's grace wait is in flight -- it runs
|
||||
without holding _session_lock precisely so a write isn't blocked behind
|
||||
it (issue #294). Committing observe mode against the now-stale local
|
||||
`sess` reference would claim "Push" on a session that's already gone,
|
||||
with nothing left to notice -- the identity re-check under the lock
|
||||
right before committing must catch this and abandon instead.
|
||||
|
||||
The new session is never-tried, though, not just abandoned: it must
|
||||
flag an immediate resubscribe rather than let _last_observe_attempt_ts
|
||||
(stamped for the now-abandoned attempt) throttle it for up to
|
||||
_RECOVERY_RETRY_S.
|
||||
|
||||
Simulates the swap from inside await_observe_notifies itself rather
|
||||
than via real concurrency: subscribe_hrefs (and its lock) has already
|
||||
returned by the time that call runs, so this lands exactly in the
|
||||
window the identity check exists to cover, deterministically."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator._resubscribe_due is False
|
||||
|
||||
other = FakeObserveSession()
|
||||
|
||||
def _swap_session_mid_wait(subscribed, grace_period_s, success_fraction=None):
|
||||
coordinator._session = other # ty: ignore[invalid-assignment]
|
||||
return True
|
||||
|
||||
with patch.object(
|
||||
coordinator._observe, "await_observe_notifies", side_effect=_swap_session_mid_wait
|
||||
):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
assert coordinator._observe.subscribed_hrefs == set()
|
||||
assert coordinator._observe._refresh_thread is None
|
||||
assert coordinator._resubscribe_due is True
|
||||
|
||||
|
||||
async def test_maybe_retry_observe_mode_uses_most_recent_attempt_not_just_mode_change(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""_set_mode only stamps last_mode_change_ts on an actual transition,
|
||||
so a device that never successfully enters observe mode leaves that
|
||||
timestamp stuck at construction time forever -- a failed attempt keeps
|
||||
calling _set_mode(MODE_POLL) while already in MODE_POLL, a no-op.
|
||||
Gating solely on that timestamp would make the 600s throttle open once
|
||||
and then never close again, re-attempting (and paying the subscribe
|
||||
burst) on every single poll cycle instead of every _RECOVERY_RETRY_S."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.observe_mode == MODE_POLL # never notified during setup
|
||||
|
||||
# Simulate exactly the scenario above: mode_change_ts is old (as it
|
||||
# would be forever, for a device that never gets push), but an attempt
|
||||
# really did just run.
|
||||
coordinator._observe.last_mode_change_ts = time.monotonic() - _RECOVERY_RETRY_S - 1
|
||||
coordinator._last_observe_attempt_ts = time.monotonic()
|
||||
|
||||
with patch.object(fake, "subscribe") as mock_subscribe:
|
||||
await coordinator._maybe_retry_observe_mode()
|
||||
|
||||
mock_subscribe.assert_not_called()
|
||||
|
||||
|
||||
async def test_maybe_retry_observe_mode_also_respects_a_mode_change_outside_an_attempt(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The mirror of the case above: last_mode_change_ts can be the more
|
||||
recent of the two as well, e.g. right after the poll or command path's
|
||||
own downgrade (neither goes through _attempt_observe_mode, so neither
|
||||
stamps _last_observe_attempt_ts). Dropping last_mode_change_ts from the
|
||||
max() would let a device that was *just* downgraded get re-attempted
|
||||
immediately instead of respecting _RECOVERY_RETRY_S."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
coordinator._last_observe_attempt_ts = time.monotonic() - _RECOVERY_RETRY_S - 1
|
||||
coordinator._observe.last_mode_change_ts = time.monotonic()
|
||||
|
||||
with patch.object(fake, "subscribe") as mock_subscribe:
|
||||
await coordinator._maybe_retry_observe_mode()
|
||||
|
||||
mock_subscribe.assert_not_called()
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_releases_lock_before_the_grace_wait(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The subscribe burst holds _session_lock (it touches the session);
|
||||
the grace wait after it must not, or a command write could stall
|
||||
behind up to _OBSERVE_GRACE_PERIOD_S of an unrelated observe-mode-entry
|
||||
attempt (issue #294). By construction, subscribe_hrefs's own
|
||||
`async with self._session_lock:` has already exited by the time
|
||||
await_observe_notifies is even called -- checked here rather than
|
||||
inferred from timing."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
locked_during_wait = {"value": None}
|
||||
|
||||
def _check_lock(subscribed, grace_period_s, success_fraction=None):
|
||||
locked_during_wait["value"] = coordinator._session_lock.locked()
|
||||
return True
|
||||
|
||||
with patch.object(coordinator._observe, "await_observe_notifies", side_effect=_check_lock):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert locked_during_wait["value"] is False
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_holds_lock_during_the_subscribe_burst(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The other half of the split above: the subscribe burst does touch
|
||||
the session, so it must hold _session_lock -- that's what actually
|
||||
stops a concurrent close from landing mid-subscribe (issue #294)."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
locked_during_subscribe = {"value": None}
|
||||
real_subscribe_hrefs = coordinator._observe.subscribe_hrefs
|
||||
|
||||
def _check_lock(session, hrefs):
|
||||
locked_during_subscribe["value"] = coordinator._session_lock.locked()
|
||||
return real_subscribe_hrefs(session, hrefs)
|
||||
|
||||
with patch.object(coordinator._observe, "subscribe_hrefs", side_effect=_check_lock):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert locked_during_subscribe["value"] is True
|
||||
|
||||
|
||||
async def test_sweep_mismatch_never_downgrades_a_live_observe_session(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -956,6 +1152,192 @@ async def test_send_command_survives_stale_confirm_poll(
|
||||
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
|
||||
|
||||
|
||||
async def test_send_command_reconnects_and_retries_after_socket_closed(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""A command lost to a session Samsung's firmware closed between polls
|
||||
must not just vanish (issue #294): `_do_put` failing once is now
|
||||
followed by a reconnect and a single retry, mirroring the poll path's
|
||||
own recovery in `_async_update_data`.
|
||||
|
||||
`mock_coordinator_observe_session` patches `_close_session` to a no-op,
|
||||
which would leave `self._session` never actually going `None` -- and
|
||||
with it, `_do_put`'s own `if self._session is None: self._connect_session()`
|
||||
guard never exercised, so a broken reconnect could still pass. Overridden
|
||||
here to actually drop the session, so the retry only succeeds if that
|
||||
guard really rebuilds it."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("socket closed")
|
||||
return (0x44, b"")
|
||||
|
||||
def _drop_session():
|
||||
coordinator._session = None
|
||||
|
||||
reconnects = {"n": 0}
|
||||
|
||||
def _reconnect():
|
||||
reconnects["n"] += 1
|
||||
coordinator._session = fake
|
||||
|
||||
with (
|
||||
patch.object(fake, "subscribe"),
|
||||
patch.object(coordinator, "_close_session", side_effect=_drop_session),
|
||||
patch.object(coordinator, "_connect_session", side_effect=_reconnect),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert reconnects["n"] == 1
|
||||
|
||||
assert calls["n"] == 2
|
||||
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
|
||||
|
||||
|
||||
async def test_send_command_raises_after_reconnect_retry_also_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""If the command still fails on the reconnected session, the user must
|
||||
see it -- previously this was swallowed into a log line with no
|
||||
feedback at all (issue #294).
|
||||
|
||||
Also covers a sibling bug the fix for that same issue introduced: the
|
||||
session is closed the moment the first attempt fails, so any OBSERVE
|
||||
subscriptions on it are already dead regardless of whether the retry
|
||||
that follows succeeds -- a failed retry must still downgrade mode, or
|
||||
it's left claiming "Push" on a session that no longer exists."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
raise ConnectionError("socket closed")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
pytest.raises(HomeAssistantError),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_send_command_reconnect_downgrades_observe_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""A command's own successful reconnect hands back a session with zero
|
||||
OBSERVE registrations too, same as the poll path's reconnect -- must
|
||||
downgrade the same way and flag a resubscribe, or observe mode stays
|
||||
claimed against a session the write just replaced underneath it
|
||||
(issue #294)."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("socket closed")
|
||||
return (0x44, b"")
|
||||
|
||||
with (
|
||||
patch.object(fake, "subscribe") as mock_subscribe,
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
# _resubscribe_due is consumed by this same call's own trailing
|
||||
# refresh (async_request_refresh is awaited, not fire-and-forget),
|
||||
# so the visible effect is a resubscribe attempt, not a lingering
|
||||
# flag value to assert on afterward.
|
||||
assert mock_subscribe.called
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
|
||||
@@ -58,6 +58,49 @@ def test_apply_merges_partial_update_onto_prior_rep():
|
||||
assert cached["x.com.samsung.da.supportedOptions"] == ["CV_FDR_WINE", "CV_FDR_MEAT"]
|
||||
|
||||
|
||||
def test_apply_fully_replaces_alarms_href_instead_of_merging():
|
||||
"""Regression test for issue #348: /alarms/vs/0's `items` array is a
|
||||
complete snapshot of every currently-active alarm, not a partial field
|
||||
update like /mode/vs/0 (issue #27). A washer's board reports a cleared
|
||||
alarm by omitting `items` entirely -- a live read_resource GET showed
|
||||
`{}` -- so merging that onto the prior rep (as every other href does)
|
||||
left the stale ErrorCode_DC entry in the cache forever. This must
|
||||
instead behave like a full replace: the empty rep wins outright."""
|
||||
mgr = _manager()
|
||||
active = {
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.code": "ErrorCode_DC", "x.com.samsung.da.state": "Created"}
|
||||
]
|
||||
}
|
||||
mgr.apply("/alarms/vs/0", active, source="poll")
|
||||
assert mgr.cache.get("/alarms/vs/0") == active
|
||||
|
||||
cleared = mgr.apply("/alarms/vs/0", {}, source="poll")
|
||||
|
||||
assert cleared is True
|
||||
assert mgr.cache.get("/alarms/vs/0") == {}
|
||||
|
||||
|
||||
def test_apply_fully_replaces_alarms_href_for_subdevice_shapes():
|
||||
"""The same full-replace behavior must hold for both hrefs
|
||||
`Subdevice.to_actual` can produce: an indexed subdevice renumbers only
|
||||
the trailing '0' (/alarms/vs/1), and a prefixed one prepends a UUID
|
||||
(/<uuid>/alarms/vs/0) -- neither ever touches the 'alarms/vs' stem
|
||||
itself (registry/subdevices.py)."""
|
||||
for href in ("/alarms/vs/1", "/6c2dff6d-ee5c-dad1-6a5e-000000000001/alarms/vs/0"):
|
||||
mgr = _manager()
|
||||
mgr.apply(
|
||||
href,
|
||||
{"x.com.samsung.da.items": [{"x.com.samsung.da.code": "ErrorCode_UB"}]},
|
||||
source="poll",
|
||||
)
|
||||
|
||||
cleared = mgr.apply(href, {}, source="poll")
|
||||
|
||||
assert cleared is True
|
||||
assert mgr.cache.get(href) == {}
|
||||
|
||||
|
||||
def test_apply_drops_update_during_settle_window():
|
||||
mgr = _manager()
|
||||
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""The particulate sensors must declare a state_class so Home Assistant keeps
|
||||
long-term statistics for them; the graded readings must not.
|
||||
|
||||
Without a state_class a sensor only lives in the short-term recorder history
|
||||
and is dropped at the next purge, so a long-range air-quality graph is not
|
||||
possible -- that is the bug this guards against reappearing.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
from custom_components.localthings.registry.entities import SensorDesc
|
||||
|
||||
PARTICULATE = ("dust", "fine_dust", "super_fine_dust")
|
||||
GRADED = ("odor", "clean_level")
|
||||
|
||||
|
||||
def _desc(key):
|
||||
return next(d for d in air_purifier.AIR_QUALITY.entities if d.key == key)
|
||||
|
||||
|
||||
def test_particulate_sensors_record_long_term_statistics():
|
||||
for key in PARTICULATE:
|
||||
assert _desc(key).state_class == "measurement", key
|
||||
|
||||
|
||||
def test_graded_sensors_are_left_without_a_state_class():
|
||||
"""Odor and CleanLevel read 0-2 on every fixture -- graded indices, not
|
||||
concentrations. Whether averaging a grade is meaningful is a separate
|
||||
call, so they stay unstamped rather than being guessed into statistics."""
|
||||
for key in GRADED:
|
||||
assert _desc(key).state_class is None, key
|
||||
|
||||
|
||||
def test_no_unit_or_device_class_is_asserted():
|
||||
"""state_class alone makes the series recordable. pm1/pm25/pm10 with
|
||||
µg/m³ would additionally assert the reading is a mass concentration,
|
||||
which no dump states."""
|
||||
for key in PARTICULATE + GRADED:
|
||||
desc = _desc(key)
|
||||
assert desc.unit is None, key
|
||||
assert desc.device_class is None, key
|
||||
|
||||
|
||||
def test_state_class_comes_from_the_shared_tuples_fourth_column():
|
||||
"""The rows carry their own state_class rather than a parallel lookup, so
|
||||
a new sensor can't be added here without deciding the question."""
|
||||
for row in air_purifier._AIR_QUALITY_SENSORS:
|
||||
assert len(row) == 4, row
|
||||
assert row[3] in ("measurement", None), row
|
||||
|
||||
|
||||
def test_air_monitor_keeps_stamping_every_shared_sensor():
|
||||
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column
|
||||
on purpose: that board (issue #210) has stamped all five as `measurement`
|
||||
since it was added, and consuming the column would silently drop long-term
|
||||
statistics for Odor/CleanLevel there. Guards the import end to end and the
|
||||
deliberate divergence together."""
|
||||
from custom_components.localthings.registry.capabilities import air_monitor
|
||||
|
||||
assert air_monitor.SENSORS.href == "/sensors/vs/0"
|
||||
for key in PARTICULATE + GRADED:
|
||||
desc = next(
|
||||
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
|
||||
)
|
||||
assert desc.state_class == "measurement", key
|
||||
|
||||
|
||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||
"""A state_class is only honoured for a numeric state, so the value
|
||||
contract this depends on is asserted here too."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("air_purifier")
|
||||
rep = resources["/sensors/vs/0"]
|
||||
for key in PARTICULATE + GRADED:
|
||||
value = _desc(key).value_fn(rep["x.com.samsung.da.items"])
|
||||
assert isinstance(value, int), (key, value)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Tests for /airlevelcheck/vs/0 -- the "AI Purify" periodic air-quality
|
||||
sensing engine (issues #84 and #190).
|
||||
|
||||
The resource is reported by three of this registry's four board families, so
|
||||
the read assertions run against each family's own fixture; the write contracts
|
||||
were exercised on AVT-WW-TP1-23-AXX500 hardware and are asserted here through
|
||||
the descriptors that carry them.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
# The three fixtures whose dumps carry this resource. air_purifier (the
|
||||
# ARTIK051_TVTL family, issue #56) has no such href and is deliberately absent.
|
||||
FAMILIES = ("air_purifier_avt_ww", "air_purifier_vtww", "air_purifier_tp1x_da_ac_air")
|
||||
|
||||
HREF = ["airlevelcheck", "vs", "0"]
|
||||
|
||||
|
||||
def _rep(fixture):
|
||||
return _load_device(fixture)["/airlevelcheck/vs/0"]
|
||||
|
||||
|
||||
def _state(fixture):
|
||||
resources = _load_device(fixture)
|
||||
reg = resolve(resources)
|
||||
assert reg is not None and reg.name == "air_purifier", fixture
|
||||
return flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
|
||||
|
||||
def _desc(key):
|
||||
return next(d for d in air_purifier.AIR_LEVEL_CHECK.entities if d.key == key)
|
||||
|
||||
|
||||
def test_air_level_check_is_bound_not_covered():
|
||||
"""The href used to sit in COVERAGE as opaque scheduler plumbing. Guard
|
||||
against it being covered again, which would silently drop every entity
|
||||
below while still reporting zero unbound hrefs."""
|
||||
covered = {cap.href for cap in air_purifier.COVERAGE}
|
||||
assert "/airlevelcheck/vs/0" not in covered
|
||||
|
||||
|
||||
def test_every_reporting_family_binds_the_cluster():
|
||||
for fixture in FAMILIES:
|
||||
state = _state(fixture)
|
||||
for key in (
|
||||
"sensing_mode",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"sensing_skip_start",
|
||||
"sensing_skip_end",
|
||||
"air_sensing_state",
|
||||
"last_air_sensing_time",
|
||||
"last_air_sensing_level",
|
||||
):
|
||||
assert key in state, f"{fixture}: {key}"
|
||||
|
||||
|
||||
def test_tvtl_family_is_untouched():
|
||||
"""Issue #56's board has no /airlevelcheck href at all -- nothing this
|
||||
change adds may appear on it."""
|
||||
state = _state("air_purifier")
|
||||
for key in ("sensing_mode", "periodic_air_sensing", "sensing_interval", "sensing_skip_start"):
|
||||
assert key not in state, key
|
||||
|
||||
|
||||
def test_no_unbound_hrefs_on_any_reporting_family():
|
||||
for fixture in FAMILIES:
|
||||
resources = _load_device(fixture)
|
||||
reg = resolve(resources)
|
||||
assert reg is not None, fixture
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == [], f"{fixture}: {unbound}"
|
||||
|
||||
|
||||
# --- the two knobs are separate entities, not one folded control -------------
|
||||
|
||||
|
||||
def test_activation_and_action_are_separate_entities():
|
||||
"""The resource carries an on/off and an action as independent fields, and
|
||||
the appliance's own UI presents them that way. Folding them into one
|
||||
control would make a configured action invisible while the feature is off,
|
||||
and would leave no way to toggle the feature without overwriting it."""
|
||||
assert _desc("periodic_air_sensing").field == (
|
||||
"x.com.samsung.da.periodicSensingActivationState"
|
||||
)
|
||||
assert _desc("sensing_mode").field == "x.com.samsung.da.autoExeState"
|
||||
|
||||
|
||||
def test_action_options_come_from_the_device_not_a_table():
|
||||
"""supportedAutoExeState is advertised on every reporting fixture, so the
|
||||
select reads it rather than carrying a typed-in tuple -- a board adding a
|
||||
fourth action is then accepted rather than rejected."""
|
||||
desc = _desc("sensing_mode")
|
||||
assert desc.options_field == "x.com.samsung.da.supportedAutoExeState"
|
||||
assert not desc.options, "options must come from the device, not a static tuple"
|
||||
for fixture in FAMILIES:
|
||||
assert _rep(fixture)["x.com.samsung.da.supportedAutoExeState"] == [
|
||||
"Off",
|
||||
"Airpurify",
|
||||
"Alarm",
|
||||
], fixture
|
||||
|
||||
|
||||
def test_action_write_sends_the_raw_advertised_value():
|
||||
"""select.py maps the chosen option back to the device's own casing before
|
||||
calling write_fn, so the body is the advertised token verbatim."""
|
||||
for raw in ("Off", "Airpurify", "Alarm"):
|
||||
href, body = _desc("sensing_mode").write_fn(raw, {})
|
||||
assert href == HREF
|
||||
assert body == {"x.com.samsung.da.autoExeState": raw}
|
||||
|
||||
|
||||
def test_activation_write_leaves_the_action_alone():
|
||||
"""Toggling the feature must not disturb autoExeState -- that's what makes
|
||||
the switch able to do something the action select can't."""
|
||||
for payload, expected in (("On", "On"), ("Off", "Off")):
|
||||
href, body = _desc("periodic_air_sensing").write_fn(payload, {})
|
||||
assert href == HREF
|
||||
assert body == {"x.com.samsung.da.periodicSensingActivationState": expected}
|
||||
assert "x.com.samsung.da.autoExeState" not in body
|
||||
|
||||
|
||||
# --- interval ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sensing_interval_only_where_the_field_exists():
|
||||
"""TP1X_DA-AC-AIR (issue #130) omits periodicSensingInterval; the other two
|
||||
report it. The entity must follow the field, not the href."""
|
||||
assert "sensing_interval" in _state("air_purifier_avt_ww")
|
||||
assert "sensing_interval" in _state("air_purifier_vtww")
|
||||
assert "sensing_interval" not in _state("air_purifier_tp1x_da_ac_air")
|
||||
|
||||
|
||||
def test_interval_is_minutes_in_the_ui_and_seconds_on_the_wire():
|
||||
desc = _desc("sensing_interval")
|
||||
assert desc.value_fn("600") == 10
|
||||
assert desc.write_fn(10, {})[1] == {"x.com.samsung.da.periodicSensingInterval": "600"}
|
||||
|
||||
|
||||
def test_interval_keeps_zero_distinct_from_unknown():
|
||||
"""`if secs` would fold a reported 0 into None. Anything else nonzero
|
||||
rounds up, so a sub-minute reading can't render as 0 and fall below the
|
||||
entity's own floor."""
|
||||
desc = _desc("sensing_interval")
|
||||
assert desc.value_fn("0") == 0
|
||||
assert desc.value_fn("20") == 1
|
||||
assert desc.value_fn("61") == 2
|
||||
assert desc.value_fn(None) is None
|
||||
|
||||
|
||||
def test_interval_floor_is_one_minute():
|
||||
"""lastSensingTime lands on an exact minute on this board family, so a
|
||||
sub-minute interval is unobservable; and 0 has no established meaning
|
||||
here, unlike the zero floors on oven.cook_time / delay_start_hours."""
|
||||
desc = _desc("sensing_interval")
|
||||
assert desc.native_min == 1
|
||||
assert desc.write_fn(0, {}) is None
|
||||
assert desc.write_fn(0.4, {}) is None
|
||||
assert desc.write_fn(1, {})[1] == {"x.com.samsung.da.periodicSensingInterval": "60"}
|
||||
|
||||
|
||||
# --- skip window -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_skip_time_splits_the_hhmmhhmm_window():
|
||||
read_start = _desc("sensing_skip_start").value_fn
|
||||
read_end = _desc("sensing_skip_end").value_fn
|
||||
# Issue #190's unit ships a real window: 03:00-23:00.
|
||||
assert read_start("03002300") == datetime.time(3, 0)
|
||||
assert read_end("03002300") == datetime.time(23, 0)
|
||||
# Issue #84's unit sits at the inert default.
|
||||
assert read_start("00000000") == datetime.time(0, 0)
|
||||
# Junk and short strings read as unknown rather than raising.
|
||||
assert read_start("") is None
|
||||
assert read_start("99999999") is None
|
||||
assert read_end("0300") is None
|
||||
|
||||
|
||||
def test_skip_time_write_preserves_the_other_half():
|
||||
rep = {"x.com.samsung.da.periodicSensingSkipTime": "03002300"}
|
||||
_, body = _desc("sensing_skip_start").write_fn(datetime.time(7, 30), rep)
|
||||
assert body == {"x.com.samsung.da.periodicSensingSkipTime": "07302300"}
|
||||
_, body = _desc("sensing_skip_end").write_fn(datetime.time(22, 5), rep)
|
||||
assert body == {"x.com.samsung.da.periodicSensingSkipTime": "03002205"}
|
||||
# A board that has never had a window set still round-trips.
|
||||
_, body = _desc("sensing_skip_end").write_fn(datetime.time(1, 2), {})
|
||||
assert body == {"x.com.samsung.da.periodicSensingSkipTime": "00000102"}
|
||||
|
||||
|
||||
def test_skip_time_write_normalizes_a_half_it_cannot_parse():
|
||||
"""Padding alone would splice a malformed half straight back onto the wire.
|
||||
The read side already refuses one, so the write side zeroes it instead of
|
||||
echoing junk to the device."""
|
||||
junk = {"x.com.samsung.da.periodicSensingSkipTime": "0730zzzz"}
|
||||
_, body = _desc("sensing_skip_start").write_fn(datetime.time(8, 0), junk)
|
||||
assert body == {"x.com.samsung.da.periodicSensingSkipTime": "08000000"}
|
||||
junk = {"x.com.samsung.da.periodicSensingSkipTime": "zzzz2200"}
|
||||
_, body = _desc("sensing_skip_end").write_fn(datetime.time(23, 0), junk)
|
||||
assert body == {"x.com.samsung.da.periodicSensingSkipTime": "00002300"}
|
||||
|
||||
|
||||
def test_skip_status_switch_body():
|
||||
assert _desc("periodic_sensing_skip_status").write_fn("On", {})[1] == {
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "On"
|
||||
}
|
||||
|
||||
|
||||
# --- read-only diagnostics ---------------------------------------------------
|
||||
|
||||
|
||||
def test_last_sensing_time_reads_as_utc():
|
||||
state = _state("air_purifier_avt_ww")
|
||||
assert state["last_air_sensing_time"].tzinfo is not None
|
||||
assert state["last_air_sensing_time"].year >= 2020
|
||||
|
||||
|
||||
def test_read_only_keys_match_the_range_hood_capability():
|
||||
"""The three read-only sensor keys are shared with the hood deliberately,
|
||||
so both families read from one translation catalog entry each. If either
|
||||
side renames one, this catches the drift.
|
||||
|
||||
periodic_air_sensing is excluded: it's a SwitchDesc here and a
|
||||
BinarySensorDesc on the hood, so the two sit in different platform
|
||||
catalogs and are worded differently -- see the next test.
|
||||
"""
|
||||
from custom_components.localthings.registry.capabilities import range_hood
|
||||
|
||||
hood = {d.key for d in range_hood.AIR_LEVEL_CHECK.entities}
|
||||
ours = {d.key for d in air_purifier.AIR_LEVEL_CHECK.entities}
|
||||
assert {
|
||||
"air_sensing_state",
|
||||
"last_air_sensing_time",
|
||||
"last_air_sensing_level",
|
||||
} <= hood & ours
|
||||
|
||||
|
||||
def test_periodic_air_sensing_is_writable_here_and_read_only_on_hoods():
|
||||
"""The reason range_hood.AIR_LEVEL_CHECK is not imported directly: the hood
|
||||
models this key as a read-only BinarySensorDesc, this board needs a
|
||||
writable SwitchDesc. Reusing the hood's capability would migrate every hood
|
||||
user's entity to a different platform."""
|
||||
from custom_components.localthings.registry.capabilities import range_hood
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc
|
||||
|
||||
hood = next(d for d in range_hood.AIR_LEVEL_CHECK.entities if d.key == "periodic_air_sensing")
|
||||
assert isinstance(hood, BinarySensorDesc)
|
||||
ours = _desc("periodic_air_sensing")
|
||||
assert isinstance(ours, SwitchDesc)
|
||||
assert ours.write_fn is not None
|
||||
|
||||
|
||||
def test_read_only_diagnostics_match_the_hood_on_visibility():
|
||||
"""The hood leaves all three enabled; asserting key parity with it while
|
||||
hiding two of them would be a quiet divergence."""
|
||||
for key in ("air_sensing_state", "last_air_sensing_time", "last_air_sensing_level"):
|
||||
assert _desc(key).enabled_default is True, key
|
||||
@@ -59,5 +59,5 @@ def test_air_quality_and_filter_sensors_present():
|
||||
assert state["dust"] == 10
|
||||
assert state["fine_dust"] == 9
|
||||
assert state["odor"] == 1
|
||||
assert state["hepa_filter_usage"] == 0
|
||||
assert state["hepa_filter_usage"] == 30
|
||||
assert state["hepa_filter_status"] == "normal"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the AILP_DA-AC-FAC-02011_0000 air conditioner (issue #319).
|
||||
|
||||
This board resolves to the airconditioner registry two independent ways --
|
||||
modelNum's 'FAC' board token (for_device_by_model) and /oic/d's
|
||||
oic.d.airconditioner type (for_device_by_oic_type) agree -- and reports
|
||||
several resources the sibling TP1X_DA-AC-CAC-01001 board (issue #191) left
|
||||
as a documented gap: /display/vs/0 and the /settings/sound/* trio (now
|
||||
shared with air_purifier.py's identical shapes), plus two genuinely new
|
||||
hrefs (/csi/absenceclean/vs/0, /csi/energysaving/vs/0).
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.entity import _is_included
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
for_device_by_model,
|
||||
for_device_by_oic_type,
|
||||
resolve,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
_DEVICE_TYPES = ("oic.wk.d", "oic.d.airconditioner")
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
"""Minimal stand-in for entity._is_included's coordinator dependency --
|
||||
same shape as test_entity.py's own fake, kept local rather than shared
|
||||
across test modules."""
|
||||
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
return self.last_resources
|
||||
|
||||
|
||||
def _resources():
|
||||
return _load_device("airconditioner_ailp_fac")
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _resources()
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_oic_device_type_resolves_to_airconditioner_registry():
|
||||
reg = for_device_by_oic_type(_DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "airconditioner"
|
||||
|
||||
|
||||
def test_board_token_resolves_to_airconditioner_registry():
|
||||
"""'FAC' is a real _BOARD_TOKEN_TO_KEY entry -- for_device_by_model
|
||||
alone (no device_types) already resolves this board correctly, same as
|
||||
for_device_by_oic_type above; resolve()'s device_types-agreement isn't
|
||||
covering an otherwise-unreachable path."""
|
||||
resources = _resources()
|
||||
info = resources["/information/vs/0"]
|
||||
reg = for_device_by_model(
|
||||
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
|
||||
)
|
||||
assert reg is not None and reg.name == "airconditioner"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
resources = _resources()
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_sound_mode_registers_despite_no_live_mode_value():
|
||||
"""This dump has no current `mode` value yet, only supportedModes
|
||||
(mute/tone/voice). entity.py's default field-presence gate would
|
||||
otherwise keep the select from ever being created in HA even though
|
||||
adapter.flatten() (checked below) has no such gate and would look
|
||||
bound regardless -- exercise the real _is_included gate, not just
|
||||
flatten(), so a regression here can't hide behind that gap again."""
|
||||
bound, resources = _bound()
|
||||
entity = next(b for b in bound if b.desc.key == "sound_mode")
|
||||
assert entity.desc.options_field == "supportedModes"
|
||||
coord = cast(LocalThingsCoordinator, _FakeCoordinator(resources))
|
||||
assert _is_included(entity, coord) is True
|
||||
state = flatten(bound, resources)
|
||||
assert "sound_mode" in state
|
||||
assert state["sound_mode"] is None
|
||||
|
||||
|
||||
def test_absence_clean_and_energy_saving_bound():
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["absence_clean"] is True
|
||||
assert state["energy_saving_mode"] == "Off_100"
|
||||
assert state["energy_saving_state"] == "Off"
|
||||
|
||||
|
||||
def test_sound_volume_self_gates_off_without_max_level():
|
||||
"""This board's /settings/sound/volume/vs/0 reports minLevel/resolution
|
||||
but no maxLevel -- air_purifier.SOUND_VOLUME's exists_fn should keep it
|
||||
unbound-but-covered rather than shipping a min=0/max=0 slider."""
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert "sound_volume" not in state
|
||||
@@ -64,6 +64,9 @@ class _FakeCoordinator:
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
def learned_modes(self, href):
|
||||
return []
|
||||
|
||||
|
||||
def _discover(resources, registry=airconditioner.REGISTRY):
|
||||
unbound = []
|
||||
@@ -217,6 +220,70 @@ def _desc(resources, key):
|
||||
return None
|
||||
|
||||
|
||||
def test_good_sleep_is_hours_while_the_token_counts_half_hours():
|
||||
"""The appliance's own app pairs its duration picker with the values it sends
|
||||
one to one -- 0:30 -> 1, 1:00 -> 2, 3:00 -> 6, 12:00 -> 24 -- so the token is
|
||||
half hours and the entity, which is in hours, has to halve and double. The
|
||||
fixture's own Sleep_0 is the one value that reads the same either way, hence
|
||||
the injected token here."""
|
||||
resources = _load_device(FIXTURE)
|
||||
mode = resources["/mode/vs/0"]
|
||||
mode["x.com.samsung.da.options"] = [
|
||||
option for option in mode["x.com.samsung.da.options"] if option != "Sleep_0"
|
||||
] + ["Sleep_5"]
|
||||
bound, _ = _discover(resources)
|
||||
assert flatten(bound, resources)["good_sleep"] == 2.5
|
||||
|
||||
desc = _desc(resources, "good_sleep")
|
||||
assert (desc.native_max, desc.step) == (12, 0.5)
|
||||
assert desc.write_fn(2.5, {}) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_5"]},
|
||||
)
|
||||
# 12 hours is the app's maximum and has to be reachable -- it was not while
|
||||
# the token was published as hours.
|
||||
assert desc.write_fn(12, {})[1]["x.com.samsung.da.options"] == ["Comode_Sleep", "Sleep_24"]
|
||||
|
||||
|
||||
def _options(rep_options):
|
||||
return {"x.com.samsung.da.options": list(rep_options)}
|
||||
|
||||
|
||||
def test_good_sleep_write_carries_the_mode_token_the_duration_belongs_to():
|
||||
"""`Sleep_<n>` on its own does nothing. Measured on an ARTIK051_KRAC_18K:
|
||||
writing `["Sleep_4"]` was answered 2.04 Changed and the token still read
|
||||
`Sleep_0` at +8s and +45s, while `["Comode_Sleep", "Sleep_4"]` held. The
|
||||
duration is a parameter of the mode, so both go in one write -- which is
|
||||
also the only form the appliance's own app sends."""
|
||||
desc = _desc(_load_device(FIXTURE), "good_sleep")
|
||||
|
||||
assert desc.write_fn(2, _options(["Comode_Off", "Sleep_0"])) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_4"]},
|
||||
)
|
||||
# Off means leaving the mode as well as zeroing the duration.
|
||||
assert desc.write_fn(0, _options(["Comode_Sleep", "Sleep_4"]))[1] == _options(
|
||||
["Comode_Off", "Sleep_0"]
|
||||
)
|
||||
|
||||
|
||||
def test_good_sleep_and_nano_wind_share_one_token():
|
||||
"""Nano wind and Good Sleep are one Comode_ slot, so running both is
|
||||
Comode_NanoSleep -- the board produces that code by itself when nano is
|
||||
asked for while the timer runs. Turning the timer off then has to leave nano
|
||||
running rather than switching the mode off entirely, which is how the app
|
||||
reads it back."""
|
||||
desc = _desc(_load_device(FIXTURE), "good_sleep")
|
||||
|
||||
for comode in ("Comode_Nano", "Comode_NanoSleep"):
|
||||
assert desc.write_fn(2, _options([comode, "Sleep_0"]))[1] == _options(
|
||||
["Comode_NanoSleep", "Sleep_4"]
|
||||
)
|
||||
assert desc.write_fn(0, _options(["Comode_NanoSleep", "Sleep_4"]))[1] == _options(
|
||||
["Comode_Nano", "Sleep_0"]
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -235,6 +302,54 @@ def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
|
||||
)
|
||||
|
||||
|
||||
def test_auto_clean_progress_and_stop_come_off_their_own_tokens():
|
||||
"""Three tokens describe the drying cycle and the switch only covered the
|
||||
first. AutocleanProgress_ is a percentage -- the app renders it into a
|
||||
`<progress max="100">` beside a "{{value}}%" label -- and StopAutoClean_ is
|
||||
the channel for ending a cycle early, whose presence is what says the
|
||||
appliance accepts that at all (the fixture reports Idle, this unit's
|
||||
resting value)."""
|
||||
assert _state()["auto_clean_progress_legacy"] == 1.0 # the fixture's own value
|
||||
|
||||
desc = _desc(_load_device(FIXTURE), "auto_clean_stop")
|
||||
assert desc is not None
|
||||
assert desc.write_fn(desc.payload, {}) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["StopAutoClean_Set"]},
|
||||
)
|
||||
|
||||
|
||||
def test_auto_clean_stop_stays_off_boards_without_the_token():
|
||||
"""Newer boards run the same cycle off /option/autoclean/vs/0 and say
|
||||
nothing about stopping it, so writing a legacy token there would be a
|
||||
guess."""
|
||||
newer = _load_device("airconditioner_tp1x_rac")
|
||||
assert _desc(newer, "auto_clean_stop") is None
|
||||
assert "auto_clean_progress_legacy" not in _state("airconditioner_tp1x_rac")
|
||||
|
||||
|
||||
def test_filter_time_reset_writes_the_appliance_s_own_trigger_token():
|
||||
"""FilterCleanAlarm_Clear, measured on hardware: 2.04 Changed and
|
||||
FilterTime_95 -> FilterTime_0, still zero on a fresh session and every poll
|
||||
after. The token is a trigger the board acts on rather than a value it
|
||||
stores -- it never appears in options[] -- so the button is gated on the
|
||||
counter's own token being present, which is what says this board has a
|
||||
filter timer at all."""
|
||||
desc = _desc(_load_device(FIXTURE), "filter_time_reset")
|
||||
assert desc is not None
|
||||
assert desc.write_fn(desc.payload, {}) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]},
|
||||
)
|
||||
|
||||
|
||||
def test_filter_time_reset_stays_off_boards_without_the_counter():
|
||||
"""No FilterTime_ token, no counter to reset. Newer boards report filter
|
||||
usage through their own resource and would need a different mechanism, so
|
||||
offering a button that writes a legacy token there would be a guess."""
|
||||
assert _desc(_load_device("airconditioner_tp1x_rac"), "filter_time_reset") is None
|
||||
|
||||
|
||||
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.
|
||||
@@ -313,17 +428,22 @@ def test_preset_comes_from_the_comode_token():
|
||||
resources = _load_device(FIXTURE)
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == "none" # Comode_Off in the fixture
|
||||
# Codes learned by driving this unit through its cloud integration and
|
||||
# reading the token back. They go through the same dynamic resolver as a
|
||||
# real convenient resource's supportedModes, so 'Nano' resolves to the
|
||||
# existing 'nano' preset -- already labelled WindFree in the catalog.
|
||||
# The codes go through the same dynamic resolver as a real convenient
|
||||
# resource's supportedModes, so 'Nano' resolves to the existing 'nano' preset
|
||||
# -- already labelled WindFree in the catalog. Which of them are offered
|
||||
# depends on the HVAC mode and on the unit's own capability bits; the fixture
|
||||
# is in Cool, and test_presets_follow_the_hvac_mode_and_the_capability_bits
|
||||
# covers every mode.
|
||||
assert entity.preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"speed",
|
||||
"2step",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
"smart",
|
||||
"sleep",
|
||||
"nanosleep",
|
||||
]
|
||||
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
@@ -343,6 +463,252 @@ async def test_preset_write_uses_the_token_path():
|
||||
assert coordinator.commands[-1][1] == ("preset_legacy", "Nano")
|
||||
|
||||
|
||||
def test_the_sleep_modes_are_presets_too():
|
||||
"""Good Sleep lives in the same Comode_ slot as the presets, so a unit
|
||||
running it reports a code that was not in the list -- and a preset_mode
|
||||
outside preset_modes is not a state HA allows. Verified against a live unit:
|
||||
with the board on Comode_Sleep, the entity reported preset_mode 'sleep'
|
||||
while preset_modes offered only none/nano/quiet/comfort/2step/speed."""
|
||||
resources = _load_device(FIXTURE)
|
||||
assert _climate(resources).preset_modes[-2:] == ["sleep", "nanosleep"]
|
||||
|
||||
for token, preset in (("Comode_Sleep", "sleep"), ("Comode_NanoSleep", "nanosleep")):
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
token if option.startswith("Comode_") else option for option in options
|
||||
]
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == preset
|
||||
assert preset in entity.preset_modes
|
||||
|
||||
|
||||
def test_boards_without_the_sleep_token_do_not_get_the_sleep_presets():
|
||||
"""The codes come with the Sleep_ token; a unit that has no such token has
|
||||
nothing to report them from."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
option for option in options if not option.startswith("Sleep_")
|
||||
]
|
||||
presets = _climate(resources).preset_modes
|
||||
assert "sleep" not in presets and "nanosleep" not in presets
|
||||
|
||||
|
||||
def _in_mode(mode, fixture=FIXTURE):
|
||||
resources = _load_device(fixture)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
return _climate(resources).preset_modes
|
||||
|
||||
|
||||
def test_presets_follow_the_hvac_mode_and_the_capability_bits():
|
||||
"""The fixture's own OptionCode_35882 / ExtendOptionCode_7 say: WindFree yes
|
||||
(eoc[31]), 18K yes (eoc[30]), Quiet yes (oc[10]), Turbo/Comfort in Heat yes
|
||||
(oc[12]), d'light no (oc[2]), Single User no (oc[3], oc[11]).
|
||||
|
||||
Its owner read the same lists off the remote and the appliance's app --
|
||||
2-Step/Fast Turbo/Comfort/Quiet/WindFree in Cool, Fast Turbo/Comfort/Quiet in
|
||||
Heat with WindFree impossible, WindFree alone in Dry and Fan, nothing in Auto
|
||||
beyond WindFree (which the app reaches by switching to Cool) -- and the
|
||||
appliance itself refused Comode_Nano in Auto and accepted it in Cool.
|
||||
"""
|
||||
assert _in_mode("Cool") == [
|
||||
"none",
|
||||
"nano",
|
||||
"speed",
|
||||
"2step",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"smart",
|
||||
"sleep",
|
||||
"nanosleep",
|
||||
]
|
||||
# Heat: no WindFree at all, and no Smart Saver (Cool-only).
|
||||
heat = _in_mode("Heat")
|
||||
assert "nano" not in heat
|
||||
assert "smart" not in heat
|
||||
assert [c for c in ("speed", "comfort", "quiet") if c in heat] == ["speed", "comfort", "quiet"]
|
||||
# Dry and Fan: WindFree is the only one, and it keeps the mode.
|
||||
for mode in ("Dry", "Wind"):
|
||||
assert _in_mode(mode) == ["none", "nano"]
|
||||
# Auto: WindFree only because this is an 18K model.
|
||||
assert _in_mode("Auto") == ["none", "nano"]
|
||||
# d'light Cool is a live rule and this unit's oc[2] denies it everywhere.
|
||||
for mode in ("Cool", "Heat", "Dry", "Wind", "Auto"):
|
||||
assert "dlightcool" not in _in_mode(mode), mode
|
||||
|
||||
|
||||
def test_a_board_with_only_the_old_map_keeps_the_unconditional_list():
|
||||
"""One map is not enough to judge by. `airconditioner_artik051_dongle_fac_18k`
|
||||
is a legacy board that publishes OptionCode and no ExtendOptionCode, so every
|
||||
eoc-gated rule would read None -- and None means "this board does not publish
|
||||
the map", not "the feature is absent". Deriving from it would have cost that
|
||||
unit WindFree in every mode, and left it with ['none'] alone in its own
|
||||
fixture mode.
|
||||
|
||||
Its OptionCode is also 521, three orders of magnitude below the RAC-class
|
||||
values these bit positions were read from, which is the second reason not to
|
||||
interpret it: the FAC and CAC families use the field differently.
|
||||
"""
|
||||
resources = _load_device("airconditioner_artik051_dongle_fac_18k")
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
assert any(o.startswith("OptionCode_") for o in options)
|
||||
assert not any(o.startswith("ExtendOptionCode_") for o in options)
|
||||
|
||||
baseline = ["none", "nano", "quiet", "comfort", "2step", "speed"]
|
||||
for mode in ("Auto", "Cool", "Heat", "Dry", "Wind"):
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
assert _climate(resources).preset_modes == baseline, mode
|
||||
|
||||
|
||||
def test_the_other_board_with_both_maps_still_derives():
|
||||
"""`airconditioner_artik051_krac_energy` is the same model as the fixture
|
||||
above with a different OptionCode (56378), and carries both maps -- so it
|
||||
stays on the derived path rather than the fallback."""
|
||||
presets = _in_mode("Cool", fixture="airconditioner_artik051_krac_energy")
|
||||
assert presets[:1] == ["none"]
|
||||
assert "nano" in presets and "smart" in presets
|
||||
assert presets != ["none", "nano", "quiet", "comfort", "2step", "speed"]
|
||||
|
||||
|
||||
def test_an_unknown_hvac_mode_falls_back_instead_of_deriving():
|
||||
"""An HVAC mode these rules have never seen is the same "cannot judge" case
|
||||
as an absent map, so it gets the same answer rather than a derived-but-wrong
|
||||
one. Reachable with a partial or malformed rep, where `modes` is missing."""
|
||||
resources = _load_device(FIXTURE)
|
||||
for modes in ([], ["CoolClean"]):
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = modes
|
||||
assert _climate(resources).preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
], modes
|
||||
|
||||
|
||||
async def test_aicomfort_neither_offers_nano_nor_switches_the_mode():
|
||||
"""The app disables WindFree in AIComfort, so it is not offered -- and the
|
||||
Cool-first write is therefore Auto-only, with no unreachable branch for a
|
||||
mode that can never ask for it."""
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["AIComfort"]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _climate(resources, coordinator)
|
||||
|
||||
assert "nano" not in entity.preset_modes
|
||||
await entity.async_set_preset_mode("quiet")
|
||||
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Quiet")]
|
||||
|
||||
|
||||
def test_a_bit_that_is_zero_removes_its_preset():
|
||||
"""oc[12] is what puts Fast Turbo and Comfort in Heat; without it the app
|
||||
hides both, and so does this."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
# 35882 with oc[12] cleared, everything else untouched.
|
||||
assert format(35882, "016b")[12] == "1"
|
||||
cleared = int(format(35882, "016b")[:12] + "0" + format(35882, "016b")[13:], 2)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
f"OptionCode_{cleared}" if option.startswith("OptionCode_") else option
|
||||
for option in options
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Heat"]
|
||||
presets = _climate(resources).preset_modes
|
||||
assert "speed" not in presets and "comfort" not in presets
|
||||
assert "quiet" in presets # oc[10], untouched
|
||||
|
||||
|
||||
def test_a_board_without_the_bit_maps_keeps_the_unconditional_list():
|
||||
"""An absent map is not a claim that nothing is supported -- issue #136's unit
|
||||
of this same model publishes a different token set, and a board that omits
|
||||
OptionCode entirely must not lose every preset."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
option for option in options if not option.startswith(("OptionCode_", "ExtendOptionCode_"))
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
assert _climate(resources).preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
]
|
||||
|
||||
|
||||
def test_the_active_code_is_always_listed_even_where_the_rules_deny_it():
|
||||
"""A remote can put the unit in a mode these rules would not offer -- and a
|
||||
preset_mode outside preset_modes is not a state HA allows, so the appliance
|
||||
has the last word. Measured: Comode_2Step was accepted and held while the
|
||||
unit was in Auto, where neither the remote nor the app offers it."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
"Comode_2Step" if option.startswith("Comode_") else option for option in options
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == "2step"
|
||||
assert "2step" in entity.preset_modes
|
||||
|
||||
|
||||
async def test_nano_in_auto_switches_the_board_to_cool_first():
|
||||
"""Comode_Nano written while the unit is in Auto is answered 2.04 Changed and
|
||||
dropped; the same token after a separate mode write holds. Putting modes and
|
||||
options in one POST does not work either, so the mode goes first, on its own.
|
||||
"""
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _climate(resources, coordinator)
|
||||
|
||||
await entity.async_set_preset_mode("nano")
|
||||
|
||||
assert [payload for _, payload in coordinator.commands] == [
|
||||
("mode", "Cool"),
|
||||
("preset_legacy", "Nano"),
|
||||
]
|
||||
|
||||
|
||||
async def test_nano_outside_auto_writes_only_the_preset():
|
||||
"""In Cool the token holds on its own, so nothing else is sent -- and in Dry
|
||||
the mode must be left alone, since WindFree keeps it."""
|
||||
for mode in ("Cool", "Dry"):
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
await _climate(resources, coordinator).async_set_preset_mode("nano")
|
||||
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Nano")], mode
|
||||
|
||||
|
||||
def test_nano_preset_keeps_a_running_good_sleep_at_its_own_duration():
|
||||
"""Writing a bare Comode_Nano over a live Comode_Sleep/Sleep_4 came back as
|
||||
Comode_NanoSleep/Sleep_16 -- the board upgrades the code by itself and then
|
||||
supplies a duration of its own, turning two hours into eight without anyone
|
||||
asking. Sending the pair keeps the user's value."""
|
||||
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
|
||||
|
||||
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Sleep", "Sleep_4"]))[
|
||||
1
|
||||
] == _options(["Comode_NanoSleep", "Sleep_4"])
|
||||
# Idle timer: nano is just nano, exactly as before.
|
||||
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Off", "Sleep_0"]))[
|
||||
1
|
||||
] == _options(["Comode_Nano"])
|
||||
# A sleep preset selected outright has no duration to reuse, so it takes the
|
||||
# one the appliance itself falls back to (Sleep_16, eight hours).
|
||||
assert _climate_write(("preset_legacy", "Sleep"), _options(["Comode_Off", "Sleep_0"]))[
|
||||
1
|
||||
] == _options(["Comode_Sleep", "Sleep_16"])
|
||||
# Any other preset is untouched by all of this.
|
||||
assert _climate_write(("preset_legacy", "Quiet"), _options(["Comode_Sleep", "Sleep_4"]))[
|
||||
1
|
||||
] == _options(["Comode_Quiet"])
|
||||
|
||||
|
||||
async def test_newer_boards_keep_the_resource_paths():
|
||||
"""The legacy fallbacks are gated on this board's resource shape, so a
|
||||
board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
|
||||
|
||||
@@ -6,18 +6,40 @@ token -- this board was the one exception (its oneUiVersion self-reports
|
||||
"7.0 Air conditioner", but 'CAC' had never been added to the board-token
|
||||
table), so it silently fell back to common caps and lost its climate entity.
|
||||
|
||||
This dump is NOT fully covered yet -- eight hrefs remain unbound (edge
|
||||
lighting, a second stateful light resource, absence-clean, four
|
||||
`/settings/sound/*` resources, smart-sensing-cooling), all genuinely new to
|
||||
this board generation. That's a real device-support gap, left documented
|
||||
here rather than guessed at, per the 'don't guess' rule -- fixing the
|
||||
routing regression was the scope of #191.
|
||||
This dump is NOT fully covered yet -- two hrefs remain unbound
|
||||
(`/settings/sound/optimization/vs/0`, smart-sensing-cooling), both
|
||||
genuinely new to this board generation. That's a real device-support gap,
|
||||
left documented here rather than guessed at, per the 'don't guess' rule --
|
||||
fixing the routing regression was the scope of #191.
|
||||
|
||||
/settings/sound/mode/vs/0, /settings/sound/output/vs/0 and
|
||||
/settings/sound/volume/vs/0 used to be on this list too, until issue #319
|
||||
(a sibling TP1X_DA-AC-FAC-class board) supplied a live dump for them --
|
||||
airconditioner.SOUND_MODE and the reused air_purifier.SOUND_OUTPUT/
|
||||
SOUND_VOLUME now cover all three here as well.
|
||||
|
||||
/edgelighting/vs/0 and /light/stateful/vs/0 used to be on this list too,
|
||||
until issue #288 (six System A/C cassette units on this same board) gave
|
||||
real dump evidence for both -- airconditioner.EDGE_LIGHTING and
|
||||
LIGHT_STATEFUL now cover them.
|
||||
|
||||
/mds/absenceclean/vs/0 used to be on this list too -- its {mode,
|
||||
supportedModes: [On, Off]} shape is byte-identical to issue #319's
|
||||
/csi/absenceclean/vs/0, confirmed on that sibling board rather than
|
||||
guessed, so airconditioner.MDS_ABSENCE_CLEAN now covers it too.
|
||||
|
||||
/uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too,
|
||||
until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both --
|
||||
this board's own live filterUsage/filterStatus data on the PM1 filter binds
|
||||
through the same exists_fn-gated entities #270's dump (which has neither
|
||||
field) leaves empty.
|
||||
|
||||
/airlevelcheck/vs/0 was never on this unbound list (this fixture's rep
|
||||
already carried a full set of periodicSensing*/autoExeState fields), but
|
||||
until PR #316 it was globally ignored by airconditioner.py's own
|
||||
_AC_IGNORED as "scheduler plumbing" -- this fixture's own populated values
|
||||
were the proof that description was wrong. air_purifier.AIR_LEVEL_CHECK
|
||||
now covers it (see test_airlevelcheck_binds_real_ai_purify_state below).
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
@@ -27,13 +49,7 @@ from tests.conftest import _load_device
|
||||
|
||||
_STILL_UNBOUND = frozenset(
|
||||
{
|
||||
"/edgelighting/vs/0",
|
||||
"/light/stateful/vs/0",
|
||||
"/mds/absenceclean/vs/0",
|
||||
"/settings/sound/mode/vs/0",
|
||||
"/settings/sound/optimization/vs/0",
|
||||
"/settings/sound/output/vs/0",
|
||||
"/settings/sound/volume/vs/0",
|
||||
"/smartsensingcooling/vs/0",
|
||||
}
|
||||
)
|
||||
@@ -65,6 +81,32 @@ def test_documented_coverage_gap_is_exactly_this_set():
|
||||
assert set(unbound) == _STILL_UNBOUND
|
||||
|
||||
|
||||
def test_mds_absenceclean_shares_csi_absenceclean_key():
|
||||
"""/mds/absenceclean/vs/0's mode=='Off' on this dump -- confirms
|
||||
MDS_ABSENCE_CLEAN actually binds (not just that the href stops
|
||||
reporting as unbound)."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state["absence_clean"] is False
|
||||
|
||||
|
||||
def test_airlevelcheck_binds_real_ai_purify_state():
|
||||
"""This fixture's /airlevelcheck/vs/0 has real, populated values --
|
||||
periodic_air_sensing on, sensing_mode 'Alarm' -- confirming
|
||||
air_purifier.AIR_LEVEL_CHECK binds real AI-Purify state here rather
|
||||
than the inert plumbing _AC_IGNORED used to describe."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state["periodic_air_sensing"] is True
|
||||
assert state["sensing_mode"] == "Alarm"
|
||||
assert state["sensing_interval"] == 30 # 1800s
|
||||
assert state["air_sensing_state"] == "NonProcessing"
|
||||
|
||||
|
||||
def test_non_legacy_board_uses_the_generic_energy_scale():
|
||||
"""This board reports /wind/strength/vs/0 (not /airflow/vs/0), so
|
||||
is_legacy_board() is False and it must use the plain wh_to_kwh scale,
|
||||
|
||||
@@ -95,10 +95,13 @@ def test_power_and_convenient_folded_into_climate():
|
||||
assert "convenient_mode" not in state
|
||||
|
||||
|
||||
def test_air_filter_usage_is_percentage_of_capacity():
|
||||
"""filterUsage is a raw count in the capacity unit (100 of 500), surfaced as
|
||||
a percentage rather than the misleading raw value."""
|
||||
assert _state()["air_filter_usage"] == 20
|
||||
def test_air_filter_usage_is_already_a_percentage():
|
||||
"""filterUsage is already 0-100, not a raw count to divide by
|
||||
filterCapacity (issue #330): this fixture (ARTIK051_PRAC_20K) reports
|
||||
filterStatus == 'wash' at filterUsage == '100', which only holds if
|
||||
filterUsage is already a percent -- dividing by the 500 capacity again
|
||||
would read a filter due for washing as 20% fresh."""
|
||||
assert _state()["air_filter_usage"] == 100
|
||||
|
||||
|
||||
def test_climate_write_targets():
|
||||
@@ -149,6 +152,81 @@ def test_climate_write_targets():
|
||||
assert write(("bogus", 1), {}) is None
|
||||
|
||||
|
||||
def test_climate_write_preserves_half_degree_temperature_steps():
|
||||
"""CAC/TP1X FAC boards advertise `0.5` on both temperature resources.
|
||||
The increment on /temperatures/vs/0 lives inside its items[] entry (the
|
||||
same shape every fixture in the corpus uses), not at the resource's top
|
||||
level -- a fabricated flat `{"/temperatures/vs/0": {"increment": ...}}`
|
||||
resource would pass a step-reading bug like that silently.
|
||||
|
||||
Calls _climate_write directly rather than through ClimateDesc.write_fn
|
||||
(as test_climate_write_targets above does): WriteFn only declares the
|
||||
(payload, rep) shape every other write_fn honors, so the type checker
|
||||
rejects a call carrying the climate-only href/resources params through
|
||||
that narrower alias -- same reason test_coordinator_send_command.py and
|
||||
test_airconditioner_artik051_krac.py import the function directly too.
|
||||
"""
|
||||
resources = _load_device("airconditioner_cac")
|
||||
assert airconditioner._climate_write(("temperature_ocf", 24.5), {}, None, resources) == (
|
||||
["temperature", "desired", "0"],
|
||||
{"temperature": 24.5},
|
||||
)
|
||||
assert airconditioner._climate_write(("temperature", 24.5), {}, None, resources) == (
|
||||
["temperatures", "vs", "0"],
|
||||
{
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "24.5"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_temperature_step_falls_back_to_temps_vs_items_when_no_control_resource():
|
||||
"""Isolates the /temperatures/vs/0 fallback: every fixture that carries
|
||||
an increment there also carries /temperature/control/vs/0, which
|
||||
_temperature_step checks first -- so without dropping that resource,
|
||||
this fallback branch is never actually exercised, and reinstating the
|
||||
original bug (reading the increment off /temperatures/vs/0's top level
|
||||
instead of unwrapping its items[0]) would still pass every other test."""
|
||||
resources = dict(_load_device("airconditioner_cac"))
|
||||
del resources[airconditioner.HREF_TEMP_CONTROL]
|
||||
assert airconditioner._temperature_step(resources) == 0.5
|
||||
assert airconditioner._climate_write(("temperature", 24.5), {}, None, resources) == (
|
||||
["temperatures", "vs", "0"],
|
||||
{
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "24.5"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_climate_write_rounds_to_whole_degree_with_no_advertised_increment():
|
||||
"""ARTIK051 boards have neither /temperature/control/vs/0 nor an
|
||||
increment field on /temperatures/vs/0's item -- target_temperature_step
|
||||
(climate.py) defaults to 1.0 there, so the write path must match rather
|
||||
than pass the raw value through unrounded."""
|
||||
resources = _load_device("airconditioner_artik051_krac_18k")
|
||||
assert airconditioner._temperature_step(resources) is None
|
||||
assert airconditioner._climate_write(("temperature", 23.6), {}, None, resources) == (
|
||||
["temperatures", "vs", "0"],
|
||||
{
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "24"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_climate_write_rejects_non_numeric_temperature():
|
||||
"""A non-numeric payload must reject the write (return None) rather than
|
||||
build a body with `{"temperature": None}` -- coordinator.py's
|
||||
async_send_command logs and drops a write_fn result of None instead of
|
||||
POSTing it."""
|
||||
assert airconditioner._climate_write(("temperature_ocf", "not-a-number"), {}) is None
|
||||
assert airconditioner._climate_write(("temperature", None), {}) is None
|
||||
|
||||
|
||||
def test_climate_consumed_hrefs_declared_as_coverage():
|
||||
"""The climate-consumed and ambiguous hrefs are declared in the AC registry
|
||||
(as no-entity coverage caps) so they don't leak as gaps -- but produce no
|
||||
@@ -221,6 +299,19 @@ def test_tp1x_climate_entity_is_bound():
|
||||
assert len(climate) == 1 and climate[0].href == "/mode/vs/0"
|
||||
|
||||
|
||||
def test_tp1x_airlevelcheck_binds_real_ai_purify_state():
|
||||
"""This fixture's /airlevelcheck/vs/0 has real, populated
|
||||
periodicSensing*/autoExeState values too (PR #316's finding that
|
||||
_AC_IGNORED's old "scheduler plumbing" description was wrong wasn't
|
||||
specific to one board) -- air_purifier.AIR_LEVEL_CHECK now covers it."""
|
||||
reg, resources = _ac_tp1x()
|
||||
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state["periodic_air_sensing"] is True
|
||||
assert state["sensing_mode"] == "Alarm"
|
||||
assert state["sensing_interval"] == 30 # 1800s
|
||||
assert state["air_sensing_state"] == "NonProcessing"
|
||||
|
||||
|
||||
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
|
||||
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
|
||||
-- resolved via the '_RAC_' modelNum fallback added for this device."""
|
||||
@@ -758,19 +849,34 @@ def test_tropical_night_state_levels_across_fixtures():
|
||||
assert level("airconditioner_tp2x_rac_20k") == 16
|
||||
|
||||
|
||||
def test_air_filter_usage_hours_reads_raw_count():
|
||||
"""filterUsage is a lifetime hour counter (41 of 500) that resets on
|
||||
filter replacement -- total_increasing, not measurement. Unit comes from
|
||||
def test_air_filter_usage_hours_derived_from_percent_and_capacity():
|
||||
"""filterUsage is a percent, not an hour count (issue #330): the lifetime
|
||||
hour reading is derived from percent * capacity rather than read off
|
||||
filterUsage directly. total_increasing, not measurement; unit comes from
|
||||
filterCapacityUnit via unit_fn, not a hardcoded 'h'."""
|
||||
desc = next(
|
||||
e
|
||||
for e in airconditioner.AIR_FILTER.entities
|
||||
if e.key == "air_filter_usage_hours" and isinstance(e, SensorDesc)
|
||||
)
|
||||
assert desc.value_fn("41") == 41
|
||||
assert desc.value_fn(41) == 41
|
||||
assert desc.value_fn(None) is None
|
||||
assert desc.value_fn("not-a-number") is None
|
||||
assert desc.rep_fn is not None
|
||||
assert (
|
||||
desc.rep_fn(
|
||||
{"x.com.samsung.da.filterUsage": "91", "x.com.samsung.da.filterCapacity": "500"}
|
||||
)
|
||||
== 455.0
|
||||
)
|
||||
assert (
|
||||
desc.rep_fn(
|
||||
{
|
||||
"x.com.samsung.da.filterUsage": "not-a-number",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert desc.rep_fn({"x.com.samsung.da.filterCapacity": "500"}) is None
|
||||
assert desc.rep_fn({"x.com.samsung.da.filterUsage": "91"}) is None
|
||||
assert desc.device_class == "duration"
|
||||
assert desc.state_class == "total_increasing"
|
||||
assert desc.unit_fn is not None
|
||||
@@ -817,8 +923,8 @@ def test_air_filter_threshold_absent_without_supported_enum():
|
||||
reg, resources = _ac_windfree()
|
||||
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert "air_filter_threshold" not in state
|
||||
assert state["air_filter_usage_hours"] == 41
|
||||
assert state["air_filter_usage"] == 8 # 41/500 -> 8%
|
||||
assert state["air_filter_usage"] == 41
|
||||
assert state["air_filter_usage_hours"] == 205.0 # 41% of 500
|
||||
|
||||
|
||||
def test_air_filter_threshold_binds_on_enum_board():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user