Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6909fb847 | ||
|
|
9ee0329467 | ||
|
|
e9e278726a | ||
|
|
ccdfe7088e | ||
|
|
c423efdd23 | ||
|
|
3918b1e5c8 | ||
|
|
6dd4de8b6b | ||
|
|
7086b134c0 | ||
|
|
7f66d21d73 | ||
|
|
9e12992cb4 | ||
|
|
b59b5ae1b4 | ||
|
|
f8a7a1fa66 | ||
|
|
290a348017 | ||
|
|
54a676f841 | ||
|
|
a38437fca4 | ||
|
|
3d0dca20f9 | ||
|
|
96d06369bc | ||
|
|
330b2f344f | ||
|
|
a5484f746a | ||
|
|
412fff9b99 | ||
|
|
0c2e219464 | ||
|
|
b5699badbe | ||
|
|
d5adf311da | ||
|
|
6033709f24 | ||
|
|
15be379243 | ||
|
|
cdaff4a1ca | ||
|
|
6a6eef25b8 | ||
|
|
da25d567cb | ||
|
|
e89aa4bab5 | ||
|
|
fb5ed32b0f | ||
|
|
1becd85f6e | ||
|
|
0cc9486ad5 | ||
|
|
e6d7dcddc8 | ||
|
|
cf09247e39 | ||
|
|
0994ca487a | ||
|
|
3ef64eae52 | ||
|
|
51103fa341 | ||
|
|
9dc1facbfe | ||
|
|
24d48d70b9 | ||
|
|
18596f23ec | ||
|
|
450f8ca933 | ||
|
|
679c3d2bce | ||
|
|
36a642135b | ||
|
|
07061c734d | ||
|
|
daf7e3787f | ||
|
|
d9c84e765b | ||
|
|
00abfb9770 | ||
|
|
9fc04e179e | ||
|
|
b0eab93b58 | ||
|
|
547388cc2b | ||
|
|
81b83f4175 | ||
|
|
c5f37ea280 | ||
|
|
b4550cc4b1 | ||
|
|
42a1812967 | ||
|
|
e926905517 | ||
|
|
6bcf8f3bec | ||
|
|
9737684c9f | ||
|
|
6efee761d9 | ||
|
|
e309d3e7b8 | ||
|
|
34b2291bef | ||
|
|
5c275753d4 | ||
|
|
57eb3e80c7 | ||
|
|
d544644e0e | ||
|
|
f9cd857ced | ||
|
|
93744e80f4 | ||
|
|
a74b92f758 | ||
|
|
d639aa693f | ||
|
|
3c6c7f246f | ||
|
|
0d0844ea7f | ||
|
|
618fc5fa51 | ||
|
|
9ee0dbb8f9 | ||
|
|
f19abc7936 |
@@ -399,6 +399,24 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
|
||||
must mirror `en.json` key for key — also enforced by
|
||||
`tests/test_translations.py`.
|
||||
|
||||
**Don't write a test that just re-asserts a translation string.** Adding
|
||||
labels is a data change, not a logic change, and `tests/test_translations.py`
|
||||
already holds the invariants that matter for data (every descriptor has a
|
||||
catalog entry, every language mirrors English key-for-key, no unresolved
|
||||
`[%key:...%]`). A test that loads the catalog and asserts
|
||||
`catalog["select"]["foo"]["state"]["16"] == "Cotton"` right after you just
|
||||
wrote that exact line into `en.json` doesn't exercise any code path — it
|
||||
re-states the JSON file in Python, passes by construction, and only ever
|
||||
fails when someone *correctly* edits the label later (a wording fix, a
|
||||
translator's improvement). It's not a regression test, because there's no
|
||||
`select.py`/`adapter.py` logic between "the JSON says X" and "the test reads
|
||||
X" for it to catch drift in. If a code/label mapping is worth locking in,
|
||||
test it through the code that actually consumes it instead — a write
|
||||
contract (`desc.write_fn(...)` returns the right raw code), a read contract
|
||||
(`flatten()` produces the right raw value from a fixture rep), or a routing
|
||||
decision — never a bare literal-string comparison against the catalog you
|
||||
just edited.
|
||||
|
||||
## 8. Coverage discipline: bound or ignored
|
||||
|
||||
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
||||
|
||||
@@ -36,6 +36,32 @@ jobs:
|
||||
with:
|
||||
category: integration
|
||||
|
||||
lint:
|
||||
name: Lint, format, and type check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format --check custom_components tests
|
||||
|
||||
- name: Run ruff lint
|
||||
run: ruff check custom_components tests
|
||||
|
||||
- name: Run ty type check
|
||||
run: ty check custom_components tests
|
||||
|
||||
tests:
|
||||
name: Pytest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
|
||||
# repeats the install attempt on every container recreate. Baking
|
||||
# smartthings-local into the image keeps the dev container usable
|
||||
# offline and avoids relying on that runtime install path.
|
||||
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.0"
|
||||
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.2"
|
||||
|
||||
@@ -63,7 +63,7 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
|
||||
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
||||
```
|
||||
|
||||
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow sweeps the whole range and auto-detects the live port, so you don't need to know which one your device uses.
|
||||
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow probes the whole range and auto-detects the live port, so you don't need to know which one your device uses.
|
||||
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
|
||||
|
||||
---
|
||||
@@ -82,10 +82,10 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
||||
2. Restart HA.
|
||||
3. **Settings > Devices & Services > Add Integration > LocalThings.**
|
||||
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
|
||||
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, sweeps the `49152-49160` range to find the live DTLS port, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
|
||||
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
|
||||
5. The flow sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and `/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
|
||||
6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
|
||||
|
||||
Entities appear under one HA device per appliance, named `Samsung Appliance (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name.
|
||||
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on its serial, not its name.
|
||||
|
||||
---
|
||||
|
||||
@@ -131,13 +131,13 @@ A large suite covering registry composition, discovery, entity descriptors, and
|
||||
custom_components/localthings/
|
||||
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
|
||||
__init__.py async_setup_entry / async_unload_entry
|
||||
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
|
||||
config_flow.py ClientHello port probe, UUID fetch, leaf cert minting, identity resolution
|
||||
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)
|
||||
const.py Domain, config keys, probe ports
|
||||
entity.py Base entity wiring capability registry -> HA entity
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
|
||||
One module per HA platform
|
||||
catalog.py Reads the shipped translation catalog (which keys/states exist)
|
||||
translations/ Config-flow copy + entity name/state translations, one file per
|
||||
@@ -194,6 +194,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.
|
||||
|
||||
@@ -1,66 +1,213 @@
|
||||
"""Local Things — Samsung appliance local control integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
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 .const import DOMAIN, PLATFORMS
|
||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.identity import resolve_serial
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 -- 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: 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}_"
|
||||
unique_id = entry.unique_id or ""
|
||||
if not unique_id.startswith(prefix):
|
||||
return host
|
||||
serial = unique_id[len(prefix) :]
|
||||
if serial == f"{host}:{entry.data.get(CONF_PORT)}":
|
||||
return host
|
||||
return resolve_serial(serial, host)
|
||||
|
||||
|
||||
@callback
|
||||
def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: str) -> None:
|
||||
"""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 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 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.
|
||||
return
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
stale_prefix = f"{DOMAIN}_{host}_"
|
||||
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||
if not entity.unique_id.startswith(stale_prefix):
|
||||
continue
|
||||
new_unique_id = f"{DOMAIN}_{serial}_{entity.unique_id[len(stale_prefix) :]}"
|
||||
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
|
||||
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
|
||||
ent_reg.async_remove(entity.entity_id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
|
||||
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
|
||||
# `host` for the master, `host_<key>` for a subdevice (device_info_for).
|
||||
stale = {
|
||||
ident
|
||||
for ident in device.identifiers
|
||||
if ident[0] == DOMAIN and (ident[1] == host or ident[1].startswith(f"{host}_"))
|
||||
}
|
||||
if not stale:
|
||||
continue
|
||||
fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") for ident in stale}
|
||||
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 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
|
||||
):
|
||||
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
|
||||
_LOGGER.debug("removing orphaned device %s", device.id)
|
||||
dev_reg.async_remove_device(device.id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
|
||||
dev_reg.async_update_device(
|
||||
device.id, new_identifiers=(device.identifiers - stale) | fresh
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if entry.version > 2:
|
||||
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)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, CONF_SERIAL: serial},
|
||||
unique_id=f"{DOMAIN}_{serial}",
|
||||
version=2,
|
||||
)
|
||||
_repair_placeholder_keys(hass, entry, serial)
|
||||
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception as err:
|
||||
# `_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): 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, 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()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
device: dr.DeviceEntry,
|
||||
) -> 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
|
||||
)
|
||||
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
|
||||
live = set(coordinator.device_info.get('identifiers') or set())
|
||||
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())
|
||||
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
|
||||
return not (device.identifiers & live)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Binary sensor platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import BinarySensorDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import BinarySensorDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,11 +27,12 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: BinarySensorDesc = bound.desc
|
||||
self._attr_device_class = desc.device_class
|
||||
self._attr_device_class = (
|
||||
BinarySensorDeviceClass(desc.device_class) if desc.device_class else None
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Button platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
@@ -6,11 +7,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import ButtonDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import ButtonDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,7 +27,6 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
self._payload = bound.desc.payload
|
||||
|
||||
@@ -20,6 +20,7 @@ states exist, which is something the Python side genuinely needs to know:
|
||||
Reading it from the catalog instead of restating it in Python means adding a
|
||||
state or a course table is a one-file change, and the two can't drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -28,8 +29,8 @@ from pathlib import Path
|
||||
# Read at import, not lazily: custom integrations are imported in an executor
|
||||
# thread, so this stays off the event loop no matter who asks first.
|
||||
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
|
||||
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
|
||||
).get('entity', {})
|
||||
(Path(__file__).parent / "translations" / "en.json").read_text(encoding="utf-8")
|
||||
).get("entity", {})
|
||||
|
||||
|
||||
def has_entity_translation(platform: str, translation_key: str) -> bool:
|
||||
@@ -46,4 +47,4 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
|
||||
leave the device's value untouched.
|
||||
"""
|
||||
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
|
||||
return frozenset(entry.get('state', ())) if entry else frozenset()
|
||||
return frozenset(entry.get("state", ())) if entry else frozenset()
|
||||
|
||||
@@ -1,176 +1,198 @@
|
||||
"""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 logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
PRESET_NONE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import ClimateDesc
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_AIRFLOW as AIRFLOW_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_CONVENIENT as CONVENIENT_HREF,
|
||||
)
|
||||
|
||||
# The AC's canonical resource hrefs live in the capability module (the single
|
||||
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
|
||||
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
|
||||
# POWER_VS_FALLBACK.
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_MODE as MODE_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_POWER as POWER_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_POWER_VS as POWER_VS_HREF,
|
||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||
HREF_TEMP_DESIRED as TEMP_DESIRED_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,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMPS_VS as TEMPS_VS_HREF,
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
|
||||
HREF_CONVENIENT as CONVENIENT_HREF,
|
||||
HREF_AIRFLOW as AIRFLOW_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
is_legacy_board,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import ClimateDesc
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||
|
||||
# --- device code <-> HA value maps -----------------------------------------
|
||||
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
|
||||
# driven by the power resource).
|
||||
_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': 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.
|
||||
'Auto': HVACMode.AUTO,
|
||||
'Heat': HVACMode.HEAT,
|
||||
"Cool": HVACMode.COOL,
|
||||
"Dry": HVACMode.DRY,
|
||||
# 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,
|
||||
# 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_COMFORT_MODE = 'AIComfort'
|
||||
PRESET_AI_COMFORT = 'ai_comfort'
|
||||
# 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 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 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"})
|
||||
|
||||
# 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] = {
|
||||
'0': 'auto',
|
||||
'1': 'low',
|
||||
'2': 'medium',
|
||||
'3': 'high',
|
||||
'4': 'turbo',
|
||||
"0": "auto",
|
||||
"1": "low",
|
||||
"2": "medium",
|
||||
"3": "high",
|
||||
"4": "turbo",
|
||||
}
|
||||
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
|
||||
|
||||
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
|
||||
_DEVICE_TO_SWING: dict[str, str] = {
|
||||
'Fix': 'off',
|
||||
'All': 'both',
|
||||
'Up_And_Low': 'vertical',
|
||||
'Left_And_Right': 'horizontal', # issue #75
|
||||
"Fix": "off",
|
||||
"All": "both",
|
||||
"Up_And_Low": "vertical",
|
||||
"Left_And_Right": "horizontal", # issue #75
|
||||
}
|
||||
_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')
|
||||
vertical = rep.get("vertical")
|
||||
horizontal = rep.get("horizontal")
|
||||
if vertical is None and horizontal is None:
|
||||
return None
|
||||
v = vertical == 'Swing'
|
||||
h = horizontal == 'Swing'
|
||||
v = vertical == "Swing"
|
||||
h = horizontal == "Swing"
|
||||
if v and h:
|
||||
return 'both'
|
||||
return "both"
|
||||
if v:
|
||||
return 'vertical'
|
||||
return "vertical"
|
||||
if h:
|
||||
return 'horizontal'
|
||||
return 'off'
|
||||
return "horizontal"
|
||||
return "off"
|
||||
|
||||
|
||||
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."""
|
||||
supported = rep.get('x.com.samsung.da.supportedModes') or []
|
||||
names = rep.get('x.com.samsung.da.modesName') or []
|
||||
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):
|
||||
return str(names[supported.index(code)]).lower()
|
||||
return str(code).lower()
|
||||
|
||||
|
||||
# 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()
|
||||
return PRESET_NONE if code == "Off" else str(code).lower()
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -204,14 +226,13 @@ 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')
|
||||
items = rep.get("x.com.samsung.da.items")
|
||||
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
||||
return items[0]
|
||||
return {}
|
||||
@@ -220,16 +241,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
|
||||
@@ -239,9 +256,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 ---------------------------------------------------
|
||||
@@ -249,93 +265,82 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
|
||||
# through its cloud integration and reading the local token back each time:
|
||||
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
|
||||
_LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed')
|
||||
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
|
||||
|
||||
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 []
|
||||
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)}
|
||||
if isinstance(option, str) and option.startswith("Comode_"):
|
||||
return {
|
||||
_MODES_FIELD: [option.split("_", 1)[1]],
|
||||
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES),
|
||||
}
|
||||
return {}
|
||||
|
||||
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()))
|
||||
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").
|
||||
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
|
||||
# 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'))
|
||||
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 [])
|
||||
|
||||
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 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
|
||||
@@ -343,7 +348,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
_LOGGER.warning(
|
||||
"%s: device mode %r on %s has no HA mapping and was dropped; "
|
||||
"please file an issue with your diagnostics dump",
|
||||
self.entity_id, code, href,
|
||||
self.entity_id or self.unique_id,
|
||||
code,
|
||||
href,
|
||||
)
|
||||
|
||||
def _read_mode(self, href: str, mapping: dict):
|
||||
@@ -365,11 +372,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:
|
||||
@@ -378,18 +384,20 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
raw = self._rep(TEMP_DESIRED_HREF).get('units')
|
||||
raw = self._rep(TEMP_DESIRED_HREF).get("units")
|
||||
if raw is None:
|
||||
raw = self._temps_vs().get('x.com.samsung.da.unit')
|
||||
return (UnitOfTemperature.FAHRENHEIT
|
||||
if normalize_temp_unit(raw, '°C') == '°F'
|
||||
else UnitOfTemperature.CELSIUS)
|
||||
raw = self._temps_vs().get("x.com.samsung.da.unit")
|
||||
return (
|
||||
UnitOfTemperature.FAHRENHEIT
|
||||
if normalize_temp_unit(raw, "°C") == "°F"
|
||||
else UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
|
||||
v = _num(self._rep(TEMP_CURRENT_HREF).get("temperature"))
|
||||
if v is None:
|
||||
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
|
||||
v = _num(self._temps_vs().get("x.com.samsung.da.current"))
|
||||
return v
|
||||
|
||||
@property
|
||||
@@ -397,19 +405,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# Read from the same channel writes go to (see async_set_temperature):
|
||||
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
|
||||
# /temperatures/vs/0 otherwise -- with the other as fallback.
|
||||
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
|
||||
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
|
||||
ocf = _num(self._rep(TEMP_DESIRED_HREF).get("temperature"))
|
||||
vs = _num(self._temps_vs().get("x.com.samsung.da.desired"))
|
||||
if self._ocf_temp_authoritative():
|
||||
return ocf if ocf is not None else vs
|
||||
return vs if vs is not None else ocf
|
||||
|
||||
def _range(self) -> list | None:
|
||||
r = self._rep(TEMP_DESIRED_HREF).get('range')
|
||||
r = self._rep(TEMP_DESIRED_HREF).get("range")
|
||||
if isinstance(r, (list, tuple)) and len(r) == 2:
|
||||
return r
|
||||
item = self._temps_vs()
|
||||
lo = _num(item.get('x.com.samsung.da.minimum'))
|
||||
hi = _num(item.get('x.com.samsung.da.maximum'))
|
||||
lo = _num(item.get("x.com.samsung.da.minimum"))
|
||||
hi = _num(item.get("x.com.samsung.da.maximum"))
|
||||
return [lo, hi] if (lo is not None and hi is not None) else None
|
||||
|
||||
@property
|
||||
@@ -424,10 +432,12 @@ 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)
|
||||
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
|
||||
)
|
||||
|
||||
# -- hvac mode ----------------------------------------------------------
|
||||
|
||||
@@ -438,7 +448,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||
if device == _AI_COMFORT_MODE:
|
||||
return HVACMode.AUTO
|
||||
if device is not None and device not in _DEVICE_TO_HVAC:
|
||||
if (
|
||||
device is not None
|
||||
and device not in _DEVICE_TO_HVAC
|
||||
and device not in _NON_HVAC_OPTION_CODES
|
||||
):
|
||||
self._warn_unmapped(MODE_HREF, device)
|
||||
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
||||
|
||||
@@ -446,7 +460,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
modes = [HVACMode.OFF]
|
||||
for m in self._supported(MODE_HREF):
|
||||
if m == _AI_COMFORT_MODE:
|
||||
if m == _AI_COMFORT_MODE or m in _NON_HVAC_OPTION_CODES:
|
||||
continue
|
||||
mapped = _DEVICE_TO_HVAC.get(m)
|
||||
if mapped is None:
|
||||
@@ -462,7 +476,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def fan_mode(self):
|
||||
airflow = self._legacy_airflow()
|
||||
if airflow:
|
||||
return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel')))
|
||||
return _DEVICE_TO_FAN.get(str(airflow.get("x.com.samsung.da.speedLevel")))
|
||||
rep = self._rep(WIND_STRENGTH_HREF)
|
||||
code = _first(rep.get(_MODES_FIELD))
|
||||
if code is None:
|
||||
@@ -492,7 +506,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def swing_mode(self):
|
||||
airflow = self._legacy_airflow()
|
||||
if airflow:
|
||||
return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction'))
|
||||
return _DEVICE_TO_SWING.get(airflow.get("x.com.samsung.da.direction"))
|
||||
if self._swing_via_direction():
|
||||
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
|
||||
@@ -534,40 +548,38 @@ 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.
|
||||
hvac_mode = kwargs.get('hvac_mode')
|
||||
# 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)
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
return
|
||||
temp = kwargs.get('temperature')
|
||||
temp = kwargs.get("temperature")
|
||||
if temp is None:
|
||||
return
|
||||
# OCF-pair boards write /temperature/desired/0; vendor boards write
|
||||
# /temperatures/vs/0 (see airconditioner._climate_write).
|
||||
kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature'
|
||||
kind = "temperature_ocf" if self._ocf_temp_authoritative() else "temperature"
|
||||
await self.coordinator.async_send_command(self._bound, (kind, temp))
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
||||
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||
return
|
||||
device = self._device_code_for_hvac(hvac_mode)
|
||||
if device is None:
|
||||
return
|
||||
if not self._is_on():
|
||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', device))
|
||||
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||
await self.coordinator.async_send_command(self._bound, ("mode", device))
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
||||
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
||||
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||
|
||||
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
|
||||
"""Map an HA fan/swing/preset value back to its device code and write it."""
|
||||
@@ -579,18 +591,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
if self._legacy_airflow():
|
||||
level = _FAN_TO_DEVICE.get(fan_mode)
|
||||
if level is not None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ('fan_legacy', level))
|
||||
await self.coordinator.async_send_command(self._bound, ("fan_legacy", level))
|
||||
return
|
||||
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:
|
||||
@@ -598,33 +608,31 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
device = code
|
||||
break
|
||||
if device is not None:
|
||||
await self.coordinator.async_send_command(self._bound, ('fan', device))
|
||||
await self.coordinator.async_send_command(self._bound, ("fan", device))
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
if self._legacy_airflow():
|
||||
code = _SWING_TO_DEVICE.get(swing_mode)
|
||||
if code is not None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ('swing_legacy', code))
|
||||
await self.coordinator.async_send_command(self._bound, ("swing_legacy", code))
|
||||
return
|
||||
if self._swing_via_direction():
|
||||
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
|
||||
await self._set_mapped("swing", _SWING_TO_DEVICE, swing_mode)
|
||||
return
|
||||
if self._rep(WIND_OSCILLATION_HREF):
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ('oscillation', swing_mode))
|
||||
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
|
||||
|
||||
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 --
|
||||
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
|
||||
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
|
||||
await self.coordinator.async_send_command(self._bound, ("mode", _AI_COMFORT_MODE))
|
||||
return
|
||||
# Reverse-resolve against the unit's own supportedModes (codes aren't
|
||||
# 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:
|
||||
kind = 'preset_legacy' if self._legacy_preset() else 'preset'
|
||||
kind = "preset_legacy" if self._legacy_preset() else "preset"
|
||||
await self.coordinator.async_send_command(self._bound, (kind, code))
|
||||
return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,94 @@
|
||||
DOMAIN = "localthings"
|
||||
|
||||
PLATFORMS = [
|
||||
"sensor", "binary_sensor", "switch", "number", "select", "button",
|
||||
"time", "climate", "fan",
|
||||
"sensor",
|
||||
"binary_sensor",
|
||||
"switch",
|
||||
"number",
|
||||
"select",
|
||||
"button",
|
||||
"time",
|
||||
"climate",
|
||||
"fan",
|
||||
"water_heater",
|
||||
]
|
||||
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
CONF_CA_CERT_PEM = "ca_cert_pem"
|
||||
CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
CONF_CA_CERT_PEM = "ca_cert_pem"
|
||||
CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
|
||||
# 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.
|
||||
# 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, 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 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.
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
# 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, 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.
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ device > the menu > Download diagnostics. This is what the Repairs issue
|
||||
users at: a redacted snapshot of the device's raw /device/0 state, plus
|
||||
enough version/coverage metadata to reproduce and diagnose the gap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version as pkg_version
|
||||
@@ -34,31 +35,24 @@ async def async_get_config_entry_diagnostics(
|
||||
|
||||
# /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))
|
||||
return {
|
||||
@@ -67,14 +61,11 @@ async def async_get_config_entry_diagnostics(
|
||||
**_seed_diag(su),
|
||||
"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.
|
||||
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
|
||||
# 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,
|
||||
}
|
||||
|
||||
@@ -86,72 +77,56 @@ async def async_get_config_entry_diagnostics(
|
||||
"model": identity.model,
|
||||
"device_types": list(identity.device_types),
|
||||
"resources": redact_resources(identity.raw),
|
||||
} if identity is not None else None,
|
||||
}
|
||||
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`.
|
||||
# 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.canonical_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.
|
||||
"resources": redact_resources({
|
||||
canon: rep
|
||||
for href, rep in coordinator._skipped_subdevice_resources.items()
|
||||
if (canon := skip.subdevice.to_canonical(href)) is not None
|
||||
}),
|
||||
# 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
|
||||
for href, rep in coordinator._skipped_subdevice_resources.items()
|
||||
if (canon := skip.subdevice.to_canonical(href)) is not None
|
||||
}
|
||||
),
|
||||
}
|
||||
for skip in coordinator._skipped_subdevices
|
||||
],
|
||||
# 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),
|
||||
"integration_version": integration.version,
|
||||
"smartthings_local_version": stl_version,
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
"""Base entity for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.adapter import _key
|
||||
from .registry.batch import is_stub_rep
|
||||
from .registry.discovery import BoundEntity, _snake_to_title
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
|
||||
|
||||
def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> bool:
|
||||
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:
|
||||
@@ -63,7 +58,7 @@ def _derive_name(state_key: str) -> str:
|
||||
builds the {instance_name} placeholder those translations interpolate,
|
||||
for a device that named its own compartments/ice makers.
|
||||
"""
|
||||
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
|
||||
name = re.sub(r"(\d+)$", lambda m: f" {m.group()}" if int(m.group()) > 0 else "", state_key)
|
||||
return _snake_to_title(name).strip()
|
||||
|
||||
|
||||
@@ -74,9 +69,9 @@ def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
|
||||
source = bound.key_override or state_key
|
||||
suffix = f"_{bound.desc.key}"
|
||||
if source.endswith(suffix):
|
||||
source = source[:-len(suffix)]
|
||||
source = source[: -len(suffix)]
|
||||
elif bound.instance and source.endswith(bound.instance):
|
||||
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
|
||||
source = source[: -len(bound.instance)] + bound.instance.replace("_", " ")
|
||||
return _derive_name(source)
|
||||
|
||||
|
||||
@@ -91,20 +86,16 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
if bound.desc.translation_placeholders is not None:
|
||||
self._attr_translation_placeholders = dict(
|
||||
bound.desc.translation_placeholders
|
||||
)
|
||||
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
|
||||
elif bound.desc.use_instance_name:
|
||||
self._attr_translation_placeholders = {
|
||||
"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
|
||||
@@ -114,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):
|
||||
@@ -135,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
|
||||
|
||||
@@ -32,22 +31,24 @@ from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.capabilities.air_purifier import HREF_AIRFLOW
|
||||
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
|
||||
from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF
|
||||
from .registry.capabilities.air_purifier import (
|
||||
HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF,
|
||||
)
|
||||
from .registry.entities import FanDesc
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
POWER_HREF = '/power/0'
|
||||
POWER_VS_HREF = '/power/vs/0'
|
||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
||||
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
|
||||
_MAX_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMaxFanSpeed'
|
||||
_OFF_SPEED_CODE = '0'
|
||||
POWER_HREF = "/power/0"
|
||||
POWER_VS_HREF = "/power/vs/0"
|
||||
_FAN_SPEED_FIELD = "x.com.samsung.da.hood.fanSpeed"
|
||||
_SUPPORTED_FAN_SPEED_FIELD = "x.com.samsung.da.hood.supportedFanSpeed"
|
||||
_MIN_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMinFanSpeed"
|
||||
_MAX_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMaxFanSpeed"
|
||||
_OFF_SPEED_CODE = "0"
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
_MODES_NAME_FIELD = 'x.com.samsung.da.modesName'
|
||||
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||
_SUPPORTED_MODES_FIELD = "x.com.samsung.da.supportedModes"
|
||||
_MODES_NAME_FIELD = "x.com.samsung.da.modesName"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -74,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
|
||||
@@ -110,13 +106,12 @@ 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
|
||||
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
|
||||
or _OFF_SPEED_CODE in self._all_speed_codes()
|
||||
)
|
||||
|
||||
@@ -138,31 +133,27 @@ 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]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
resources = self._resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
return "power", enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
if self._speed_zero_is_off():
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
|
||||
return current not in ('', _OFF_SPEED_CODE)
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, "0"))
|
||||
return current not in ("", _OFF_SPEED_CODE)
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
return str(
|
||||
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
|
||||
).lower() == 'on'
|
||||
if "value" in rep:
|
||||
return bool(rep.get("value"))
|
||||
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
@@ -173,13 +164,15 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
if not self.is_on:
|
||||
return 0
|
||||
codes = self._active_speed_codes()
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ""))
|
||||
if not codes or current not in codes:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(codes, current)
|
||||
|
||||
async def async_turn_on(
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
@@ -192,20 +185,22 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if codes:
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
|
||||
await self.coordinator.async_send_command(self._bound, ("speed", codes[0]))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
self._bound,
|
||||
self._power_payload(True),
|
||||
)
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
|
||||
await self.coordinator.async_send_command(self._bound, ("speed", _OFF_SPEED_CODE))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(False),
|
||||
self._bound,
|
||||
self._power_payload(False),
|
||||
)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
@@ -217,10 +212,11 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
return
|
||||
if not self._speed_zero_is_off() and not self.is_on:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
self._bound,
|
||||
self._power_payload(True),
|
||||
)
|
||||
code = percentage_to_ordered_list_item(codes, percentage)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', code))
|
||||
await self.coordinator.async_send_command(self._bound, ("speed", code))
|
||||
|
||||
|
||||
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
@@ -230,9 +226,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
@@ -253,27 +247,24 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
correctly."""
|
||||
resources = self._resources
|
||||
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
||||
return 'power', enabled, target
|
||||
return "power", enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
|
||||
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'))
|
||||
return str(power).lower() == "on"
|
||||
return bool(self._rep(POWER_HREF).get("value"))
|
||||
|
||||
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)
|
||||
@@ -284,8 +275,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
return [
|
||||
self._label_for_code(code)
|
||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
||||
self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
||||
]
|
||||
|
||||
@property
|
||||
@@ -295,7 +285,9 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
return self._label_for_code(code) if code is not None else None
|
||||
|
||||
async def async_turn_on(
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
@@ -311,22 +303,21 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
# modesName-labelled board), not the lowercased HA value.
|
||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
|
||||
if self._label_for_code(code) == preset_mode:
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', code))
|
||||
await self.coordinator.async_send_command(self._bound, ("mode", code))
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"%s: %r is not a valid preset mode (supported: %s)",
|
||||
self.entity_id, preset_mode, self.preset_modes,
|
||||
self.entity_id,
|
||||
preset_mode,
|
||||
self.preset_modes,
|
||||
)
|
||||
|
||||
|
||||
_AIRFLOW_SPEED_FIELD = 'speed'
|
||||
_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.
|
||||
_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
|
||||
# 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"]
|
||||
|
||||
|
||||
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
@@ -335,9 +326,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
@@ -350,24 +339,21 @@ 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
|
||||
return "power", enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
power = self._rep(POWER_HREF)
|
||||
if 'value' in power:
|
||||
return bool(power.get('value'))
|
||||
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
|
||||
if "value" in power:
|
||||
return bool(power.get("value"))
|
||||
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
@@ -377,13 +363,15 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
def percentage(self) -> int | None:
|
||||
if not self.is_on:
|
||||
return 0
|
||||
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
|
||||
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ""))
|
||||
if current not in _AIRFLOW_SPEED_CODES:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
|
||||
|
||||
async def async_turn_on(
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
@@ -400,4 +388,4 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
if not self.is_on:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
|
||||
await self.coordinator.async_send_command(self._bound, ("speed", int(code)))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.1"
|
||||
"smartthings-local>=0.1.2"
|
||||
],
|
||||
"version": "0.18.0"
|
||||
"version": "0.19.0"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""Number platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import NumberDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import NumberDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,14 +29,15 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: NumberDesc = bound.desc
|
||||
desc = cast(NumberDesc, bound.desc)
|
||||
self._attr_native_unit_of_measurement = desc.unit
|
||||
self._attr_device_class = desc.device_class
|
||||
self._attr_device_class = (
|
||||
NumberDeviceClass(desc.device_class) if desc.device_class else None
|
||||
)
|
||||
if desc.native_min is not None:
|
||||
self._attr_native_min_value = desc.native_min
|
||||
if desc.native_max is not None:
|
||||
@@ -44,13 +47,13 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: NumberDesc = self._bound.desc
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if desc.unit_fn is not None:
|
||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
def _range_from_resource(self) -> list | None:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if not desc.range_field:
|
||||
return None
|
||||
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
||||
@@ -58,34 +61,34 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if desc.native_min_fn is not None:
|
||||
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
|
||||
r = self._range_from_resource()
|
||||
if r is not None:
|
||||
return float(r[0])
|
||||
if hasattr(self, '_attr_native_min_value'):
|
||||
if hasattr(self, "_attr_native_min_value"):
|
||||
return self._attr_native_min_value
|
||||
return super().native_min_value
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if desc.native_max_fn is not None:
|
||||
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
|
||||
r = self._range_from_resource()
|
||||
if r is not None:
|
||||
return float(r[1])
|
||||
if hasattr(self, '_attr_native_max_value'):
|
||||
if hasattr(self, "_attr_native_max_value"):
|
||||
return self._attr_native_max_value
|
||||
return super().native_max_value
|
||||
|
||||
@property
|
||||
def native_step(self) -> float:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
def native_step(self) -> float | None:
|
||||
desc = cast(NumberDesc, self._bound.desc)
|
||||
if desc.step_fn is not None:
|
||||
return desc.step_fn(self.coordinator.resource(self._bound.href))
|
||||
if hasattr(self, '_attr_native_step'):
|
||||
if hasattr(self, "_attr_native_step"):
|
||||
return self._attr_native_step
|
||||
return super().native_step
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ are an external pip dependency we don't own, so behavior that would
|
||||
naturally live inside StateCache.apply_rep lives here instead, gating
|
||||
whether apply_rep is called at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -15,16 +16,15 @@ import threading
|
||||
import time
|
||||
|
||||
import cbor2
|
||||
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REFRESH_INTERVAL_S = 6 * 3600.0
|
||||
|
||||
MODE_OBSERVE = 'observe'
|
||||
MODE_POLL = 'poll'
|
||||
MODE_OBSERVE = "observe"
|
||||
MODE_POLL = "poll"
|
||||
|
||||
DEFAULT_SETTLE_S = 4.0
|
||||
GRACE_PERIOD_S = 15.0
|
||||
@@ -73,6 +73,9 @@ class ObserveManager:
|
||||
self.subscribed_hrefs: set[str] = set()
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||
self._notify_cond = threading.Condition()
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
self._refresh_task: ObserveRefreshTask | None = None
|
||||
self._refresh_stop: threading.Event | None = None
|
||||
@@ -130,7 +133,7 @@ class ObserveManager:
|
||||
happened to expire, i.e. the exact symptom this guard exists to
|
||||
prevent, just relocated to whichever write loses the race.
|
||||
"""
|
||||
if source != 'optimistic' and self._is_settling(href):
|
||||
if source != "optimistic" and self._is_settling(href):
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
with self._cache_lock:
|
||||
@@ -147,10 +150,12 @@ class ObserveManager:
|
||||
return
|
||||
if not isinstance(rep, dict):
|
||||
return
|
||||
self._notified.add(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
with self._notify_cond:
|
||||
self._notified.add(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
self._notify_cond.notify_all()
|
||||
self.log.debug("observe notify: %s", href)
|
||||
self.apply(href, rep, source='observe')
|
||||
self.apply(href, rep, source="observe")
|
||||
|
||||
def recently_notified(self, window_s: float = PUSH_HEALTH_WINDOW_S) -> bool:
|
||||
"""True if any OBSERVE notify has arrived within `window_s`.
|
||||
@@ -163,22 +168,25 @@ class ObserveManager:
|
||||
channel has been perfectly healthy").
|
||||
"""
|
||||
return (
|
||||
self._last_notify_ts is not None
|
||||
and time.monotonic() - self._last_notify_ts < window_s
|
||||
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],
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — subscribes to every href then sleeps for the whole
|
||||
grace period. Caller must run this in an executor, never on the
|
||||
"""Blocking — subscribes to every href then waits up to
|
||||
`grace_period_s`, returning early once `success_fraction` of hrefs
|
||||
have notified. Caller must run this in an executor, never on the
|
||||
event loop."""
|
||||
self._notified.clear()
|
||||
with self._notify_cond:
|
||||
self._notified.clear()
|
||||
subscribed: set[str] = set()
|
||||
for href in hrefs:
|
||||
segs = [s for s in href.strip('/').split('/') if s]
|
||||
segs = [s for s in href.strip("/").split("/") if s]
|
||||
try:
|
||||
session.subscribe(segs)
|
||||
subscribed.add(href)
|
||||
@@ -190,10 +198,16 @@ class ObserveManager:
|
||||
self.subscribed_hrefs = set()
|
||||
return False
|
||||
|
||||
time.sleep(grace_period_s)
|
||||
def _fraction_reached() -> bool:
|
||||
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
|
||||
|
||||
fraction = len(set(self._notified) & subscribed) / len(subscribed)
|
||||
if fraction >= success_fraction:
|
||||
with self._notify_cond:
|
||||
reached = self._notify_cond.wait_for(
|
||||
_fraction_reached,
|
||||
timeout=grace_period_s,
|
||||
)
|
||||
|
||||
if reached:
|
||||
self.subscribed_hrefs = subscribed
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
@@ -256,7 +270,8 @@ class ObserveManager:
|
||||
found = True
|
||||
self.log.debug(
|
||||
"observe missed a change on %s (sweep disagrees with cache): %s",
|
||||
href, diff,
|
||||
href,
|
||||
diff,
|
||||
)
|
||||
return found
|
||||
|
||||
@@ -268,15 +283,19 @@ class ObserveManager:
|
||||
|
||||
def start_refresh_task(self, session) -> None:
|
||||
self._stop_refresh_task()
|
||||
paths = [tuple(h.strip('/').split('/')) for h in self.subscribed_hrefs]
|
||||
paths = [tuple(h.strip("/").split("/")) for h in self.subscribed_hrefs]
|
||||
self._refresh_task = ObserveRefreshTask(
|
||||
session, paths, interval_s=REFRESH_INTERVAL_S, logger=self.log,
|
||||
session,
|
||||
paths,
|
||||
interval_s=REFRESH_INTERVAL_S,
|
||||
logger=self.log,
|
||||
)
|
||||
self._refresh_stop = threading.Event()
|
||||
self._refresh_thread = threading.Thread(
|
||||
target=self._refresh_task.run_forever,
|
||||
args=(self._refresh_stop,),
|
||||
daemon=True, name='localthings-observe-refresh',
|
||||
daemon=True,
|
||||
name="localthings-observe-refresh",
|
||||
)
|
||||
self._refresh_thread.start()
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ to the matching capabilities, and flattens the result into HA-ready entity
|
||||
state. The DTLS/CoAP transport itself lives in the smartthings-local
|
||||
package, not here.
|
||||
"""
|
||||
from .adapter import flatten, _key
|
||||
|
||||
from .adapter import _key, flatten
|
||||
from .discovery import BoundEntity, discover
|
||||
from .registry import CAPABILITIES
|
||||
|
||||
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', '_key']
|
||||
__all__ = ["CAPABILITIES", "BoundEntity", "_key", "discover", "flatten"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""OCF /device/0 batch response parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -11,22 +12,27 @@ def is_stub_rep(rep: dict) -> bool:
|
||||
never populate. Conflating the two used to make every field-gated entity
|
||||
on a permanently-empty resource look like a not-yet-fetched stub forever,
|
||||
creating phantom always-"unknown" entities (issue #127)."""
|
||||
return isinstance(rep, dict) and set(rep.keys()) == {'href'}
|
||||
return isinstance(rep, dict) and set(rep.keys()) == {"href"}
|
||||
|
||||
|
||||
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')
|
||||
rep = entry.get('rep')
|
||||
href = entry.get("href")
|
||||
rep = entry.get("rep")
|
||||
if not href:
|
||||
continue
|
||||
if isinstance(rep, dict):
|
||||
|
||||
@@ -1,123 +1,139 @@
|
||||
"""Per-device-type registries."""
|
||||
import re
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
from . import (
|
||||
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
|
||||
dehumidifier, dishwasher, dryer, induction_cooktop, microwave, oven,
|
||||
range as _range, range_hood, refrigerator, vacuum_station, washer,
|
||||
air_dresser,
|
||||
air_monitor,
|
||||
air_purifier,
|
||||
airconditioner,
|
||||
cooktop,
|
||||
dehumidifier,
|
||||
dishwasher,
|
||||
dryer,
|
||||
ehs,
|
||||
induction_cooktop,
|
||||
microwave,
|
||||
oven,
|
||||
range_hood,
|
||||
refrigerator,
|
||||
vacuum_station,
|
||||
washer,
|
||||
water_purifier,
|
||||
)
|
||||
from . import (
|
||||
range as _range,
|
||||
)
|
||||
from ._base import DeviceRegistry
|
||||
|
||||
__all__ = [
|
||||
'DeviceRegistry', 'resolve', 'for_device_by_oic_type', 'for_device_by_model',
|
||||
'for_device_by_resources', '_board_tokens',
|
||||
"DeviceRegistry",
|
||||
"_board_tokens",
|
||||
"for_device_by_model",
|
||||
"for_device_by_oic_type",
|
||||
"for_device_by_resources",
|
||||
"resolve",
|
||||
]
|
||||
|
||||
|
||||
# One entry per registry, no aliases: every key here is reachable from
|
||||
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
|
||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'air_dresser': air_dresser.REGISTRY,
|
||||
'air_monitor': air_monitor.REGISTRY,
|
||||
'air_purifier': air_purifier.REGISTRY,
|
||||
'airconditioner': airconditioner.REGISTRY,
|
||||
'cooktop': cooktop.REGISTRY,
|
||||
'dehumidifier': dehumidifier.REGISTRY,
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'induction_cooktop': induction_cooktop.REGISTRY,
|
||||
'microwave': microwave.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
'range_hood': range_hood.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
'vacuum_station': vacuum_station.REGISTRY,
|
||||
'washer': washer.REGISTRY,
|
||||
'water_purifier': water_purifier.REGISTRY,
|
||||
"air_dresser": air_dresser.REGISTRY,
|
||||
"air_monitor": air_monitor.REGISTRY,
|
||||
"air_purifier": air_purifier.REGISTRY,
|
||||
"airconditioner": airconditioner.REGISTRY,
|
||||
"cooktop": cooktop.REGISTRY,
|
||||
"dehumidifier": dehumidifier.REGISTRY,
|
||||
"dishwasher": dishwasher.REGISTRY,
|
||||
"dryer": dryer.REGISTRY,
|
||||
"ehs": ehs.REGISTRY,
|
||||
"induction_cooktop": induction_cooktop.REGISTRY,
|
||||
"microwave": microwave.REGISTRY,
|
||||
"oven": oven.REGISTRY,
|
||||
"range": _range.REGISTRY,
|
||||
"range_hood": range_hood.REGISTRY,
|
||||
"refrigerator": refrigerator.REGISTRY,
|
||||
"vacuum_station": vacuum_station.REGISTRY,
|
||||
"washer": washer.REGISTRY,
|
||||
"water_purifier": water_purifier.REGISTRY,
|
||||
}
|
||||
|
||||
|
||||
# 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',
|
||||
'WF': 'washer',
|
||||
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
|
||||
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
|
||||
'DV': 'dryer',
|
||||
'DW': 'dishwasher',
|
||||
"WW": "washer",
|
||||
"WD": "washer",
|
||||
"WF": "washer",
|
||||
"WV": "washer", # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
|
||||
"WA": "washer", # Top-load washers (e.g. WA8000T) -- issue #106
|
||||
"DV": "dryer",
|
||||
"DW": "dishwasher",
|
||||
}
|
||||
|
||||
# Board-family token -> registry key, matched against whole tokens of
|
||||
# `modelNum`/`description` (see `_board_tokens`).
|
||||
#
|
||||
# Tokenizing instead of substring-matching is what keeps this a table rather
|
||||
# than a ladder of hand-written rules. Samsung spells the same board family
|
||||
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
|
||||
# same RAC family -- so a substring rule has to be written once per spelling
|
||||
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
|
||||
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
|
||||
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
|
||||
# every one of those as a single entry.
|
||||
# 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).
|
||||
'RAC': 'airconditioner',
|
||||
'PRAC': 'airconditioner',
|
||||
'KRAC': 'airconditioner',
|
||||
'WAC': 'airconditioner',
|
||||
'FAC': 'airconditioner',
|
||||
'CAWW': 'airconditioner',
|
||||
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
|
||||
'ARA': 'airconditioner',
|
||||
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
|
||||
'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
|
||||
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
|
||||
'WATERPURIFIER': 'water_purifier', # issue #90
|
||||
'ADW': 'dishwasher',
|
||||
'AHD': 'range_hood',
|
||||
'RANGE': 'range', # issue #44 -- cooktop+oven combo
|
||||
'OVEN': 'oven', # issue #55 -- wall oven, no burners
|
||||
'MICROWAVE': 'microwave', # issues #66, #121
|
||||
'COOKTOP': 'induction_cooktop', # issue #86 -- standalone, no oven
|
||||
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
|
||||
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
|
||||
# token, and so the loosest entry in this table -- it is only ever
|
||||
# reached by a device that matched nothing more specific, and its
|
||||
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
||||
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
|
||||
# for the field ordering that makes that resolve correctly.
|
||||
'CT': 'cooktop',
|
||||
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
|
||||
'DF': 'air_dresser', # issue #162
|
||||
'VSWW': 'vacuum_station', # issue #219
|
||||
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
|
||||
"REF": "refrigerator",
|
||||
# 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
|
||||
"ARA": "airconditioner",
|
||||
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
|
||||
"EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water
|
||||
"TVTL": "air_purifier", # issue #56 (ARTIK051)
|
||||
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
|
||||
# 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",
|
||||
"AHD": "range_hood",
|
||||
"RANGE": "range", # issue #44 -- cooktop+oven combo
|
||||
"OVEN": "oven", # issue #55 -- wall oven, no burners
|
||||
"MICROWAVE": "microwave", # issues #66, #121
|
||||
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
|
||||
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'): 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
|
||||
"VSWW": "vacuum_station", # issue #219
|
||||
"ASM": "air_monitor", # issue #210 -- Air Monitor Plus
|
||||
}
|
||||
|
||||
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
|
||||
_TOKEN_SPLIT_RE = re.compile(r"[^A-Z0-9]+")
|
||||
|
||||
|
||||
def _board_tokens(value: str, cut_at: str) -> list[str]:
|
||||
@@ -128,31 +144,27 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
|
||||
contain anything) and after description's first '/' (a '/DC92-...' board
|
||||
part number).
|
||||
"""
|
||||
head = (value or '').split(cut_at, 1)[0].upper()
|
||||
head = (value or "").split(cut_at, 1)[0].upper()
|
||||
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
|
||||
|
||||
|
||||
def _board_family_key(value: str, cut_at: str) -> Optional[str]:
|
||||
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:
|
||||
return 'water_purifier'
|
||||
if "REF" in tokens and "WATERPURIFIER" in tokens:
|
||||
return "water_purifier"
|
||||
for token in tokens:
|
||||
key = _BOARD_TOKEN_TO_KEY.get(token)
|
||||
if key is not None:
|
||||
@@ -160,32 +172,26 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _consumer_model_key(description: str) -> Optional[str]:
|
||||
def _consumer_model_key(description: str) -> str | None:
|
||||
"""Registry key from the consumer-model token in `description`, or 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('_')
|
||||
segments = (description or "").split("/", 1)[0].split("_")
|
||||
for segment in reversed(segments):
|
||||
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
|
||||
if key is not None:
|
||||
@@ -194,49 +200,47 @@ def _consumer_model_key(description: str) -> Optional[str]:
|
||||
|
||||
|
||||
# /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: 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.refrigerator': 'refrigerator',
|
||||
'oic.d.washer': 'washer',
|
||||
'x.com.st.d.airqualitysensor': 'air_monitor',
|
||||
'x.com.st.d.stickcleaner': 'vacuum_station',
|
||||
'x.com.st.d.steamcloset': 'air_dresser',
|
||||
"oic.d.airconditioner": "airconditioner",
|
||||
"oic.d.airpurifier": "air_purifier",
|
||||
"oic.d.dishwasher": "dishwasher",
|
||||
"oic.d.dryer": "dryer",
|
||||
"oic.d.oven": "oven",
|
||||
"oic.d.refrigerator": "refrigerator",
|
||||
"oic.d.washer": "washer",
|
||||
"x.com.st.d.airqualitysensor": "air_monitor",
|
||||
"x.com.st.d.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",
|
||||
}
|
||||
|
||||
|
||||
def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegistry]:
|
||||
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)
|
||||
@@ -245,7 +249,7 @@ def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegist
|
||||
return None
|
||||
|
||||
|
||||
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
|
||||
def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | None:
|
||||
"""Device-type detection from /information/vs/0's model strings.
|
||||
|
||||
The primary path: the board named in `modelNum` determines the resource
|
||||
@@ -275,90 +279,89 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
known type, None otherwise.
|
||||
"""
|
||||
key = (
|
||||
_board_family_key(model_num, '|')
|
||||
or _board_family_key(description, '/')
|
||||
_board_family_key(model_num, "|")
|
||||
or _board_family_key(description, "/")
|
||||
or _consumer_model_key(description)
|
||||
)
|
||||
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||
|
||||
|
||||
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
|
||||
"""Detect a device family from a distinctive local-resource signature.
|
||||
|
||||
For boards that ship no ``/information/vs/0`` at all, leaving
|
||||
`for_device_by_model` nothing to read. Some newer cooktops are the
|
||||
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
|
||||
an unrelated family's ``/mode/vs/0`` isn't misclassified.
|
||||
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', {})
|
||||
options = mode.get('x.com.samsung.da.options') or ()
|
||||
mode = resources.get("/mode/vs/0", {})
|
||||
options = mode.get("x.com.samsung.da.options") or ()
|
||||
has_device_type = any(
|
||||
isinstance(option, str) and option.startswith('DeviceType_')
|
||||
for option in options
|
||||
isinstance(option, str) and option.startswith("DeviceType_") for option in options
|
||||
)
|
||||
operation_states = sum(
|
||||
1 for option in options
|
||||
if isinstance(option, str) and option.startswith('OperationState')
|
||||
1 for option in options if isinstance(option, str) and option.startswith("OperationState")
|
||||
)
|
||||
if has_device_type and operation_states >= 2:
|
||||
return _REGISTRY_BY_KEY['cooktop']
|
||||
if (
|
||||
'/hood/fanspeed/vs/0' in resources
|
||||
and '/hood/lamp/vs/0' in resources
|
||||
):
|
||||
return _REGISTRY_BY_KEY['range_hood']
|
||||
return _REGISTRY_BY_KEY["cooktop"]
|
||||
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 signature.
|
||||
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
|
||||
if '/oven/vs/0' in resources:
|
||||
# (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 = ()
|
||||
cavity = resources.get("/oven/vs/0")
|
||||
if isinstance(cavity, dict):
|
||||
if any(
|
||||
m in supported_modes
|
||||
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
|
||||
m in supported_modes for m in ("MicroWave", "MicroWaveGrill", "MicroWaveConvection")
|
||||
):
|
||||
return _REGISTRY_BY_KEY['microwave']
|
||||
if 'Bake' in supported_modes:
|
||||
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
|
||||
return _REGISTRY_BY_KEY['range']
|
||||
return _REGISTRY_BY_KEY['oven']
|
||||
return _REGISTRY_BY_KEY["microwave"]
|
||||
if "Bake" in supported_modes:
|
||||
if "/cooktopmonitoring/vs/0" in resources or "/cooktop/status/vs/0" in resources:
|
||||
return _REGISTRY_BY_KEY["range"]
|
||||
return _REGISTRY_BY_KEY["oven"]
|
||||
return None
|
||||
|
||||
|
||||
def resolve(
|
||||
resources: dict[str, dict], device_types: Sequence[str] = (),
|
||||
) -> Optional[DeviceRegistry]:
|
||||
resources: dict[str, dict],
|
||||
device_types: Sequence[str] = (),
|
||||
) -> DeviceRegistry | None:
|
||||
"""Device type for a parsed /device/0 dump, or None if unrecognized.
|
||||
|
||||
The single entry point for detection -- the coordinator, the config
|
||||
flow's probe and the golden-regression harness all call this, so the
|
||||
order can't drift between what ships and what the tests assert.
|
||||
|
||||
`device_types` (/oic/d's `rt`, read separately from the /device/0 dump --
|
||||
see registry/identity.py) is the primary signal when present: the device
|
||||
naming its own type beats parsing board part numbers. Falls back to model
|
||||
strings (`for_device_by_model`), then a distinctive resource signature
|
||||
(`for_device_by_resources`) for boards that report no /information/vs/0
|
||||
at all -- both unchanged from before /oic/d was ever consulted, since most
|
||||
hardware still doesn't populate it usefully.
|
||||
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', {})
|
||||
info = resources.get("/information/vs/0", {})
|
||||
return (
|
||||
for_device_by_oic_type(device_types)
|
||||
for_device_by_resources(resources)
|
||||
or for_device_by_oic_type(device_types)
|
||||
or for_device_by_model(
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
info.get("x.com.samsung.da.modelNum", ""),
|
||||
info.get("x.com.samsung.da.description", ""),
|
||||
)
|
||||
or for_device_by_resources(resources)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Base DeviceRegistry dataclass and builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -9,6 +10,7 @@ from ..capability import Capability
|
||||
@dataclass(frozen=True)
|
||||
class DeviceRegistry:
|
||||
"""Registry of capabilities for a specific device type."""
|
||||
|
||||
name: str
|
||||
capabilities: dict[str, list[Capability]]
|
||||
pattern_capabilities: list[Capability] = field(default_factory=list)
|
||||
@@ -31,17 +33,19 @@ def _build(caps: list[Capability]) -> dict[str, list[Capability]]:
|
||||
|
||||
for cap in caps:
|
||||
if cap.href is None:
|
||||
raise ValueError(f"Use pattern_capabilities for href=None caps")
|
||||
raise ValueError("Use pattern_capabilities for href=None caps")
|
||||
if cap.href_prefix is not None:
|
||||
raise ValueError(
|
||||
f"href_prefix is only valid for pattern caps (href=None); "
|
||||
f"cap with href={cap.href!r} must not set href_prefix")
|
||||
f"cap with href={cap.href!r} must not set href_prefix"
|
||||
)
|
||||
out.setdefault(cap.href, []).append(cap)
|
||||
|
||||
# Validate that multi-cap hrefs have proper discrimination
|
||||
for href, cs in out.items():
|
||||
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
|
||||
raise ValueError(
|
||||
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn")
|
||||
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn"
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
@@ -8,21 +8,24 @@ expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
|
||||
pieces specific to this device type: a minimal wrinkle-prevent-only
|
||||
/washer/vs/0 capability, and the course select's own translation key.
|
||||
"""
|
||||
|
||||
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_dresser',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
air_dresser.AIR_DRESSER_SETTINGS,
|
||||
air_dresser.AIR_DRESSER_COURSE,
|
||||
air_dresser.AIR_DRESSER_SANITIZE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
]),
|
||||
name="air_dresser",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
air_dresser.AIR_DRESSER_SETTINGS,
|
||||
air_dresser.AIR_DRESSER_COURSE,
|
||||
air_dresser.AIR_DRESSER_SANITIZE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,19 +5,22 @@ board) -- no controllable state at all beyond the do-not-disturb window,
|
||||
so this registry is almost entirely sensors. No common.POWER: there's no
|
||||
`/power/*` resource on this board, only `/energy/battery/vs/0`.
|
||||
"""
|
||||
|
||||
from ..capabilities import air_monitor, common, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_monitor',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*air_monitor.COVERAGE,
|
||||
air_monitor.SENSORS,
|
||||
air_monitor.HUMIDITY,
|
||||
air_monitor.BATTERY,
|
||||
air_monitor.AIR_QUALITY_STANDARD,
|
||||
air_monitor.DND,
|
||||
]),
|
||||
name="air_monitor",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*air_monitor.COVERAGE,
|
||||
air_monitor.SENSORS,
|
||||
air_monitor.HUMIDITY,
|
||||
air_monitor.BATTERY,
|
||||
air_monitor.AIR_QUALITY_STANDARD,
|
||||
air_monitor.DND,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -22,36 +22,46 @@ 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).
|
||||
"""
|
||||
|
||||
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_purifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
air_purifier.FAN,
|
||||
air_purifier.WIND_STRENGTH_FAN,
|
||||
air_purifier.DISPLAY,
|
||||
air_purifier.HEPA_FILTER,
|
||||
air_purifier.PANEL_STATUS,
|
||||
air_purifier.PET_FILTER_ACTIVATION,
|
||||
air_purifier.SOUND_MODE,
|
||||
air_purifier.SOUND_OUTPUT,
|
||||
air_purifier.SOUND_VOLUME,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
*air_purifier.COVERAGE,
|
||||
]),
|
||||
name="air_purifier",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.AIR_LEVEL_CHECK,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
air_purifier.FAN,
|
||||
air_purifier.WIND_STRENGTH_FAN,
|
||||
air_purifier.DISPLAY,
|
||||
air_purifier.HEPA_FILTER,
|
||||
air_purifier.PANEL_STATUS,
|
||||
air_purifier.PET_FILTER_ACTIVATION,
|
||||
air_purifier.SOUND_MODE,
|
||||
air_purifier.SOUND_OUTPUT,
|
||||
air_purifier.SOUND_VOLUME,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
*air_purifier.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -16,31 +16,37 @@ uses unconditionally.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
"""
|
||||
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='airconditioner',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
|
||||
airconditioner.ENERGY_METER_GENERIC,
|
||||
airconditioner.ENERGY_METER_LEGACY,
|
||||
dishwasher.DIAGNOSIS,
|
||||
airconditioner.CLIMATE,
|
||||
airconditioner.AIR_PURIFY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.AIR_QUALITY,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
airconditioner.ANOMALY_LOAD,
|
||||
airconditioner.ABSENCE_POWER_SAVING,
|
||||
airconditioner.MOTION_DETECT_WIND,
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
*airconditioner.COVERAGE,
|
||||
]),
|
||||
name="airconditioner",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
|
||||
airconditioner.ENERGY_METER_GENERIC,
|
||||
airconditioner.ENERGY_METER_LEGACY,
|
||||
dishwasher.DIAGNOSIS,
|
||||
airconditioner.CLIMATE,
|
||||
airconditioner.AIR_PURIFY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.AIR_FILTER_PM1,
|
||||
airconditioner.AIR_QUALITY,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.UV_LED,
|
||||
airconditioner.VENTILATION_ALARM,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
airconditioner.ANOMALY_LOAD,
|
||||
airconditioner.ABSENCE_POWER_SAVING,
|
||||
airconditioner.MOTION_DETECT_WIND,
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
*airconditioner.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,15 +12,16 @@ key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
|
||||
from ..capabilities import common, cooktop, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='gas_cooktop',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
cooktop.COOKTOP_POWER,
|
||||
cooktop.COOKTOP_MODE,
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
]),
|
||||
name="gas_cooktop",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
cooktop.COOKTOP_POWER,
|
||||
cooktop.COOKTOP_MODE,
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,22 +2,30 @@
|
||||
|
||||
Shares the DA_AC_ board family with airconditioner.py (power, air filter,
|
||||
auto-clean, mute-once all use the identical resource shapes), so those three
|
||||
Capability objects are reused directly rather than duplicated.
|
||||
Capability objects are reused directly rather than duplicated. The
|
||||
TP1X_DA_AC_DHM_01001_0000 revision (issues #271/#231) also reports
|
||||
air_purifier.py's screen-on/off resource on the identical href/shape, so
|
||||
that's reused too rather than re-defined.
|
||||
"""
|
||||
from ..capabilities import airconditioner, common, dehumidifier, ignored
|
||||
|
||||
from ..capabilities import air_purifier, airconditioner, common, dehumidifier, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dehumidifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dehumidifier.MODE,
|
||||
dehumidifier.HUMIDITY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.MUTE_ONCE,
|
||||
*dehumidifier.COVERAGE,
|
||||
]),
|
||||
name="dehumidifier",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dehumidifier.MODE,
|
||||
dehumidifier.HUMIDITY,
|
||||
dehumidifier.WATERTANK_LIGHTING,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.MUTE_ONCE,
|
||||
air_purifier.DISPLAY,
|
||||
*dehumidifier.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"""Dishwasher device registry."""
|
||||
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dishwasher',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
common.WATER_METER,
|
||||
common.WATER_FILTER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.CYCLE_OPTIONS,
|
||||
dishwasher.DISHWASHER_SETTINGS,
|
||||
dishwasher.DIAGNOSIS,
|
||||
dishwasher.OPERATION_ORIGIN,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.SOUND_VOLUME,
|
||||
]),
|
||||
name="dishwasher",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
common.WATER_METER,
|
||||
common.WATER_FILTER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.CYCLE_OPTIONS,
|
||||
dishwasher.DISHWASHER_SETTINGS,
|
||||
dishwasher.DIAGNOSIS,
|
||||
dishwasher.OPERATION_ORIGIN,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.SOUND_VOLUME,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,22 +9,25 @@ objects the washer registry uses -- washer and dryer expose the same
|
||||
DA_WM_-family surface, so they stay consistent instead of each carrying a
|
||||
bespoke variant.
|
||||
"""
|
||||
|
||||
from ..capabilities import common, dryer, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dryer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
dryer.DRYER_SETTINGS,
|
||||
dryer.DRYER_COURSE,
|
||||
dryer.DRYER_DIAGNOSIS,
|
||||
]),
|
||||
name="dryer",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
dryer.DRYER_SETTINGS,
|
||||
dryer.DRYER_COURSE,
|
||||
dryer.DRYER_DIAGNOSIS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""EHS (Eco Heating System) air-to-water heat pump device registry
|
||||
(Samsung TP1X_DA_AC_EHS-class).
|
||||
|
||||
Shares the DA_AC_ board prefix with the room-AC family in
|
||||
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
|
||||
are its own shape (two independent loops: zone1 space heating/cooling and
|
||||
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
|
||||
pattern -- so nothing from that module is reused here except MUTE_ONCE,
|
||||
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
|
||||
family's dump.
|
||||
"""
|
||||
|
||||
from ..capabilities import airconditioner, common, ehs, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name="ehs",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
airconditioner.MUTE_ONCE,
|
||||
ehs.ZONE_POWER,
|
||||
ehs.ZONE_MODE,
|
||||
ehs.ZONE_TEMPERATURE,
|
||||
ehs.DHW,
|
||||
*ehs.DHW_CONSUMED,
|
||||
ehs.AWAY_MODE,
|
||||
*ehs.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -11,20 +11,24 @@ units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
|
||||
embedded in /mode/vs/0's options array, a completely different OCF
|
||||
surface that happens to share the English word "cooktop").
|
||||
"""
|
||||
from ..capabilities import common, cooktop as cooktop_caps, ignored
|
||||
|
||||
from ..capabilities import common, ignored
|
||||
from ..capabilities import cooktop as cooktop_caps
|
||||
from ..capabilities import range as range_caps
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='induction_cooktop',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
range_caps.PROBE_STATUS,
|
||||
cooktop_caps.PAIRED_HOOD_STATUS,
|
||||
]),
|
||||
name="induction_cooktop",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
range_caps.PROBE_STATUS,
|
||||
cooktop_caps.PAIRED_HOOD_STATUS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -16,22 +16,25 @@ has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
|
||||
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
|
||||
case (see its `_speed_zero_is_off` check).
|
||||
"""
|
||||
|
||||
from ..capabilities import common, ignored, microwave, oven, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='microwave',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
microwave.MICROWAVE_CAVITY,
|
||||
microwave.MICROWAVE_SETPOINT,
|
||||
microwave.MICROWAVE_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
range_hood.HOOD_FAN,
|
||||
]),
|
||||
name="microwave",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
microwave.MICROWAVE_CAVITY,
|
||||
microwave.MICROWAVE_SETPOINT,
|
||||
microwave.MICROWAVE_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
range_hood.HOOD_FAN,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"""Oven device registry."""
|
||||
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='oven',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
]),
|
||||
name="oven",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,26 +5,29 @@ connected capabilities wholesale (a range's oven half is the same OCF
|
||||
surface as a standalone oven) and adds the cooktop-specific capabilities
|
||||
for the burner half.
|
||||
"""
|
||||
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ..capabilities import range as range_caps
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='range',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
range_caps.COOKTOP_MONITORING,
|
||||
]),
|
||||
name="range",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
range_caps.COOKTOP_MONITORING,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,21 +3,22 @@
|
||||
from ..capabilities import common, ignored, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='range_hood',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
range_hood.HOOD_ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.FIRMWARE_UPDATE,
|
||||
range_hood.AFTER_RUN,
|
||||
range_hood.HOOD_FAN,
|
||||
range_hood.HOOD_LAMP,
|
||||
range_hood.HOOD_FILTER,
|
||||
range_hood.AIR_QUALITY,
|
||||
range_hood.AIR_LEVEL_CHECK,
|
||||
range_hood.AUTO_VENTILATION,
|
||||
*range_hood.COVERAGE,
|
||||
]),
|
||||
name="range_hood",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
range_hood.HOOD_ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.FIRMWARE_UPDATE,
|
||||
range_hood.AFTER_RUN,
|
||||
range_hood.HOOD_FAN,
|
||||
range_hood.HOOD_LAMP,
|
||||
range_hood.HOOD_FILTER,
|
||||
range_hood.AIR_QUALITY,
|
||||
range_hood.AIR_LEVEL_CHECK,
|
||||
range_hood.AUTO_VENTILATION,
|
||||
*range_hood.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
"""Refrigerator device registry."""
|
||||
|
||||
from ..capabilities import common, dishwasher, fridge, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='refrigerator',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.WATER_FILTER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
fridge.REFRIGERATION,
|
||||
fridge.AUTOFILL,
|
||||
fridge.WELCOME_LIGHTING,
|
||||
fridge.CABINET_LIGHT,
|
||||
fridge.CABINET_LIGHT_ENHANCED,
|
||||
fridge.SABBATH,
|
||||
fridge.BEVERAGE_ZONE,
|
||||
fridge.PANTRY_ZONE,
|
||||
fridge.DEFROST_DELAY,
|
||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||
fridge.DEFROST_BLOCK_STATUS,
|
||||
fridge.DEFINITE_TEMPERATURE_COOLER,
|
||||
fridge.DOORS_FALLBACK,
|
||||
fridge.TEMPERATURES_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
|
||||
fridge.REFRIGERATION_FALLBACK,
|
||||
]),
|
||||
name="refrigerator",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.WATER_FILTER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
fridge.REFRIGERATION,
|
||||
fridge.AUTOFILL,
|
||||
fridge.WELCOME_LIGHTING,
|
||||
fridge.CABINET_LIGHT,
|
||||
fridge.CABINET_LIGHT_ENHANCED,
|
||||
fridge.SABBATH,
|
||||
fridge.BEVERAGE_ZONE,
|
||||
fridge.PANTRY_ZONE,
|
||||
fridge.DEFROST_DELAY,
|
||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||
fridge.DEFROST_BLOCK_STATUS,
|
||||
fridge.DEFINITE_TEMPERATURE_COOLER,
|
||||
fridge.DEFINITE_TEMPERATURE_FREEZER,
|
||||
fridge.DOORS_FALLBACK,
|
||||
fridge.TEMPERATURES_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
|
||||
fridge.REFRIGERATION_FALLBACK,
|
||||
]
|
||||
),
|
||||
pattern_capabilities=[
|
||||
fridge.TEMP_CURRENT_GENERIC,
|
||||
fridge.TEMP_SETPOINT,
|
||||
|
||||
@@ -5,18 +5,21 @@ covers the station's own dustbag/dustbin/UV-sanitize state and not any
|
||||
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
|
||||
dump this was built from reports none of that.
|
||||
"""
|
||||
|
||||
from ..capabilities import common, ignored, vacuum_station
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='vacuum_station',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
vacuum_station.DUSTBAG,
|
||||
vacuum_station.DUSTBAG_USAGE,
|
||||
vacuum_station.DUSTBIN_SETTING,
|
||||
vacuum_station.CLEANSTATION_STATUS,
|
||||
]),
|
||||
name="vacuum_station",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
vacuum_station.DUSTBAG,
|
||||
vacuum_station.DUSTBAG_USAGE,
|
||||
vacuum_station.DUSTBIN_SETTING,
|
||||
vacuum_station.CLEANSTATION_STATUS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
"""Washer device registry."""
|
||||
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='washer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
washer.WASHER_SETTINGS,
|
||||
washer.WASHER_COURSE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
]),
|
||||
name="washer",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
washer.WASHER_SETTINGS,
|
||||
washer.WASHER_COURSE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90).
|
||||
"""
|
||||
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90)."""
|
||||
|
||||
from ..capabilities import common, ignored, water_purifier
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='water_purifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
common.WATER_FILTER,
|
||||
water_purifier.DISPENSE,
|
||||
water_purifier.STATUS,
|
||||
water_purifier.FAVORITE_CAPACITY,
|
||||
water_purifier.FAVORITE_HOTWATER,
|
||||
water_purifier.COFFEE,
|
||||
water_purifier.LOCK,
|
||||
water_purifier.CUP_STATE,
|
||||
water_purifier.SOUND_MODE,
|
||||
water_purifier.SOUND_OUTPUT,
|
||||
water_purifier.SOUND_VOLUME,
|
||||
water_purifier.STATISTIC_POUR,
|
||||
*water_purifier.COVERAGE,
|
||||
]),
|
||||
name="water_purifier",
|
||||
capabilities=_build(
|
||||
[
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
common.WATER_FILTER,
|
||||
water_purifier.DISPENSE,
|
||||
water_purifier.STATUS,
|
||||
water_purifier.FAVORITE_CAPACITY,
|
||||
water_purifier.FAVORITE_HOTWATER,
|
||||
water_purifier.COFFEE,
|
||||
water_purifier.LOCK,
|
||||
water_purifier.CUP_STATE,
|
||||
water_purifier.SOUND_MODE,
|
||||
water_purifier.SOUND_OUTPUT,
|
||||
water_purifier.SOUND_VOLUME,
|
||||
water_purifier.STATISTIC_POUR,
|
||||
*water_purifier.COVERAGE,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from . import (
|
||||
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
|
||||
range_hood,
|
||||
)
|
||||
from ..capability import Capability
|
||||
from . import (
|
||||
common,
|
||||
fridge,
|
||||
ignored,
|
||||
laundry,
|
||||
operational,
|
||||
oven,
|
||||
)
|
||||
|
||||
|
||||
def _is_capability(v):
|
||||
@@ -18,5 +22,13 @@ _OVEN_GLOBAL_CAPS = [
|
||||
oven.OVEN_CAVITY,
|
||||
]
|
||||
|
||||
ALL = [v for mod in (common, operational, laundry, fridge)
|
||||
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
|
||||
ALL = (
|
||||
[
|
||||
v
|
||||
for mod in (common, operational, laundry, fridge)
|
||||
for v in vars(mod).values()
|
||||
if _is_capability(v)
|
||||
]
|
||||
+ _OVEN_GLOBAL_CAPS
|
||||
+ ignored.IGNORED
|
||||
)
|
||||
|
||||
@@ -32,49 +32,59 @@ machinery:
|
||||
/airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only;
|
||||
#162's board doesn't report this resource at all)
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import SwitchDesc
|
||||
from .laundry import cycle_select
|
||||
|
||||
|
||||
def _wrinkle_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
||||
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
|
||||
|
||||
|
||||
def _sanitize_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p}
|
||||
return ["airdresseroption", "sanitize", "vs", "0"], {"x.com.samsung.da.sanitize": p}
|
||||
|
||||
|
||||
AIR_DRESSER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/washer/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
||||
icon='mdi:iron',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_wrinkle_write),
|
||||
SwitchDesc(
|
||||
key="wrinkle_prevent",
|
||||
field="x.com.samsung.da.wrinklePrevent",
|
||||
icon="mdi:iron",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_wrinkle_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_DRESSER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew',
|
||||
table_href='/st/airdressercourse/vs/0'),
|
||||
cycle_select(
|
||||
translation_key="air_dresser_cycle",
|
||||
icon="mdi:tshirt-crew",
|
||||
table_href="/st/airdressercourse/vs/0",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_DRESSER_SANITIZE = Capability(
|
||||
href='/airdresseroption/sanitize/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/airdresseroption/sanitize/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
||||
icon='mdi:weather-sunny',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_sanitize_write),
|
||||
SwitchDesc(
|
||||
key="sanitize",
|
||||
field="x.com.samsung.da.sanitize",
|
||||
icon="mdi:weather-sunny",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_sanitize_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,25 +12,18 @@ 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
|
||||
|
||||
from ..capability import Capability
|
||||
@@ -38,54 +31,78 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
from .air_purifier import _AIR_QUALITY_SENSORS
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
|
||||
SENSORS = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
|
||||
state_class='measurement',
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
) + (
|
||||
SensorDesc(key='co2', field='x.com.samsung.da.items',
|
||||
device_class='carbon_dioxide', state_class='measurement',
|
||||
unit='ppm',
|
||||
value_fn=lambda items: sensor_item_value(items, 'CO2')),
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
*(
|
||||
SensorDesc(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class="measurement",
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="carbon_dioxide",
|
||||
state_class="measurement",
|
||||
unit="ppm",
|
||||
value_fn=lambda items: sensor_item_value(items, "CO2"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
HUMIDITY = Capability(
|
||||
href='/humidity/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/humidity/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
|
||||
device_class='humidity', state_class='measurement',
|
||||
unit='%', value_fn=int_or_none),
|
||||
SensorDesc(
|
||||
key="humidity",
|
||||
field="x.com.samsung.da.humidity",
|
||||
device_class="humidity",
|
||||
state_class="measurement",
|
||||
unit="%",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
BATTERY = Capability(
|
||||
href='/energy/battery/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/energy/battery/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='battery', field='x.com.samsung.da.battery',
|
||||
device_class='battery', state_class='measurement',
|
||||
unit='%', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
BinarySensorDesc(key='battery_charging', field='x.com.samsung.da.charging',
|
||||
device_class='battery_charging',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
SensorDesc(
|
||||
key="battery",
|
||||
field="x.com.samsung.da.battery",
|
||||
device_class="battery",
|
||||
state_class="measurement",
|
||||
unit="%",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="battery_charging",
|
||||
field="x.com.samsung.da.charging",
|
||||
device_class="battery_charging",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v == "On",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_QUALITY_STANDARD = Capability(
|
||||
href='/airqualitystandard/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/airqualitystandard/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='air_quality_standard', field='x.com.samsung.da.standard',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="air_quality_standard",
|
||||
field="x.com.samsung.da.standard",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -96,7 +113,7 @@ def _parse_hms(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
parts = v.split(':')
|
||||
parts = v.split(":")
|
||||
return dt_time(int(parts[0]), int(parts[1]))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
@@ -104,38 +121,46 @@ def _parse_hms(v):
|
||||
|
||||
def _dnd_time_write(field):
|
||||
def _write(p, rep, href=None):
|
||||
return ['dnd', 'vs', '0'], {field: f'{p.hour:02d}:{p.minute:02d}:00'}
|
||||
return ["dnd", "vs", "0"], {field: f"{p.hour:02d}:{p.minute:02d}:00"}
|
||||
|
||||
return _write
|
||||
|
||||
|
||||
# Issue #210: no idle-vs-active dump pair exists for this href (only one
|
||||
# dump total, DND never toggled in it), so this write contract is an
|
||||
# educated guess, not a confirmed one -- symmetric with the read side
|
||||
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
|
||||
# device itself reports back) rather than invented from nothing, but still
|
||||
# needs a reporter to actually flip it on real hardware and confirm. See
|
||||
# the adding-device-support skill's "Educated guesses are fine" section.
|
||||
# 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',
|
||||
href="/dnd/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(key='dnd', field='x.com.samsung.da.value',
|
||||
icon='mdi:sleep',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'true',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dnd', 'vs', '0'],
|
||||
{'x.com.samsung.da.value': 'true' if p == 'On' else 'false'})),
|
||||
TimeDesc(key='dnd_start', field='x.com.samsung.da.startTime',
|
||||
icon='mdi:clock-start',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hms,
|
||||
write_fn=_dnd_time_write('x.com.samsung.da.startTime')),
|
||||
TimeDesc(key='dnd_end', field='x.com.samsung.da.endTime',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hms,
|
||||
write_fn=_dnd_time_write('x.com.samsung.da.endTime')),
|
||||
SwitchDesc(
|
||||
key="dnd",
|
||||
field="x.com.samsung.da.value",
|
||||
icon="mdi:sleep",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "true",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["dnd", "vs", "0"],
|
||||
{"x.com.samsung.da.value": "true" if p == "On" else "false"},
|
||||
),
|
||||
),
|
||||
TimeDesc(
|
||||
key="dnd_start",
|
||||
field="x.com.samsung.da.startTime",
|
||||
icon="mdi:clock-start",
|
||||
entity_category="config",
|
||||
value_fn=_parse_hms,
|
||||
write_fn=_dnd_time_write("x.com.samsung.da.startTime"),
|
||||
),
|
||||
TimeDesc(
|
||||
key="dnd_end",
|
||||
field="x.com.samsung.da.endTime",
|
||||
icon="mdi:clock-end",
|
||||
entity_category="config",
|
||||
value_fn=_parse_hms,
|
||||
write_fn=_dnd_time_write("x.com.samsung.da.endTime"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -146,10 +171,10 @@ DND = Capability(
|
||||
_AM_IGNORED = [
|
||||
# A single bare integer ('keepnormal': 0) with no description, no
|
||||
# supported-values list, and no second dump to compare against -- opaque.
|
||||
'/keepnormalstate/vs/0',
|
||||
"/keepnormalstate/vs/0",
|
||||
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
|
||||
# live user-facing field.
|
||||
'/sensordatasinks/vs/0',
|
||||
"/sensordatasinks/vs/0",
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
|
||||
|
||||
@@ -1,99 +1,74 @@
|
||||
"""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, FanDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
BinarySensorDesc,
|
||||
FanDesc,
|
||||
NumberDesc,
|
||||
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.
|
||||
HREF_MODE = '/mode/vs/0'
|
||||
HREF_AIRFLOW = '/airflow/0'
|
||||
HREF_WIND_STRENGTH = '/wind/strength/vs/0'
|
||||
# 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"
|
||||
|
||||
|
||||
def _has_top_level_modes(rep, resources):
|
||||
return isinstance(rep.get('x.com.samsung.da.supportedModes'), (list, tuple))
|
||||
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
|
||||
|
||||
|
||||
_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"),
|
||||
("fine_dust", "mdi:blur", "FineDust"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust"),
|
||||
("odor", "mdi:scent", "Odor"),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel"),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
|
||||
SensorDesc(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
@@ -103,363 +78,584 @@ def _consumable_state(items, name):
|
||||
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
|
||||
unlike AIR_QUALITY's {type, value} shape above."""
|
||||
for item in items or ():
|
||||
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
|
||||
return item.get('x.com.samsung.da.state')
|
||||
if isinstance(item, dict) and item.get("x.com.samsung.da.name") == name:
|
||||
return item.get("x.com.samsung.da.state")
|
||||
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',
|
||||
href="/consumable/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda items: int_or_none(
|
||||
_consumable_state(items, 'FilterProgress'))),
|
||||
SensorDesc(
|
||||
key="filter_progress",
|
||||
field="x.com.samsung.da.items",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda items: int_or_none(_consumable_state(items, "FilterProgress")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
DEVICE_ACTIVE = Capability(
|
||||
href='/devicespecificinfo/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/devicespecificinfo/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
|
||||
icon='mdi:check-network-outline',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v)),
|
||||
BinarySensorDesc(
|
||||
key="device_active",
|
||||
field="x.com.samsung.da.deviceActive",
|
||||
icon="mdi:check-network-outline",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: bool(v),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
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'})
|
||||
"""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"})
|
||||
|
||||
|
||||
def _airflow_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'speed':
|
||||
return ['airflow', '0'], {'speed': int(value)}
|
||||
if kind == "power":
|
||||
return _power_write(args[0] if args else "/power/vs/0", value)
|
||||
if kind == "speed":
|
||||
return ["airflow", "0"], {"speed": int(value)}
|
||||
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',
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
FanDesc(key="airflow_fan", field="speed", write_fn=_airflow_fan_write),
|
||||
SensorDesc(
|
||||
key="fan_direction",
|
||||
field="direction",
|
||||
icon="mdi:rotate-3d-variant",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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,
|
||||
poll_tier='warm',
|
||||
href="/airflow/vs/0",
|
||||
match_fn=lambda rep, resources: "/airflow/0" not in resources,
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
|
||||
icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="fan_speed_level",
|
||||
field="x.com.samsung.da.speedLevel",
|
||||
icon="mdi:fan",
|
||||
state_class="measurement",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="fan_direction",
|
||||
field="x.com.samsung.da.direction",
|
||||
icon="mdi:rotate-3d-variant",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Light', payload),
|
||||
# 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),
|
||||
}
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
match_fn=lambda rep, resources: not _has_top_level_modes(rep, resources),
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
rep_fn=bool_option_value('Light'),
|
||||
exists_fn=bool_option_exists('Light'),
|
||||
write_fn=_light_write),
|
||||
SwitchDesc(
|
||||
key="display_light",
|
||||
icon="mdi:led-on",
|
||||
entity_category="config",
|
||||
rep_fn=bool_option_value("Light"),
|
||||
exists_fn=bool_option_exists("Light"),
|
||||
write_fn=_light_write,
|
||||
),
|
||||
# Read-only -- confirmed NOT the fan-speed selector (see module
|
||||
# docstring), actual purpose still unconfirmed.
|
||||
SensorDesc(key='operating_mode', icon='mdi:fan',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
|
||||
exists_fn=bool_option_exists('Comode')),
|
||||
SensorDesc(
|
||||
key="operating_mode",
|
||||
icon="mdi:fan",
|
||||
entity_category="diagnostic",
|
||||
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Comode"),
|
||||
exists_fn=bool_option_exists("Comode"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'mode':
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
|
||||
if kind == "power":
|
||||
return _power_write(args[0] if args else "/power/vs/0", value)
|
||||
if kind == "mode":
|
||||
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [value]}
|
||||
return 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."""
|
||||
modes = rep.get('x.com.samsung.da.modes')
|
||||
"""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
|
||||
return modes
|
||||
|
||||
|
||||
# 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',
|
||||
poll_tier="warm",
|
||||
match_fn=_has_top_level_modes,
|
||||
entities=(
|
||||
FanDesc(key='fan', translation_key='air_purifier_fan',
|
||||
rep_fn=_first_fan_mode, write_fn=_fan_write),
|
||||
FanDesc(
|
||||
key="fan",
|
||||
translation_key="air_purifier_fan",
|
||||
rep_fn=_first_fan_mode,
|
||||
write_fn=_fan_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _wind_strength_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'mode':
|
||||
return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}
|
||||
if kind == "power":
|
||||
return _power_write(args[0] if args else "/power/vs/0", value)
|
||||
if kind == "mode":
|
||||
return ["wind", "strength", "vs", "0"], {"x.com.samsung.da.modes": value}
|
||||
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',
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan',
|
||||
field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write),
|
||||
FanDesc(
|
||||
key="wind_strength_fan",
|
||||
translation_key="air_purifier_fan",
|
||||
field="x.com.samsung.da.modes",
|
||||
write_fn=_wind_strength_fan_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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',
|
||||
href="/display/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(key='display', field='mode',
|
||||
icon='mdi:monitor',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['display', 'vs', '0'],
|
||||
{'mode': 'On' if p == 'On' else 'Off'})),
|
||||
SwitchDesc(
|
||||
key="display",
|
||||
field="mode",
|
||||
icon="mdi:monitor",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["display", "vs", "0"],
|
||||
{"mode": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
href="/filter/hepafilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='hepa_filter_usage', rep_fn=filter_usage_percent,
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
SensorDesc(key='hepa_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),
|
||||
SensorDesc(
|
||||
key="hepa_filter_usage",
|
||||
rep_fn=filter_usage_percent,
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="hepa_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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
href="/panel/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='panel_status', field='status',
|
||||
icon='mdi:archive-outline', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="panel_status",
|
||||
field="status",
|
||||
icon="mdi:archive-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Pet-care filter mode -- a plain On/Off field with no vendor prefix, same
|
||||
# convention as airconditioner.MUTE_ONCE.
|
||||
PET_FILTER_ACTIVATION = Capability(
|
||||
href='/petfilteractivation/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/petfilteractivation/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(key='pet_filter_activation', field='status',
|
||||
icon='mdi:paw',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['petfilteractivation', 'vs', '0'],
|
||||
{'status': 'On' if p == 'On' else 'Off'})),
|
||||
SwitchDesc(
|
||||
key="pet_filter_activation",
|
||||
field="status",
|
||||
icon="mdi:paw",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["petfilteractivation", "vs", "0"],
|
||||
{"status": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
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.
|
||||
SelectDesc(key='sound_mode', translation_key='air_purifier_sound_mode',
|
||||
field='mode',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedModes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
|
||||
# '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",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "mode", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Read-only descriptor of which sound output the unit has -- only one value
|
||||
# seen, no alternatives to select between.
|
||||
SOUND_OUTPUT = Capability(
|
||||
href='/settings/sound/output/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/settings/sound/output/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='sound_output', field='deviceType',
|
||||
icon='mdi:volume-high', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="sound_output",
|
||||
field="deviceType",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/settings/sound/volume/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
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,
|
||||
value_fn=int_or_none,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'volume', 'vs', '0'],
|
||||
{'level': str(int(p))})),
|
||||
NumberDesc(
|
||||
key="sound_volume",
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
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,
|
||||
value_fn=int_or_none,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "volume", "vs", "0"],
|
||||
{"level": str(int(p))},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# /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.
|
||||
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.
|
||||
Capability(href='/mode/convenient/vs/0'),
|
||||
Capability(href="/humidity/0"),
|
||||
Capability(href="/humidity/vs/0"),
|
||||
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 -- 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 lives in WIND_STRENGTH_FAN instead.
|
||||
Capability(href="/mode/convenient/vs/0"),
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,16 +13,15 @@ from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc
|
||||
|
||||
|
||||
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
|
||||
_INACTIVE_OPERATION_STATES = {"Off", "Ready"}
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Return the value from the first ``<prefix>_<value>`` option."""
|
||||
marker = prefix + '_'
|
||||
marker = prefix + "_"
|
||||
for option in options or ():
|
||||
if isinstance(option, str) and option.startswith(marker):
|
||||
return option[len(marker):]
|
||||
return option[len(marker) :]
|
||||
return None
|
||||
|
||||
|
||||
@@ -32,7 +31,7 @@ def _operation_slots(options) -> tuple[int, ...]:
|
||||
for option in options or ():
|
||||
if not isinstance(option, str):
|
||||
continue
|
||||
match = re.match(r'^OperationState(\d+)_', option)
|
||||
match = re.match(r"^OperationState(\d+)_", option)
|
||||
if match:
|
||||
slots.add(int(match.group(1)))
|
||||
return tuple(sorted(slots))
|
||||
@@ -40,10 +39,7 @@ def _operation_slots(options) -> tuple[int, ...]:
|
||||
|
||||
def _any_burner_active(options):
|
||||
"""True when any advertised burner slot is not idle."""
|
||||
states = [
|
||||
_option_value(options, f'OperationState{slot}')
|
||||
for slot in _operation_slots(options)
|
||||
]
|
||||
states = [_option_value(options, f"OperationState{slot}") for slot in _operation_slots(options)]
|
||||
states = [state for state in states if state is not None]
|
||||
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
|
||||
|
||||
@@ -56,15 +52,15 @@ def _int_or_none(value):
|
||||
|
||||
|
||||
COOKTOP_POWER = Capability(
|
||||
href='/power/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/power/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='power_state',
|
||||
field='x.com.samsung.da.power',
|
||||
device_class='power',
|
||||
icon='mdi:stove',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="power_state",
|
||||
field="x.com.samsung.da.power",
|
||||
device_class="power",
|
||||
icon="mdi:stove",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -77,109 +73,107 @@ COOKTOP_POWER = Capability(
|
||||
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
|
||||
|
||||
COOKTOP_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='any_burner_active',
|
||||
field='x.com.samsung.da.options',
|
||||
device_class='running',
|
||||
icon='mdi:fire',
|
||||
key="any_burner_active",
|
||||
field="x.com.samsung.da.options",
|
||||
device_class="running",
|
||||
icon="mdi:fire",
|
||||
value_fn=_any_burner_active,
|
||||
),
|
||||
*(
|
||||
SensorDesc(
|
||||
key=f'burner_{slot}_state',
|
||||
field='x.com.samsung.da.options',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(slot)},
|
||||
icon='mdi:gas-burner',
|
||||
value_fn=lambda options, slot=slot: _option_value(
|
||||
options, f'OperationState{slot}'
|
||||
),
|
||||
key=f"burner_{slot}_state",
|
||||
field="x.com.samsung.da.options",
|
||||
translation_key="burner_state",
|
||||
translation_placeholders={"number": str(slot)},
|
||||
icon="mdi:gas-burner",
|
||||
value_fn=lambda options, slot=slot: _option_value(options, f"OperationState{slot}"),
|
||||
exists_fn=lambda rep, resources, slot=slot: (
|
||||
is_stub_rep(rep) or _option_value(
|
||||
rep.get('x.com.samsung.da.options'),
|
||||
f'OperationState{slot}',
|
||||
) is not None
|
||||
is_stub_rep(rep)
|
||||
or _option_value(
|
||||
rep.get("x.com.samsung.da.options"),
|
||||
f"OperationState{slot}",
|
||||
)
|
||||
is not None
|
||||
),
|
||||
)
|
||||
for slot in _SUPPORTED_OPERATION_SLOTS
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_state',
|
||||
field='x.com.samsung.da.options',
|
||||
icon='mdi:timer-outline',
|
||||
value_fn=lambda options: _option_value(options, 'MainTimerState'),
|
||||
key="main_timer_state",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:timer-outline",
|
||||
value_fn=lambda options: _option_value(options, "MainTimerState"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_current',
|
||||
field='x.com.samsung.da.options',
|
||||
icon='mdi:timer-sand',
|
||||
key="main_timer_current",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:timer-sand",
|
||||
enabled_default=False,
|
||||
value_fn=lambda options: _int_or_none(
|
||||
_option_value(options, 'MainTimerCurrent')
|
||||
),
|
||||
value_fn=lambda options: _int_or_none(_option_value(options, "MainTimerCurrent")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
COOKTOP_CONNECTED = Capability(
|
||||
href='/connected/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/connected/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='cloud_connected',
|
||||
field='x.com.samsung.da.connected',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="cloud_connected",
|
||||
field="x.com.samsung.da.connected",
|
||||
device_class="connectivity",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
PAIRED_HOOD_STATUS = Capability(
|
||||
href='/bluetooth/hood/status/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/bluetooth/hood/status/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_connected',
|
||||
field='connectionState',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda value: str(value).lower() == 'connected',
|
||||
key="paired_hood_connected",
|
||||
field="connectionState",
|
||||
device_class="connectivity",
|
||||
value_fn=lambda value: str(value).lower() == "connected",
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_power',
|
||||
field='power',
|
||||
device_class='running',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="paired_hood_power",
|
||||
field="power",
|
||||
device_class="running",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_fan_speed',
|
||||
field='fanSpeed',
|
||||
icon='mdi:fan',
|
||||
key="paired_hood_fan_speed",
|
||||
field="fanSpeed",
|
||||
icon="mdi:fan",
|
||||
value_fn=_int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_light',
|
||||
field='lampState',
|
||||
device_class='light',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="paired_hood_light",
|
||||
field="lampState",
|
||||
device_class="light",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_model',
|
||||
field='micomModelId',
|
||||
icon='mdi:information-outline',
|
||||
entity_category='diagnostic',
|
||||
key="paired_hood_model",
|
||||
field="micomModelId",
|
||||
icon="mdi:information-outline",
|
||||
entity_category="diagnostic",
|
||||
enabled_default=False,
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_firmware',
|
||||
field='firmwareVersion',
|
||||
icon='mdi:chip',
|
||||
entity_category='diagnostic',
|
||||
key="paired_hood_firmware",
|
||||
field="firmwareVersion",
|
||||
icon="mdi:chip",
|
||||
entity_category="diagnostic",
|
||||
enabled_default=False,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,79 +7,141 @@ humidity -- not temperature -- is this device's primary control, and there
|
||||
is no climate composite: power, mode, and humidity are exposed as separate
|
||||
entities rather than folded into one card.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
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."""
|
||||
modes = rep.get('x.com.samsung.da.modes')
|
||||
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
|
||||
return modes
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(key='operating_mode', rep_fn=_first_mode,
|
||||
icon='mdi:tune-variant',
|
||||
options_field='x.com.samsung.da.supportedModes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
|
||||
SelectDesc(
|
||||
key="operating_mode",
|
||||
rep_fn=_first_mode,
|
||||
icon="mdi:tune-variant",
|
||||
options_field="x.com.samsung.da.supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.modes": [p]},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
href="/humidity/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
|
||||
device_class='humidity', unit='%', state_class='measurement',
|
||||
value_fn=int_or_none),
|
||||
NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity',
|
||||
device_class='humidity', unit='%',
|
||||
icon='mdi:water-percent',
|
||||
entity_category='config',
|
||||
value_fn=int_or_none,
|
||||
step_fn=lambda rep: int_or_none(rep.get('increment')) or 1,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['humidity', 'vs', '0'],
|
||||
{'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})),
|
||||
SensorDesc(
|
||||
key="humidity",
|
||||
field="x.com.samsung.da.humidity",
|
||||
device_class="humidity",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
NumberDesc(
|
||||
key="target_humidity",
|
||||
field="x.com.samsung.da.desiredHumidity",
|
||||
device_class="humidity",
|
||||
unit="%",
|
||||
icon="mdi:water-percent",
|
||||
entity_category="config",
|
||||
value_fn=int_or_none,
|
||||
step_fn=lambda rep: int_or_none(rep.get("increment")) or 1,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["humidity", "vs", "0"],
|
||||
{"x.com.samsung.da.desiredHumidity": str(round(float(p)))},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="watertank_light",
|
||||
field="status",
|
||||
icon="mdi:led-on",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["watertank", "lighting", "vs", "0"],
|
||||
{"status": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="watertank_light_color",
|
||||
field="colorOption",
|
||||
icon="mdi:palette",
|
||||
entity_category="config",
|
||||
options_field="colorSupportedList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["watertank", "lighting", "vs", "0"],
|
||||
{"colorOption": p},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="watertank_light_brightness",
|
||||
field="mode",
|
||||
icon="mdi:brightness-6",
|
||||
entity_category="config",
|
||||
options_field="modeSupportedList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["watertank", "lighting", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="watertank_full_alarm_status",
|
||||
field="waterfullAlarmStatus",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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
|
||||
'/keepnormalstate/vs/0', # internal keep-normal flag
|
||||
'/personality/presence/vs/0', # presence-personalization plumbing (empty item value)
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/sensors/vs/0', # empty {} on this dump
|
||||
'/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert)
|
||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
|
||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||
"/keepnormalstate/vs/0", # internal keep-normal flag
|
||||
"/personality/presence/vs/0", # presence-personalization plumbing (empty item value)
|
||||
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
|
||||
"/sensors/vs/0", # empty {} on this dump
|
||||
"/welcome/humidity/vs/0", # welcome-mode plumbing (requestId/operatingStatus, inert)
|
||||
# Only supportedModes ([Off, Sleep]) is present -- no live "current
|
||||
# value" field on this dump to confirm the read/write contract, so per
|
||||
# the 'don't guess' rule this is left unmodeled rather than assumed.
|
||||
'/mode/convenient/vs/0',
|
||||
"/mode/convenient/vs/0",
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
|
||||
|
||||
@@ -6,6 +6,7 @@ The /course/vs/0 cycle select and its options-array machinery are shared with
|
||||
washer and dryer in laundry.py; only the dishwasher-specific options (storm
|
||||
wash, auto release dry) are read locally here.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
@@ -15,40 +16,50 @@ from .laundry import bool_option_switch, cycle_select
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DISHWASHER_SETTINGS = Capability(
|
||||
href='/dishwasher/vs/0',
|
||||
href="/dishwasher/vs/0",
|
||||
entities=(
|
||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
||||
icon='mdi:bacteria',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
|
||||
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
|
||||
icon='mdi:heat-wave',
|
||||
options_field='x.com.samsung.da.supportedHeatedDry',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
{'x.com.samsung.da.heatedDry': p})),
|
||||
SwitchDesc(
|
||||
key="sanitize",
|
||||
field="x.com.samsung.da.sanitize",
|
||||
icon="mdi:bacteria",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["dishwasher", "vs", "0"],
|
||||
{"x.com.samsung.da.sanitize": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="heated_dry",
|
||||
field="x.com.samsung.da.heatedDry",
|
||||
icon="mdi:heat-wave",
|
||||
options_field="x.com.samsung.da.supportedHeatedDry",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["dishwasher", "vs", "0"],
|
||||
{"x.com.samsung.da.heatedDry": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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: 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',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
||||
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
|
||||
'StormWashZone'),
|
||||
bool_option_switch('auto_release_dry', 'mdi:door-open',
|
||||
'AutoDoorRelease', gate_on_presence=True),
|
||||
cycle_select(translation_key="dishwasher_cycle", icon="mdi:dishwasher"),
|
||||
bool_option_switch("storm_wash", "mdi:weather-lightning-rainy", "StormWashZone"),
|
||||
bool_option_switch(
|
||||
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,26 +68,35 @@ CYCLE_OPTIONS = Capability(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DIAGNOSIS = Capability(
|
||||
href='/diagnosis/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/diagnosis/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
ButtonDesc(key='diagnosis_start', field='', payload='Start',
|
||||
icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
|
||||
SensorDesc(
|
||||
key="diagnosis_status",
|
||||
field="x.com.samsung.da.diagnosisStart",
|
||||
icon="mdi:stethoscope",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
ButtonDesc(
|
||||
key="diagnosis_start",
|
||||
field="",
|
||||
payload="Start",
|
||||
icon="mdi:play-circle-outline",
|
||||
entity_category="diagnostic",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["diagnosis", "vs", "0"],
|
||||
{"x.com.samsung.da.diagnosisStart": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
OPERATION_ORIGIN = Capability(
|
||||
href='/operation/origin/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/operation/origin/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='operation_origin', field='origin',
|
||||
icon='mdi:remote',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="operation_origin", field="origin", icon="mdi:remote", entity_category="diagnostic"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,59 +8,83 @@ the /course/vs/0 cycle select -- lives in laundry.py.
|
||||
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import SensorDesc, SwitchDesc
|
||||
from .laundry import cycle_select
|
||||
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
|
||||
|
||||
|
||||
def _wrinkle_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
||||
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
|
||||
|
||||
|
||||
DRYER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/washer/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
icon='mdi:water-percent'),
|
||||
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
|
||||
icon='mdi:timer'),
|
||||
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
|
||||
icon='mdi:tumble-dryer'),
|
||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
||||
icon='mdi:iron',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_wrinkle_write),
|
||||
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
|
||||
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
|
||||
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
|
||||
SwitchDesc(
|
||||
key="wrinkle_prevent",
|
||||
field="x.com.samsung.da.wrinklePrevent",
|
||||
icon="mdi:iron",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_wrinkle_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# /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 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
|
||||
# editCourseList but aren't identified yet -- they render as the raw code
|
||||
# until named. 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). 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.
|
||||
#
|
||||
# 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. 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',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
|
||||
table_href='/st/dryercourse/vs/0'),
|
||||
cycle_select(
|
||||
translation_key="dryer_cycle",
|
||||
icon="mdi:tumble-dryer",
|
||||
table_href="/st/dryercourse/vs/0",
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
unit="cycles",
|
||||
icon="mdi:tumble-dryer-alert",
|
||||
state_class="measurement",
|
||||
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=drum_clean_cycles_remaining,
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_last_cleaned",
|
||||
device_class="timestamp",
|
||||
icon="mdi:calendar-clock",
|
||||
entity_category="diagnostic",
|
||||
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=drum_clean_last_cleaned,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
DRYER_DIAGNOSIS = Capability(
|
||||
href='/diagnosis/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/diagnosis/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Capabilities for the Samsung EHS (Eco Heating System) air-to-water heat
|
||||
pump family (TP1X_DA_AC_EHS-class, model TP1X_DA_AC_EHS_01001_0000).
|
||||
|
||||
An EHS unit runs two independently-controlled loops off one outdoor unit:
|
||||
space heating/cooling ("zone1", through /mode/vs/0, /power/vs/0,
|
||||
/temperatures/indoor/vs/0) and domestic hot water ("dhw", through
|
||||
/mode/dhw/vs/0, /power/dhw/vs/0, /temperatures/dhw/vs/0). There's no shared
|
||||
vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
|
||||
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
|
||||
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
|
||||
|
||||
zone1 has no HA platform with matching semantics (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.
|
||||
|
||||
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
|
||||
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _first_mode(rep):
|
||||
"""Representative scalar for a mode select -- `modes` is a single-element
|
||||
list on every dump seen, mirroring airconditioner._first_mode."""
|
||||
modes = rep.get("x.com.samsung.da.modes")
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
def _temp_unit(rep):
|
||||
return normalize_temp_unit(rep.get("x.com.samsung.da.unit"), "°C")
|
||||
|
||||
|
||||
def _bounds(rep, default_min, default_max):
|
||||
"""The resource's own (minimum, maximum) pair, or the defaults. Both
|
||||
ends together or neither -- a board reporting only one would otherwise
|
||||
pair a real 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)
|
||||
|
||||
|
||||
def _step(rep, default):
|
||||
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
|
||||
step = _num(rep.get("x.com.samsung.da.increment"))
|
||||
return default if step is None else step
|
||||
|
||||
|
||||
ZONE_POWER = Capability(
|
||||
href="/power/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="zone_power",
|
||||
field="x.com.samsung.da.power",
|
||||
icon="mdi:radiator",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["power", "vs", "0"],
|
||||
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
ZONE_MODE = Capability(
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="zone_mode",
|
||||
rep_fn=_first_mode,
|
||||
icon="mdi:sun-snowflake-variant",
|
||||
options_field="x.com.samsung.da.supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.modes": [p]},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
|
||||
# room setpoint, not a literal water temperature -- EHS zone control is
|
||||
# leaving-water-temperature-based, same convention as the dhw loop below.
|
||||
ZONE_TEMPERATURE = Capability(
|
||||
href="/temperatures/indoor/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="zone_temperature",
|
||||
field="x.com.samsung.da.current",
|
||||
device_class="temperature",
|
||||
unit_fn=_temp_unit,
|
||||
state_class="measurement",
|
||||
value_fn=_num,
|
||||
),
|
||||
NumberDesc(
|
||||
key="zone_target_temperature",
|
||||
field="x.com.samsung.da.desired",
|
||||
device_class="temperature",
|
||||
unit_fn=_temp_unit,
|
||||
entity_category="config",
|
||||
value_fn=_num,
|
||||
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
|
||||
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
|
||||
step_fn=lambda rep: _step(rep, 0.5),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["temperatures", "indoor", "vs", "0"],
|
||||
{"x.com.samsung.da.desired": str(float(p))},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Canonical dhw resource hrefs. water_heater.py binds 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
|
||||
|
||||
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, 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"})
|
||||
if kind == "mode":
|
||||
return (["mode", "dhw", "vs", "0"], {"x.com.samsung.da.modes": [value]})
|
||||
if kind == "temperature":
|
||||
return (["temperatures", "dhw", "vs", "0"], {"x.com.samsung.da.desired": str(float(value))})
|
||||
return None
|
||||
|
||||
|
||||
DHW = Capability(
|
||||
href=HREF_DHW_MODE,
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
WaterHeaterDesc(
|
||||
key="water_heater", translation_key="dhw", rep_fn=_first_mode, write_fn=_dhw_write
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Power and temperature are read by the composite DHW entity above, not
|
||||
# given their own entities -- coverage-only caps so discover() reports no
|
||||
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS).
|
||||
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 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",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="away_mode",
|
||||
field="x.com.samsung.da.away",
|
||||
icon="mdi:home-export-outline",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["option", "outgoing", "vs", "0"],
|
||||
{"x.com.samsung.da.away": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# EHS-scoped coverage: opaque vendor plumbing 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
|
||||
"/diagnosis/vs/0", # empty {} on this dump
|
||||
"/ehscycle/vs/0", # opaque hex-encoded indoor/outdoor cycle log
|
||||
"/ehsfsv/vs/0", # opaque hex-encoded factory setting values
|
||||
"/option/dhwdisplay/vs/0", # front-panel DHW-display show/hide, cosmetic only
|
||||
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
|
||||
"/sac/installationinfo/vs/0", # static outdoor/indoor installation info, diagnostic only
|
||||
"/actions/zone1/vs/0", # zone1 schedule/timer program -- unmodeled for now
|
||||
"/actions/dhw/vs/0", # DHW schedule/timer program -- unmodeled for now
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,133 +19,109 @@ 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
|
||||
|
||||
IGNORED: list[Capability] = [
|
||||
# Device serial/model is read directly by the coordinator for HA device
|
||||
# identity, not modeled as an entity capability.
|
||||
Capability(href='/information/vs/0'),
|
||||
|
||||
Capability(href="/information/vs/0"),
|
||||
# Bixby voice assistant: feature negotiation, account provisioning
|
||||
# (Samsung account email, access tokens), terms-of-service state, and
|
||||
# enable/disable status.
|
||||
Capability(href='/voice/feature/vs/0'),
|
||||
Capability(href='/voice/provisioning/vs/0'),
|
||||
Capability(href='/bixby/vs/0'),
|
||||
Capability(href='/bixby/status/vs/0'),
|
||||
Capability(href='/bixbyuservalidate/vs/0'),
|
||||
Capability(href='/bixbyterms/vs/0'),
|
||||
|
||||
Capability(href="/voice/feature/vs/0"),
|
||||
Capability(href="/voice/provisioning/vs/0"),
|
||||
Capability(href="/bixby/vs/0"),
|
||||
Capability(href="/bixby/status/vs/0"),
|
||||
Capability(href="/bixbyuservalidate/vs/0"),
|
||||
Capability(href="/bixbyterms/vs/0"),
|
||||
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
|
||||
# types, no controllable or observable appliance state.
|
||||
Capability(href='/wirelessinfo/vs/0'),
|
||||
Capability(href='/connectionconfig/vs/0'),
|
||||
|
||||
Capability(href="/wirelessinfo/vs/0"),
|
||||
Capability(href="/connectionconfig/vs/0"),
|
||||
# Static or internal-protocol metadata, not entity-worthy.
|
||||
Capability(href='/quickcontrol/info/vs/0'),
|
||||
Capability(href='/realtimenotiforclient/vs/0'),
|
||||
Capability(href='/file/information/vs/0'),
|
||||
Capability(href='/configuration/vs/0'), # region/countryCode
|
||||
Capability(href='/setting/vs/0'), # supported/selected UI language
|
||||
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
|
||||
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
|
||||
Capability(href="/quickcontrol/info/vs/0"),
|
||||
Capability(href="/realtimenotiforclient/vs/0"),
|
||||
Capability(href="/file/information/vs/0"),
|
||||
Capability(href="/configuration/vs/0"), # region/countryCode
|
||||
Capability(href="/setting/vs/0"), # supported/selected UI language
|
||||
Capability(href="/timezone/vs/0"), # redundant with HA's own timezone
|
||||
Capability(href="/wm/setinfo/vs/0"), # model/manufacturing metadata
|
||||
# Resource-monitoring poll-interval config (a bare minPeriod in
|
||||
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
|
||||
# plumbing, not appliance state.
|
||||
Capability(href='/rm/control/vs/0'),
|
||||
|
||||
Capability(href="/rm/control/vs/0"),
|
||||
# Demand Response Load Control — utility-company grid signals; requires
|
||||
# cloud registration with a utility program we don't support locally.
|
||||
Capability(href='/drlc/vs/0'),
|
||||
|
||||
Capability(href="/drlc/vs/0"),
|
||||
# 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.
|
||||
Capability(href='/wm/editcourse/vs/0'),
|
||||
|
||||
Capability(href="/speakersound/vs/0"),
|
||||
# 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
|
||||
# integration has no local path to configure or use.
|
||||
Capability(href='/sec/networkaudio/audio/vs/0'),
|
||||
|
||||
Capability(href="/sec/networkaudio/audio/vs/0"),
|
||||
# Static Bespoke-product-line flag, not appliance state.
|
||||
Capability(href='/bespoke/vs/0'),
|
||||
Capability(href="/bespoke/vs/0"),
|
||||
# Empty resource on every dump seen so far — nothing to expose.
|
||||
Capability(href='/defrost/prediction/vs/0'),
|
||||
Capability(href="/defrost/prediction/vs/0"),
|
||||
# Seasonal defrost schedule (start/period/end per season). Automating
|
||||
# this cleanly would need a multi-field schedule editor; the practical
|
||||
# on/off control is fridge.DEFROST_DELAY.
|
||||
Capability(href='/defrost/reservation/vs/0'),
|
||||
Capability(href="/defrost/reservation/vs/0"),
|
||||
# Warranty/service-plan enrollment status — every field reads "Unknown"
|
||||
# on hardware not enrolled in a Samsung Care+ style program.
|
||||
Capability(href='/dginformation/vs/0'),
|
||||
Capability(href="/dginformation/vs/0"),
|
||||
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
|
||||
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
|
||||
# expose yet. Revisit if a device surfaces it toggled on.
|
||||
Capability(href='/mode/0'),
|
||||
Capability(href="/mode/0"),
|
||||
# Opaque integer with no supportedModes/options list to interpret it
|
||||
# against — meaning unclear from the raw resource alone.
|
||||
Capability(href='/runningmode/vs/0'),
|
||||
|
||||
Capability(href="/runningmode/vs/0"),
|
||||
# Demand-response energy planner — same utility-program dependency as
|
||||
# /drlc/vs/0 above; every dump seen so far is inert (plan: 'none').
|
||||
Capability(href='/energy/planner/vs/0'),
|
||||
Capability(href="/energy/planner/vs/0"),
|
||||
# Temperature-unit display preference, redundant with HA's own units.
|
||||
Capability(href='/wm/submode/vs/0'),
|
||||
|
||||
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>").
|
||||
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>").
|
||||
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.
|
||||
Capability(href='/st/airdressercourse/vs/0'),
|
||||
# washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed
|
||||
# "Table_02_Course_").
|
||||
Capability(href="/st/washercourse/vs/0"),
|
||||
# Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0.
|
||||
Capability(href="/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'),
|
||||
Capability(href="/wm/welcomemsg/vs/0"),
|
||||
# User-saved custom course slots (F1-FA). No controllable/observable
|
||||
# state without a multi-slot editor; revisit if that becomes valuable.
|
||||
Capability(href='/wm/personalcourse/vs/0'),
|
||||
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.
|
||||
Capability(href='/energy/consumption/0'),
|
||||
# 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'),
|
||||
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.
|
||||
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.
|
||||
Capability(href='/cooktop/recipe/status/vs/0'),
|
||||
Capability(href="/drlc/0"),
|
||||
# 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): 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"),
|
||||
]
|
||||
|
||||
@@ -19,33 +19,35 @@ Resource hrefs seen across laundry dumps:
|
||||
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
|
||||
_LED_LEVELS = ('Low', 'High')
|
||||
_SOUND_MODES = ('voice', 'tone', 'mute')
|
||||
_LED_LEVELS = ("Low", "High")
|
||||
_SOUND_MODES = ("voice", "tone", "mute")
|
||||
|
||||
|
||||
def _led_brightness_write(p, rep, href=None):
|
||||
if p not in _LED_LEVELS:
|
||||
return None
|
||||
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
|
||||
return ["doorled", "light", "vs", "0"], {"setBrightness": p}
|
||||
|
||||
|
||||
def _led_night_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
|
||||
return ["doorled", "light", "vs", "0"], {"setNightLight": p}
|
||||
|
||||
|
||||
def _parse_hm(v):
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
h, m = v.split(':')
|
||||
h, m = v.split(":")
|
||||
return dt_time(int(h), int(m))
|
||||
except Exception:
|
||||
return None
|
||||
@@ -54,66 +56,95 @@ def _parse_hm(v):
|
||||
def _sound_mode_write(p, rep, href=None):
|
||||
if p not in _SOUND_MODES:
|
||||
return None
|
||||
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
|
||||
return ["settings", "sound", "mode", "vs", "0"], {"mode": p}
|
||||
|
||||
|
||||
DOOR_LED = Capability(
|
||||
href='/doorled/light/vs/0',
|
||||
href="/doorled/light/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='led_brightness', field='setBrightness',
|
||||
icon='mdi:brightness-6',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS, write_fn=_led_brightness_write),
|
||||
SwitchDesc(key='led_night_light', field='setNightLight',
|
||||
icon='mdi:weather-night',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_led_night_write),
|
||||
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
|
||||
icon='mdi:brightness-4',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightBrightness': p})),
|
||||
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
|
||||
icon='mdi:clock-start',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
|
||||
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightTimeEnd': f'{p.hour:02d}:{p.minute:02d}'})),
|
||||
SelectDesc(
|
||||
key="led_brightness",
|
||||
field="setBrightness",
|
||||
icon="mdi:brightness-6",
|
||||
entity_category="config",
|
||||
options=_LED_LEVELS,
|
||||
write_fn=_led_brightness_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="led_night_light",
|
||||
field="setNightLight",
|
||||
icon="mdi:weather-night",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_led_night_write,
|
||||
),
|
||||
SelectDesc(
|
||||
key="led_night_brightness",
|
||||
field="setNightLightBrightness",
|
||||
icon="mdi:brightness-4",
|
||||
entity_category="config",
|
||||
options=_LED_LEVELS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["doorled", "light", "vs", "0"],
|
||||
{"setNightLightBrightness": p},
|
||||
),
|
||||
),
|
||||
TimeDesc(
|
||||
key="led_night_start",
|
||||
field="setNightLightTimeStart",
|
||||
icon="mdi:clock-start",
|
||||
entity_category="config",
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["doorled", "light", "vs", "0"],
|
||||
{"setNightLightTimeStart": f"{p.hour:02d}:{p.minute:02d}"},
|
||||
),
|
||||
),
|
||||
TimeDesc(
|
||||
key="led_night_end",
|
||||
field="setNightLightTimeEnd",
|
||||
icon="mdi:clock-end",
|
||||
entity_category="config",
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["doorled", "light", "vs", "0"],
|
||||
{"setNightLightTimeEnd": f"{p.hour:02d}:{p.minute:02d}"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_MODE = Capability(
|
||||
href='/settings/sound/mode/vs/0',
|
||||
href="/settings/sound/mode/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='sound_mode', field='mode',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options=_SOUND_MODES, write_fn=_sound_mode_write),
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options=_SOUND_MODES,
|
||||
write_fn=_sound_mode_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
href="/settings/sound/volume/vs/0",
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
native_min=0, native_max=15, step=5,
|
||||
value_fn=lambda v: int(v) if v is not None else None,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'volume', 'vs', '0'],
|
||||
{'level': str(int(p))})),
|
||||
NumberDesc(
|
||||
key="sound_volume",
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
native_min=0,
|
||||
native_max=15,
|
||||
step=5,
|
||||
value_fn=lambda v: int(v) if v is not None else None,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "volume", "vs", "0"],
|
||||
{"level": str(int(p))},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -125,120 +156,136 @@ SOUND_VOLUME = Capability(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
href="/buzzersound/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedBuzzerSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||
icon='mdi:bell-ring',
|
||||
entity_category='config',
|
||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||
options_field='supportedFinishSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
|
||||
SelectDesc(
|
||||
key="buzzer_sound",
|
||||
field="setBuzzerSound",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedBuzzerSound",
|
||||
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setBuzzerSound": p}),
|
||||
),
|
||||
SelectDesc(
|
||||
key="finish_sound",
|
||||
field="setFinishSound",
|
||||
icon="mdi:bell-ring",
|
||||
entity_category="config",
|
||||
exists_fn=lambda rep, resources: "supportedFinishSound" in rep,
|
||||
options_field="supportedFinishSound",
|
||||
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setFinishSound": p}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)]
|
||||
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
if not isinstance(raw, str) or "_" not in raw:
|
||||
return []
|
||||
return hex_pairs(raw.split('_', 1)[1])
|
||||
return hex_pairs(raw.split("_", 1)[1])
|
||||
|
||||
|
||||
def cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
rep = resources.get("/wm/editcourse/vs/0") or {}
|
||||
codes = parse_edit_course_list(rep.get("x.com.samsung.da.editCourseList"))
|
||||
if codes:
|
||||
return codes
|
||||
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
|
||||
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]
|
||||
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), 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")
|
||||
washed = option_value(opts, "WashingTimes")
|
||||
if proposal is None or washed is None:
|
||||
return None
|
||||
try:
|
||||
return max(int(proposal) - int(washed), 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
|
||||
# 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:
|
||||
return None
|
||||
last = raw.rsplit("|", 1)[-1]
|
||||
try:
|
||||
return datetime.fromisoformat(last).replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
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')
|
||||
raw = course_rep.get("x.com.samsung.da.supportedOptions")
|
||||
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
||||
if not isinstance(hexstr, str) or len(hexstr) < 3:
|
||||
return []
|
||||
@@ -246,14 +293,14 @@ def _course_codes_from_supported_options(course_rep):
|
||||
if len(body) % 2:
|
||||
return []
|
||||
total_bytes = len(body) // 2
|
||||
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
|
||||
current = option_value(course_rep.get("x.com.samsung.da.options"), "Course")
|
||||
for k in range(1, total_bytes + 1):
|
||||
if total_bytes % k:
|
||||
continue
|
||||
n = total_bytes // k
|
||||
if n < 2:
|
||||
continue
|
||||
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
|
||||
firsts = [body[i * k * 2 : i * k * 2 + 2] for i in range(n)]
|
||||
if len(set(firsts)) != n:
|
||||
continue
|
||||
if current is not None and current not in firsts:
|
||||
@@ -264,119 +311,112 @@ def _course_codes_from_supported_options(course_rep):
|
||||
|
||||
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 [f"{prefix}_{new_value}"]
|
||||
|
||||
|
||||
def cycle_write(p, rep, href=None):
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Course', p),
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Course", p),
|
||||
}
|
||||
|
||||
|
||||
def _table_id(resources, table_href):
|
||||
rep = resources.get(table_href) or {}
|
||||
return rep.get('x.com.samsung.da.st.courseTable')
|
||||
return rep.get("x.com.samsung.da.st.courseTable")
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
key = translation_key
|
||||
if table_href is not None:
|
||||
|
||||
def key(resources):
|
||||
table = _table_id(resources, table_href)
|
||||
if not isinstance(table, str) or not table:
|
||||
return 'cycle'
|
||||
candidate = f'{translation_key}_{table.lower()}'
|
||||
return candidate if has_entity_translation('select', candidate) else 'cycle'
|
||||
return "cycle"
|
||||
candidate = f"{translation_key}_{table.lower()}"
|
||||
return candidate if has_entity_translation("select", candidate) else "cycle"
|
||||
|
||||
return SelectDesc(
|
||||
key='cycle', icon=icon, translation_key=key,
|
||||
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'),
|
||||
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
|
||||
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):
|
||||
def write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write(prefix, p),
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write(prefix, p),
|
||||
}
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def bool_option_value(prefix):
|
||||
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
|
||||
return lambda rep: option_value(rep.get("x.com.samsung.da.options"), prefix) == "On"
|
||||
|
||||
|
||||
def bool_option_exists(prefix):
|
||||
return lambda rep, resources: option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||
return lambda rep, resources: (
|
||||
option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||
)
|
||||
|
||||
|
||||
def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
||||
gate_on_presence=False, validate_fn=None):
|
||||
def bool_option_switch(
|
||||
key, icon, prefix, *, entity_category=None, gate_on_presence=False, validate_fn=None
|
||||
):
|
||||
"""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, icon=icon, entity_category=entity_category,
|
||||
key=key,
|
||||
icon=icon,
|
||||
entity_category=entity_category,
|
||||
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
|
||||
rep_fn=bool_option_value(prefix),
|
||||
write_fn=bool_option_write(prefix),
|
||||
@@ -384,21 +424,20 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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',
|
||||
poll_tier='warm',
|
||||
href="/wm/jobbeginingstatus/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="job_beginning_status",
|
||||
field="x.com.samsung.da.currentStatus",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,34 +8,27 @@ 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
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, normalize_temp_unit
|
||||
@@ -45,52 +38,44 @@ 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',
|
||||
'MicroWaveGrill',
|
||||
'MicroWaveConvection',
|
||||
'Convection',
|
||||
'AirFryer',
|
||||
'Grill',
|
||||
'Autocook',
|
||||
'AutocookCustom',
|
||||
'Deodorization',
|
||||
'KeepWarm',
|
||||
"NoOperation",
|
||||
"MicroWave",
|
||||
"MicroWaveGrill",
|
||||
"MicroWaveConvection",
|
||||
"Convection",
|
||||
"AirFryer",
|
||||
"Grill",
|
||||
"Autocook",
|
||||
"AutocookCustom",
|
||||
"Deodorization",
|
||||
"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)."""
|
||||
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')
|
||||
"""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")
|
||||
|
||||
|
||||
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):
|
||||
@@ -98,12 +83,12 @@ def _setpoint_write(p, rep, href=None):
|
||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
||||
return None
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
items = rep.get("x.com.samsung.da.items")
|
||||
if not items:
|
||||
return None
|
||||
items = [dict(it) for it in items]
|
||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
||||
items[0]["x.com.samsung.da.desired"] = str(temp_i)
|
||||
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
|
||||
|
||||
|
||||
def _power_level_watts(v):
|
||||
@@ -111,83 +96,81 @@ def _power_level_watts(v):
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
if s.upper().endswith('W'):
|
||||
if s.upper().endswith("W"):
|
||||
s = s[:-1]
|
||||
return int_or_none(s)
|
||||
|
||||
|
||||
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)."""
|
||||
rep = resources.get('/mode/vs/0') or {}
|
||||
live = rep.get('x.com.samsung.da.supportedModes')
|
||||
"""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)
|
||||
|
||||
|
||||
def _mode_write(p, rep, href=None):
|
||||
valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES
|
||||
valid = rep.get("x.com.samsung.da.supportedModes") or _MICROWAVE_MODES
|
||||
if p not in valid:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
||||
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
|
||||
|
||||
|
||||
def _sound_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Sound', p),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Sound", p),
|
||||
}
|
||||
|
||||
|
||||
def _lamp_exists(rep, resources):
|
||||
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
|
||||
return option_value(rep.get("x.com.samsung.da.options"), "Lamp") is not None
|
||||
|
||||
|
||||
def _filter_remind_exists(rep, resources):
|
||||
return option_value(rep.get('x.com.samsung.da.options'), 'FilterRemind') is not None
|
||||
return option_value(rep.get("x.com.samsung.da.options"), "FilterRemind") is not None
|
||||
|
||||
|
||||
def _remind_beep_exists(rep, resources):
|
||||
return option_value(rep.get('x.com.samsung.da.options'), 'RemindBeep') is not None
|
||||
return option_value(rep.get("x.com.samsung.da.options"), "RemindBeep") is not None
|
||||
|
||||
|
||||
def _lamp_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
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).
|
||||
token = 'High' if p == 'On' else 'Off'
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Lamp', token),
|
||||
# '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),
|
||||
}
|
||||
|
||||
|
||||
def _filter_remind_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('FilterRemind', p),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("FilterRemind", p),
|
||||
}
|
||||
|
||||
|
||||
def _remind_beep_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('RemindBeep', p),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("RemindBeep", p),
|
||||
}
|
||||
|
||||
|
||||
@@ -196,71 +179,100 @@ def _remind_beep_write(p, rep, href=None):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MICROWAVE_CAVITY = Capability(
|
||||
href='/oven/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/oven/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
|
||||
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
|
||||
unit='W', value_fn=_power_level_watts),
|
||||
SensorDesc(key="cavity_state", field="x.com.samsung.da.state"),
|
||||
SensorDesc(
|
||||
key="power_level",
|
||||
field="x.com.samsung.da.powerLevel",
|
||||
unit="W",
|
||||
value_fn=_power_level_watts,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
MICROWAVE_SETPOINT = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/temperatures/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
|
||||
device_class='temperature', unit_fn=_microwave_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
|
||||
value_fn=lambda items: int_or_none(
|
||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
||||
write_fn=_setpoint_write),
|
||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||
device_class='temperature',
|
||||
state_class='measurement', unit_fn=_microwave_temp_unit,
|
||||
value_fn=lambda items: int_or_none(
|
||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||
NumberDesc(
|
||||
key="setpoint",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="temperature",
|
||||
unit_fn=_microwave_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C),
|
||||
native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C),
|
||||
icon="mdi:thermometer-chevron-up",
|
||||
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == "°C",
|
||||
value_fn=lambda items: int_or_none(
|
||||
items[0].get("x.com.samsung.da.desired") if items else None
|
||||
),
|
||||
write_fn=_setpoint_write,
|
||||
),
|
||||
SensorDesc(
|
||||
key="current_temp_c",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="temperature",
|
||||
state_class="measurement",
|
||||
unit_fn=_microwave_temp_unit,
|
||||
value_fn=lambda items: int_or_none(
|
||||
items[0].get("x.com.samsung.da.current") if items else None
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
MICROWAVE_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
|
||||
icon='mdi:tune',
|
||||
options=_cooking_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_mode_write),
|
||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda opts: option_value(opts, 'Sound') == 'On',
|
||||
write_fn=_sound_write),
|
||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
||||
icon='mdi:track-light',
|
||||
exists_fn=_lamp_exists,
|
||||
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
|
||||
write_fn=_lamp_write),
|
||||
# issue #181: Filter Reminder / End Signal Reminder toggles,
|
||||
# confirmed present (both On and Off observed across dumps -- see
|
||||
# issue #152's ME7500D fixtures) but only on boards that carry the
|
||||
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
|
||||
# MW7300B combi dump has neither) rather than assumed universal.
|
||||
SwitchDesc(key='filter_remind', field='x.com.samsung.da.options',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='config',
|
||||
exists_fn=_filter_remind_exists,
|
||||
value_fn=lambda opts: option_value(opts, 'FilterRemind') == 'On',
|
||||
write_fn=_filter_remind_write),
|
||||
SwitchDesc(key='remind_beep', field='x.com.samsung.da.options',
|
||||
icon='mdi:bell-ring',
|
||||
entity_category='config',
|
||||
exists_fn=_remind_beep_exists,
|
||||
value_fn=lambda opts: option_value(opts, 'RemindBeep') == 'On',
|
||||
write_fn=_remind_beep_write),
|
||||
SelectDesc(
|
||||
key="cooking_mode",
|
||||
field="x.com.samsung.da.modes",
|
||||
icon="mdi:tune",
|
||||
options=_cooking_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_mode_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="sound",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
value_fn=lambda opts: option_value(opts, "Sound") == "On",
|
||||
write_fn=_sound_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="lamp",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:track-light",
|
||||
exists_fn=_lamp_exists,
|
||||
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
|
||||
write_fn=_lamp_write,
|
||||
),
|
||||
# issue #181: Filter Reminder / End Signal Reminder toggles, 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",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="config",
|
||||
exists_fn=_filter_remind_exists,
|
||||
value_fn=lambda opts: option_value(opts, "FilterRemind") == "On",
|
||||
write_fn=_filter_remind_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="remind_beep",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:bell-ring",
|
||||
entity_category="config",
|
||||
exists_fn=_remind_beep_exists,
|
||||
value_fn=lambda opts: option_value(opts, "RemindBeep") == "On",
|
||||
write_fn=_remind_beep_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
Shared by dryer/dishwasher/oven/washer families.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
||||
|
||||
_SAMSUNG_STATE_TO_OCF = {
|
||||
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
|
||||
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
|
||||
"Ready": "idle",
|
||||
"Run": "active",
|
||||
"Running": "active",
|
||||
"Pause": "pause",
|
||||
"Paused": "pause",
|
||||
"End": "idle",
|
||||
"Stop": "idle",
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +25,7 @@ def _to_ocf(v):
|
||||
|
||||
|
||||
def _progress(v):
|
||||
return 'Idle' if v in (None, 'None') else v
|
||||
return "Idle" if v in (None, "None") else v
|
||||
|
||||
|
||||
def _int(v):
|
||||
@@ -32,8 +38,8 @@ def _int(v):
|
||||
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'
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
)
|
||||
|
||||
|
||||
@@ -42,7 +48,7 @@ def _remaining_seconds(raw):
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
parts = [int(p) for p in raw.split(':')]
|
||||
parts = [int(p) for p in raw.split(":")]
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@@ -69,7 +75,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}:{m:02d}:00"
|
||||
|
||||
|
||||
def _delay_field(rep):
|
||||
@@ -79,118 +85,149 @@ def _delay_field(rep):
|
||||
device itself is using; default to delayStartTime for hardware that
|
||||
reports neither yet (matches prior behavior)."""
|
||||
return (
|
||||
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||
else 'x.com.samsung.da.delayStartTime'
|
||||
"x.com.samsung.da.delayEndTime"
|
||||
if "x.com.samsung.da.delayEndTime" in rep
|
||||
else "x.com.samsung.da.delayStartTime"
|
||||
)
|
||||
|
||||
|
||||
def _finish_time(rep):
|
||||
if not _is_active(rep):
|
||||
return None
|
||||
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
|
||||
total_s = _remaining_seconds(rep.get("x.com.samsung.da.remainingTime"))
|
||||
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.
|
||||
finish = datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
||||
# 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)
|
||||
|
||||
|
||||
def _completion_minutes(rep):
|
||||
"""Parse remaining time into minutes directly from device payload."""
|
||||
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
|
||||
raw = rep.get("x.com.samsung.da.remainingTime") or rep.get("remainingTime")
|
||||
total_s = _remaining_seconds(raw)
|
||||
if total_s is None:
|
||||
return None
|
||||
|
||||
|
||||
# Avoid the firmware bug where it freezes at 1 minute post-cycle
|
||||
if rep.get('x.com.samsung.da.progress') == 'Finish':
|
||||
if rep.get("x.com.samsung.da.progress") == "Finish":
|
||||
return 0
|
||||
|
||||
|
||||
return math.ceil(total_s / 60)
|
||||
|
||||
|
||||
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||
# rather than keeping its own copy, since both wrote the identical
|
||||
# state='Ready' RMW.
|
||||
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p}))
|
||||
STOP_BUTTON = ButtonDesc(
|
||||
key="stop",
|
||||
field="",
|
||||
payload="Ready",
|
||||
icon="mdi:stop",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["operational", "state", "vs", "0"],
|
||||
{"x.com.samsung.da.state": p},
|
||||
),
|
||||
)
|
||||
|
||||
OPERATIONAL_STATE = Capability(
|
||||
href='/operational/state/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/operational/state/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
device_class='enum',
|
||||
options=('idle', 'active', 'pause'),
|
||||
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.
|
||||
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'
|
||||
)),
|
||||
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'
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
|
||||
SensorDesc(key='progress_percentage',
|
||||
unit='%', state_class='measurement',
|
||||
rep_fn=lambda rep: (
|
||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||
)),
|
||||
|
||||
# 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'.
|
||||
SensorDesc(key='finish_time', device_class='timestamp',
|
||||
hysteresis=True, rep_fn=_finish_time),
|
||||
|
||||
SensorDesc(key='completion_minutes',
|
||||
icon='mdi:clock-outline', unit='min',
|
||||
device_class='duration', state_class='measurement',
|
||||
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
|
||||
rep_fn=_completion_minutes),
|
||||
|
||||
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
|
||||
device_class='duration', unit='h',
|
||||
native_min=0, native_max=24, step=1,
|
||||
rep_fn=lambda rep: _delay_hours(
|
||||
rep.get('x.com.samsung.da.delayStartTime')
|
||||
or rep.get('x.com.samsung.da.delayEndTime')),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{_delay_field(rep): _format_delay(p)})),
|
||||
ButtonDesc(key='start', field='', payload='Run',
|
||||
icon='mdi:play',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
ButtonDesc(key='pause', field='', payload='Pause',
|
||||
icon='mdi:pause',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
|
||||
SensorDesc(
|
||||
key="machine_state",
|
||||
field="x.com.samsung.da.state",
|
||||
device_class="enum",
|
||||
options=("idle", "active", "pause"),
|
||||
translation_key="machine_state",
|
||||
value_fn=_to_ocf,
|
||||
),
|
||||
# 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"
|
||||
),
|
||||
),
|
||||
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"
|
||||
else _progress(rep.get("x.com.samsung.da.progress"))
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="progress_percentage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
rep_fn=lambda rep: (
|
||||
0
|
||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
||||
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
||||
),
|
||||
),
|
||||
# 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
|
||||
),
|
||||
SensorDesc(
|
||||
key="completion_minutes",
|
||||
icon="mdi:clock-outline",
|
||||
unit="min",
|
||||
device_class="duration",
|
||||
state_class="measurement",
|
||||
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
|
||||
rep_fn=_completion_minutes,
|
||||
),
|
||||
NumberDesc(
|
||||
key="delay_start_hours",
|
||||
icon="mdi:timer-plus-outline",
|
||||
device_class="duration",
|
||||
unit="h",
|
||||
native_min=0,
|
||||
native_max=24,
|
||||
step=1,
|
||||
rep_fn=lambda rep: _delay_hours(
|
||||
rep.get("x.com.samsung.da.delayStartTime")
|
||||
or rep.get("x.com.samsung.da.delayEndTime")
|
||||
),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["operational", "state", "vs", "0"],
|
||||
{_delay_field(rep): _format_delay(p)},
|
||||
),
|
||||
),
|
||||
ButtonDesc(
|
||||
key="start",
|
||||
field="",
|
||||
payload="Run",
|
||||
icon="mdi:play",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["operational", "state", "vs", "0"],
|
||||
{"x.com.samsung.da.state": p},
|
||||
),
|
||||
),
|
||||
ButtonDesc(
|
||||
key="pause",
|
||||
field="",
|
||||
payload="Pause",
|
||||
icon="mdi:pause",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["operational", "state", "vs", "0"],
|
||||
{"x.com.samsung.da.state": p},
|
||||
),
|
||||
),
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
"""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 datetime, timezone, timedelta
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
BinarySensorDesc,
|
||||
NumberDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .operational import STOP_BUTTON
|
||||
@@ -40,47 +38,39 @@ 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',
|
||||
'Broil',
|
||||
'Convection',
|
||||
'ConvectionBake',
|
||||
'ConvectionBroil',
|
||||
'FrozenPizzaPlus',
|
||||
'SlowCook',
|
||||
'PlateWarm',
|
||||
'AirFry',
|
||||
"NoOperation",
|
||||
"Bake",
|
||||
"Broil",
|
||||
"Convection",
|
||||
"ConvectionBake",
|
||||
"ConvectionBroil",
|
||||
"FrozenPizzaPlus",
|
||||
"SlowCook",
|
||||
"PlateWarm",
|
||||
"AirFry",
|
||||
)
|
||||
|
||||
_SAMSUNG_STATE_TO_OCF = {
|
||||
'Ready': 'idle',
|
||||
'Run': 'active',
|
||||
'Running': 'active',
|
||||
'Pause': 'pause',
|
||||
'Paused': 'pause',
|
||||
'End': 'idle',
|
||||
'Stop': 'idle',
|
||||
"Ready": "idle",
|
||||
"Run": "active",
|
||||
"Running": "active",
|
||||
"Pause": "pause",
|
||||
"Paused": "pause",
|
||||
"End": "idle",
|
||||
"Stop": "idle",
|
||||
}
|
||||
|
||||
|
||||
@@ -99,11 +89,11 @@ def _finish_time(remaining_str):
|
||||
if not remaining_str:
|
||||
return None
|
||||
try:
|
||||
h, m, s = remaining_str.split(':')
|
||||
h, m, s = remaining_str.split(":")
|
||||
total_s = int(h) * 3600 + int(m) * 60 + int(s)
|
||||
if total_s == 0:
|
||||
return None
|
||||
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
||||
return datetime.now(UTC) + timedelta(seconds=total_s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -113,7 +103,7 @@ def _op_minutes(op_time):
|
||||
if not op_time:
|
||||
return None
|
||||
try:
|
||||
h, m, s = op_time.split(':')
|
||||
h, m, s = op_time.split(":")
|
||||
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -123,53 +113,49 @@ def _op_minutes(op_time):
|
||||
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in an options array and return <value>."""
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
for o in options or []:
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
return o.split("_", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
return lambda rep, resources: (
|
||||
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
return [f'{prefix}_{new_value}']
|
||||
#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}"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _oven_setpoint_write(p, rep, href=None):
|
||||
"""RMW write to /temperatures/vs/0 items array."""
|
||||
try:
|
||||
@@ -180,27 +166,27 @@ def _oven_setpoint_write(p, rep, href=None):
|
||||
temp_i = int(round(temp / step_v) * step_v)
|
||||
if not (min_v <= temp_i <= max_v):
|
||||
return None
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
items = rep.get("x.com.samsung.da.items")
|
||||
if not items:
|
||||
return None
|
||||
items = [dict(it) for it in items]
|
||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
||||
items[0]["x.com.samsung.da.desired"] = str(temp_i)
|
||||
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
|
||||
|
||||
|
||||
def _cook_time_write(p, rep, href=None):
|
||||
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
|
||||
try:
|
||||
minutes = int(round(float(p)))
|
||||
minutes = round(float(p))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (0 <= minutes <= 1439):
|
||||
return None
|
||||
h, m = divmod(minutes, 60)
|
||||
hms = f"{h:02d}:{m:02d}:00"
|
||||
return ['operational', 'state', 'vs', '0'], {
|
||||
'x.com.samsung.da.operationTime': hms,
|
||||
'x.com.samsung.da.remainingTime': hms,
|
||||
return ["operational", "state", "vs", "0"], {
|
||||
"x.com.samsung.da.operationTime": hms,
|
||||
"x.com.samsung.da.remainingTime": hms,
|
||||
}
|
||||
|
||||
|
||||
@@ -210,30 +196,32 @@ def _oven_mode_options(resources):
|
||||
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
|
||||
needs the callable form rather than options_field because a static
|
||||
fallback has to kick in when the device's own field is absent."""
|
||||
rep = resources.get('/mode/vs/0') or {}
|
||||
live = rep.get('x.com.samsung.da.supportedModes')
|
||||
rep = resources.get("/mode/vs/0") or {}
|
||||
live = rep.get("x.com.samsung.da.supportedModes")
|
||||
return list(live) if live else list(_OVEN_MODES)
|
||||
|
||||
|
||||
def _oven_mode_write(p, rep, href=None):
|
||||
valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES
|
||||
valid = rep.get("x.com.samsung.da.supportedModes") or _OVEN_MODES
|
||||
if p not in valid:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
||||
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
|
||||
|
||||
|
||||
def _option_switch_write(prefix):
|
||||
"""Factory for a single-token on/off options-array write -- lamp, sound,
|
||||
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
|
||||
a byte-for-byte copy of this same shape, one per prefix."""
|
||||
|
||||
def write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write(prefix, p),
|
||||
return ["mode", "vs", "0"], {
|
||||
"x.com.samsung.da.options": _option_write(prefix, p),
|
||||
}
|
||||
|
||||
return write
|
||||
|
||||
|
||||
@@ -242,55 +230,80 @@ def _option_switch_write(prefix):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OVEN_OPERATIONAL_STATE = Capability(
|
||||
href='/operational/state/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/operational/state/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
icon='mdi:stove',
|
||||
device_class='enum', options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
|
||||
device_class='running',
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
SensorDesc(key='operation_time_minutes',
|
||||
field='x.com.samsung.da.operationTime',
|
||||
unit='min',
|
||||
state_class='measurement', value_fn=_op_minutes),
|
||||
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
|
||||
device_class='timestamp',
|
||||
value_fn=_finish_time),
|
||||
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
|
||||
unit='min', native_min=0, native_max=1439,
|
||||
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
|
||||
write_fn=_cook_time_write),
|
||||
SensorDesc(
|
||||
key="machine_state",
|
||||
field="x.com.samsung.da.state",
|
||||
icon="mdi:stove",
|
||||
device_class="enum",
|
||||
options=("idle", "active", "pause"),
|
||||
translation_key="machine_state",
|
||||
value_fn=_to_ocf,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="cycle_active",
|
||||
field="x.com.samsung.da.state",
|
||||
device_class="running",
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == "active",
|
||||
),
|
||||
SensorDesc(
|
||||
key="progress_percentage",
|
||||
field="x.com.samsung.da.progressPercentage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
value_fn=_int,
|
||||
),
|
||||
SensorDesc(
|
||||
key="operation_time_minutes",
|
||||
field="x.com.samsung.da.operationTime",
|
||||
unit="min",
|
||||
state_class="measurement",
|
||||
value_fn=_op_minutes,
|
||||
),
|
||||
SensorDesc(
|
||||
key="finish_time",
|
||||
field="x.com.samsung.da.remainingTime",
|
||||
device_class="timestamp",
|
||||
value_fn=_finish_time,
|
||||
),
|
||||
NumberDesc(
|
||||
key="cook_time",
|
||||
field="x.com.samsung.da.operationTime",
|
||||
unit="min",
|
||||
native_min=0,
|
||||
native_max=1439,
|
||||
step=1.0,
|
||||
icon="mdi:timer",
|
||||
value_fn=_op_minutes,
|
||||
write_fn=_cook_time_write,
|
||||
),
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
OVEN_CAVITY = Capability(
|
||||
href='/oven/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/oven/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
|
||||
),
|
||||
SensorDesc(
|
||||
key="oven_state",
|
||||
field="x.com.samsung.da.state",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
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')
|
||||
"""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")
|
||||
|
||||
|
||||
def _setpoint_bounds(rep):
|
||||
@@ -298,115 +311,150 @@ def _setpoint_bounds(rep):
|
||||
constants above for provenance. Bounds must track the unit shown by
|
||||
unit_fn (both read the same live rep), or the HA slider's range would
|
||||
silently mismatch its own displayed unit."""
|
||||
if _oven_temp_unit(rep) == '°F':
|
||||
if _oven_temp_unit(rep) == "°F":
|
||||
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
|
||||
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
|
||||
|
||||
|
||||
OVEN_SETPOINT = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/temperatures/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
||||
device_class='temperature', unit_fn=_oven_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
|
||||
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
||||
write_fn=_oven_setpoint_write),
|
||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||
device_class='temperature',
|
||||
state_class='measurement', unit_fn=_oven_temp_unit,
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||
NumberDesc(
|
||||
key="oven_setpoint",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="temperature",
|
||||
unit_fn=_oven_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C),
|
||||
native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C),
|
||||
icon="mdi:thermometer-chevron-up",
|
||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
|
||||
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
|
||||
value_fn=lambda items: _int(
|
||||
items[0].get("x.com.samsung.da.desired") if items else None
|
||||
),
|
||||
write_fn=_oven_setpoint_write,
|
||||
),
|
||||
SensorDesc(
|
||||
key="current_temp_c",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="temperature",
|
||||
state_class="measurement",
|
||||
unit_fn=_oven_temp_unit,
|
||||
value_fn=lambda items: _int(
|
||||
items[0].get("x.com.samsung.da.current") if items else None
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
OVEN_DOOR = Capability(
|
||||
href='/doors/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/doors/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
device_class='door',
|
||||
value_fn=lambda items: (
|
||||
items[0].get('x.com.samsung.da.openState') == 'Open'
|
||||
if items else None)),
|
||||
BinarySensorDesc(
|
||||
key="door_open",
|
||||
field="x.com.samsung.da.items",
|
||||
device_class="door",
|
||||
value_fn=lambda items: (
|
||||
items[0].get("x.com.samsung.da.openState") == "Open" if items else None
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
OVEN_CONNECTED = Capability(
|
||||
href='/connected/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/connected/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
BinarySensorDesc(
|
||||
key="cloud_connected",
|
||||
field="x.com.samsung.da.connected",
|
||||
device_class="connectivity",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v == "On",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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.
|
||||
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
|
||||
# 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.
|
||||
OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0')
|
||||
# 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(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/mode/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
|
||||
icon='mdi:tune',
|
||||
options=_oven_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write),
|
||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
||||
icon='mdi:track-light',
|
||||
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
|
||||
write_fn=_option_switch_write('UpperLamp')),
|
||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
|
||||
write_fn=_option_switch_write('Sound')),
|
||||
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
|
||||
icon='mdi:fire',
|
||||
exists_fn=_has_option('fastpreheat'),
|
||||
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
|
||||
write_fn=_option_switch_write('fastpreheat')),
|
||||
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
|
||||
icon='mdi:kettle-steam',
|
||||
exists_fn=_has_option('NaturalSteam'),
|
||||
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
|
||||
write_fn=_option_switch_write('NaturalSteam')),
|
||||
SelectDesc(
|
||||
key="oven_mode",
|
||||
field="x.com.samsung.da.modes",
|
||||
icon="mdi:tune",
|
||||
options=_oven_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="lamp",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:track-light",
|
||||
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
|
||||
write_fn=_option_switch_write("UpperLamp"),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="sound",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
value_fn=lambda opts: _option_value(opts, "Sound") == "On",
|
||||
write_fn=_option_switch_write("Sound"),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="fast_preheat",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:fire",
|
||||
exists_fn=_has_option("fastpreheat"),
|
||||
value_fn=lambda opts: _option_value(opts, "fastpreheat") == "On",
|
||||
write_fn=_option_switch_write("fastpreheat"),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="natural_steam",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:kettle-steam",
|
||||
exists_fn=_has_option("NaturalSteam"),
|
||||
value_fn=lambda opts: _option_value(opts, "NaturalSteam") == "On",
|
||||
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.
|
||||
SwitchDesc(key='energy_saving', field='x.com.samsung.da.options',
|
||||
icon='mdi:leaf', entity_category='config',
|
||||
exists_fn=_has_option('EnergySaving'),
|
||||
value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On',
|
||||
write_fn=_option_switch_write('EnergySaving')),
|
||||
# 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",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
exists_fn=_has_option("EnergySaving"),
|
||||
value_fn=lambda opts: _option_value(opts, "EnergySaving") == "On",
|
||||
write_fn=_option_switch_write("EnergySaving"),
|
||||
),
|
||||
# Cooktop-on alert (issue #183): also confirmed present
|
||||
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
|
||||
# the SmartThings app's own alerting, not local automation.
|
||||
SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options',
|
||||
icon='mdi:alert-circle-outline', entity_category='config',
|
||||
exists_fn=_has_option('BurnerOnAlert'),
|
||||
value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On',
|
||||
write_fn=_option_switch_write('BurnerOnAlert')),
|
||||
SwitchDesc(
|
||||
key="cooktop_on_alert",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:alert-circle-outline",
|
||||
entity_category="config",
|
||||
exists_fn=_has_option("BurnerOnAlert"),
|
||||
value_fn=lambda opts: _option_value(opts, "BurnerOnAlert") == "On",
|
||||
write_fn=_option_switch_write("BurnerOnAlert"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,47 +1,44 @@
|
||||
"""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
|
||||
|
||||
|
||||
def _burner(burner_list, i):
|
||||
for b in (burner_list or []):
|
||||
if b.get('burnerNumber') == i:
|
||||
for b in burner_list or []:
|
||||
if b.get("burnerNumber") == i:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
def _burner_exists(i):
|
||||
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
|
||||
return lambda rep, resources: _burner(rep.get("burnerList"), i) is not None
|
||||
|
||||
|
||||
def _burner_field_fn(i, field):
|
||||
@@ -49,35 +46,36 @@ def _burner_field_fn(i, field):
|
||||
|
||||
|
||||
def _burner_hot_surface_fn(i):
|
||||
get_state = _burner_field_fn(i, 'hotSurfaceState')
|
||||
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
|
||||
get_state = _burner_field_fn(i, "hotSurfaceState")
|
||||
return lambda burner_list: get_state(burner_list) not in (None, "normal")
|
||||
|
||||
|
||||
def _burner_pan_detection_fn(i):
|
||||
return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list))
|
||||
return lambda burner_list: bool(_burner_field_fn(i, "panDetection")(burner_list))
|
||||
|
||||
|
||||
def _power_level_options(resources):
|
||||
spec = resources.get('/cooktop/spec/vs/0') or {}
|
||||
return list(spec.get('supportedPowerLevelList') or [])
|
||||
spec = resources.get("/cooktop/spec/vs/0") or {}
|
||||
return list(spec.get("supportedPowerLevelList") or [])
|
||||
|
||||
|
||||
def _burner_power_level_write(i):
|
||||
def write(p, rep, href=None):
|
||||
burner_list = rep.get('burnerList')
|
||||
burner_list = rep.get("burnerList")
|
||||
if not burner_list:
|
||||
return None
|
||||
new_list = []
|
||||
found = False
|
||||
for b in burner_list:
|
||||
if b.get('burnerNumber') == i:
|
||||
if b.get("burnerNumber") == i:
|
||||
b = dict(b)
|
||||
b['powerLevel'] = p
|
||||
b["powerLevel"] = p
|
||||
found = True
|
||||
new_list.append(b)
|
||||
if not found:
|
||||
return None
|
||||
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
|
||||
return ["cooktop", "status", "vs", "0"], {"burnerList": new_list}
|
||||
|
||||
return write
|
||||
|
||||
|
||||
@@ -85,128 +83,166 @@ def _burner_entities(i):
|
||||
exists = _burner_exists(i)
|
||||
n = i + 1
|
||||
return (
|
||||
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
|
||||
icon='mdi:knob',
|
||||
translation_key='range_burner_power_level',
|
||||
translation_placeholders={'number': str(n)},
|
||||
options=_power_level_options,
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'powerLevel'),
|
||||
write_fn=_burner_power_level_write(i)),
|
||||
SensorDesc(key=f'burner_{i}_state', field='burnerList',
|
||||
icon='mdi:stove',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'operationState')),
|
||||
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
|
||||
device_class='heat',
|
||||
translation_key='burner_hot_surface',
|
||||
translation_placeholders={'number': str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_hot_surface_fn(i)),
|
||||
BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList',
|
||||
icon='mdi:pot',
|
||||
translation_key='burner_pan_detected',
|
||||
translation_placeholders={'number': str(n)},
|
||||
entity_category='diagnostic',
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_pan_detection_fn(i)),
|
||||
SelectDesc(
|
||||
key=f"burner_{i}_power_level",
|
||||
field="burnerList",
|
||||
icon="mdi:knob",
|
||||
translation_key="range_burner_power_level",
|
||||
translation_placeholders={"number": str(n)},
|
||||
options=_power_level_options,
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, "powerLevel"),
|
||||
write_fn=_burner_power_level_write(i),
|
||||
),
|
||||
SensorDesc(
|
||||
key=f"burner_{i}_state",
|
||||
field="burnerList",
|
||||
icon="mdi:stove",
|
||||
translation_key="burner_state",
|
||||
translation_placeholders={"number": str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, "operationState"),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key=f"burner_{i}_hot_surface",
|
||||
field="burnerList",
|
||||
device_class="heat",
|
||||
translation_key="burner_hot_surface",
|
||||
translation_placeholders={"number": str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_hot_surface_fn(i),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key=f"burner_{i}_pan_detected",
|
||||
field="burnerList",
|
||||
icon="mdi:pot",
|
||||
translation_key="burner_pan_detected",
|
||||
translation_placeholders={"number": str(n)},
|
||||
entity_category="diagnostic",
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_pan_detection_fn(i),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _child_lock_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
if p not in ("On", "Off"):
|
||||
return None
|
||||
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
|
||||
return ["cooktop", "status", "vs", "0"], {"childLock": p.lower()}
|
||||
|
||||
|
||||
COOKTOP_STATUS = Capability(
|
||||
href='/cooktop/status/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/cooktop/status/vs/0",
|
||||
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.
|
||||
BinarySensorDesc(key='cooktop_power', field='power',
|
||||
device_class='power', icon='mdi:pot-steam',
|
||||
value_fn=lambda v: str(v).lower() == 'on'),
|
||||
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
|
||||
# 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",
|
||||
device_class="power",
|
||||
icon="mdi:pot-steam",
|
||||
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).
|
||||
SwitchDesc(key='cooktop_child_lock', field='childLock',
|
||||
device_class='lock', entity_category='config',
|
||||
icon='mdi:lock',
|
||||
value_fn=lambda v: str(v).lower() == 'on',
|
||||
write_fn=_child_lock_write),
|
||||
# direct single-field PUT, no RMW needed.
|
||||
SwitchDesc(
|
||||
key="cooktop_child_lock",
|
||||
field="childLock",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
icon="mdi:lock",
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
write_fn=_child_lock_write,
|
||||
),
|
||||
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
|
||||
),
|
||||
)
|
||||
|
||||
# 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_SPEC = Capability(href='/cooktop/spec/vs/0')
|
||||
# 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',
|
||||
href="/cooktop/settings/status/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: (v or {}).get('state') == 'on'),
|
||||
BinarySensorDesc(
|
||||
key="cooktop_safety_shutoff_enabled",
|
||||
field="safetyAlert",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: (v or {}).get("state") == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
href="/bluetooth/probe/status/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='probe_connected', field='connectionState',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'connected'),
|
||||
SensorDesc(key='probe_battery', field='batteryPercentage',
|
||||
device_class='battery', state_class='measurement',
|
||||
unit='%', entity_category='diagnostic'),
|
||||
SensorDesc(key='probe_temperature', field='currentTemperature',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
|
||||
SensorDesc(key='probe_target_temperature', field='targetTemperature',
|
||||
device_class='temperature', entity_category='diagnostic',
|
||||
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
|
||||
BinarySensorDesc(
|
||||
key="probe_connected",
|
||||
field="connectionState",
|
||||
device_class="connectivity",
|
||||
value_fn=lambda v: str(v).lower() == "connected",
|
||||
),
|
||||
SensorDesc(
|
||||
key="probe_battery",
|
||||
field="batteryPercentage",
|
||||
device_class="battery",
|
||||
state_class="measurement",
|
||||
unit="%",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="probe_temperature",
|
||||
field="currentTemperature",
|
||||
device_class="temperature",
|
||||
state_class="measurement",
|
||||
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
|
||||
),
|
||||
SensorDesc(
|
||||
key="probe_target_temperature",
|
||||
field="targetTemperature",
|
||||
device_class="temperature",
|
||||
entity_category="diagnostic",
|
||||
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
href="/cooktopmonitoring/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
|
||||
icon='mdi:pot-steam-outline'),
|
||||
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
|
||||
icon='mdi:heat-wave', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="cooktop_running_state",
|
||||
field="x.com.samsung.da.cooktopRunningState",
|
||||
icon="mdi:pot-steam-outline",
|
||||
),
|
||||
SensorDesc(
|
||||
key="warming_center_state",
|
||||
field="x.com.samsung.da.warmingCenterState",
|
||||
icon="mdi:heat-wave",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,8 +7,6 @@ brightness remain separate controls because the device advertises them as two
|
||||
independent fields.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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=timezone.utc)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
from .common import epoch_to_utc, int_or_none, sensor_item_value
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
@@ -39,23 +30,23 @@ def _active_alarm_codes(items):
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
|
||||
if str(item.get("x.com.samsung.da.state", "")).lower() == "deleted":
|
||||
continue
|
||||
code = item.get('x.com.samsung.da.code')
|
||||
if code and str(code).lower() != 'errorcode_off':
|
||||
code = item.get("x.com.samsung.da.code")
|
||||
if code and str(code).lower() != "errorcode_off":
|
||||
codes.append(code)
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
return ", ".join(codes) if codes else "none"
|
||||
|
||||
|
||||
HOOD_ALARMS = Capability(
|
||||
href='/alarms/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/alarms/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='alarm_code',
|
||||
field='x.com.samsung.da.items',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic',
|
||||
key="alarm_code",
|
||||
field="x.com.samsung.da.items",
|
||||
icon="mdi:alert",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_active_alarm_codes,
|
||||
),
|
||||
),
|
||||
@@ -64,24 +55,21 @@ HOOD_ALARMS = Capability(
|
||||
|
||||
def _hood_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
power_href = args[0] if args else '/power/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
if power_href == '/power/vs/0':
|
||||
return ['power', 'vs', '0'], {
|
||||
'x.com.samsung.da.power': 'On' if value else 'Off',
|
||||
if kind == "power":
|
||||
power_href = args[0] if args else "/power/0"
|
||||
if power_href == "/power/0":
|
||||
return ["power", "0"], {"value": bool(value)}
|
||||
if power_href == "/power/vs/0":
|
||||
return ["power", "vs", "0"], {
|
||||
"x.com.samsung.da.power": "On" if value else "Off",
|
||||
}
|
||||
return None
|
||||
if kind == 'speed':
|
||||
if kind == "speed":
|
||||
value = str(value)
|
||||
supported = [
|
||||
str(code)
|
||||
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
|
||||
]
|
||||
supported = [str(code) for code in rep.get("x.com.samsung.da.hood.supportedFanSpeed", ())]
|
||||
if not supported:
|
||||
min_s = rep.get('x.com.samsung.da.hood.settableMinFanSpeed')
|
||||
max_s = rep.get('x.com.samsung.da.hood.settableMaxFanSpeed')
|
||||
min_s = rep.get("x.com.samsung.da.hood.settableMinFanSpeed")
|
||||
max_s = rep.get("x.com.samsung.da.hood.settableMaxFanSpeed")
|
||||
if min_s is not None and max_s is not None:
|
||||
try:
|
||||
mn, mx = int(min_s), int(max_s)
|
||||
@@ -90,32 +78,33 @@ def _hood_fan_write(payload, rep, href=None):
|
||||
pass
|
||||
if value not in supported:
|
||||
return None
|
||||
return ['hood', 'fanspeed', 'vs', '0'], {
|
||||
'x.com.samsung.da.hood.fanSpeed': value,
|
||||
return ["hood", "fanspeed", "vs", "0"], {
|
||||
"x.com.samsung.da.hood.fanSpeed": value,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
HOOD_FAN = Capability(
|
||||
href='/hood/fanspeed/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/hood/fanspeed/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
FanDesc(
|
||||
key='fan',
|
||||
field='x.com.samsung.da.hood.fanSpeed',
|
||||
key="fan",
|
||||
field="x.com.samsung.da.hood.fanSpeed",
|
||||
write_fn=_hood_fan_write,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='automatic_operation',
|
||||
field='x.com.samsung.da.hood.autoOperation',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
key="automatic_operation",
|
||||
field="x.com.samsung.da.hood.autoOperation",
|
||||
icon="mdi:fan-auto",
|
||||
entity_category="diagnostic",
|
||||
# Absent on the microwave family's built-in vent fan (issue
|
||||
# #137) -- this board has no auto-ventilation mode, unlike the
|
||||
# standalone range hood this capability was written for.
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
is_stub_rep(rep) or "x.com.samsung.da.hood.autoOperation" in rep
|
||||
),
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -123,34 +112,34 @@ HOOD_FAN = Capability(
|
||||
|
||||
def _lamp_level_write(value, rep, href=None):
|
||||
code = str(value)
|
||||
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
|
||||
supported = [str(level) for level in rep.get("x.com.samsung.lamp.range", ())]
|
||||
if code not in supported:
|
||||
return None
|
||||
return ['hood', 'lamp', 'vs', '0'], {
|
||||
'x.com.samsung.lamp.current': code,
|
||||
return ["hood", "lamp", "vs", "0"], {
|
||||
"x.com.samsung.lamp.current": code,
|
||||
}
|
||||
|
||||
|
||||
HOOD_LAMP = Capability(
|
||||
href='/hood/lamp/vs/0',
|
||||
poll_tier='hot',
|
||||
href="/hood/lamp/vs/0",
|
||||
poll_tier="hot",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key='lamp',
|
||||
field='x.com.samsung.lamp.power',
|
||||
icon='mdi:range-hood',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="lamp",
|
||||
field="x.com.samsung.lamp.power",
|
||||
icon="mdi:range-hood",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
write_fn=lambda payload, rep, href=None: (
|
||||
['hood', 'lamp', 'vs', '0'],
|
||||
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
|
||||
["hood", "lamp", "vs", "0"],
|
||||
{"x.com.samsung.lamp.power": "On" if payload == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key='lamp_brightness',
|
||||
field='x.com.samsung.lamp.current',
|
||||
icon='mdi:brightness-6',
|
||||
translation_key='range_hood_lamp_brightness',
|
||||
options_field='x.com.samsung.lamp.range',
|
||||
key="lamp_brightness",
|
||||
field="x.com.samsung.lamp.current",
|
||||
icon="mdi:brightness-6",
|
||||
translation_key="range_hood_lamp_brightness",
|
||||
options_field="x.com.samsung.lamp.range",
|
||||
write_fn=_lamp_level_write,
|
||||
),
|
||||
),
|
||||
@@ -158,36 +147,34 @@ HOOD_LAMP = Capability(
|
||||
|
||||
|
||||
HOOD_FILTER = Capability(
|
||||
href='/filter/hoodfilter/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/filter/hoodfilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='hood_filter_usage',
|
||||
field='x.com.samsung.da.filterUsage',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
key="hood_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='hood_filter_status',
|
||||
field='x.com.samsung.da.filterStatus',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
translation_key='filter_status',
|
||||
value_fn=lambda value: (
|
||||
value.lower() if isinstance(value, str) else value
|
||||
),
|
||||
key="hood_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_capacity',
|
||||
field='x.com.samsung.da.filterCapacity',
|
||||
unit='h',
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='diagnostic',
|
||||
key="hood_filter_capacity",
|
||||
field="x.com.samsung.da.filterCapacity",
|
||||
unit="h",
|
||||
icon="mdi:timer-outline",
|
||||
entity_category="diagnostic",
|
||||
enabled_default=False,
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
@@ -195,44 +182,39 @@ 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',
|
||||
href="/afterrun/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='after_run_active',
|
||||
field='x.com.samsung.da.activationState',
|
||||
icon='mdi:fan-clock',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="after_run_active",
|
||||
field="x.com.samsung.da.activationState",
|
||||
icon="mdi:fan-clock",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='after_run_progress',
|
||||
field='x.com.samsung.da.runningProgress',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:fan-clock',
|
||||
key="after_run_progress",
|
||||
field="x.com.samsung.da.runningProgress",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:fan-clock",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
ButtonDesc(
|
||||
key='after_run_cancel',
|
||||
field='',
|
||||
payload='Cancel',
|
||||
icon='mdi:fan-off',
|
||||
key="after_run_cancel",
|
||||
field="",
|
||||
payload="Cancel",
|
||||
icon="mdi:fan-off",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['afterrun', 'vs', '0'],
|
||||
{'x.com.samsung.da.runningCancel': p},
|
||||
["afterrun", "vs", "0"],
|
||||
{"x.com.samsung.da.runningCancel": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -240,82 +222,82 @@ AFTER_RUN = Capability(
|
||||
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='clean_level',
|
||||
field='x.com.samsung.da.items',
|
||||
icon='mdi:air-filter',
|
||||
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
|
||||
key="clean_level",
|
||||
field="x.com.samsung.da.items",
|
||||
icon="mdi:air-filter",
|
||||
value_fn=lambda items: sensor_item_value(items, "CleanLevel"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'Dust'),
|
||||
key="dust",
|
||||
field="x.com.samsung.da.items",
|
||||
value_fn=lambda items: sensor_item_value(items, "Dust"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
|
||||
key="fine_dust",
|
||||
field="x.com.samsung.da.items",
|
||||
value_fn=lambda items: sensor_item_value(items, "FineDust"),
|
||||
),
|
||||
SensorDesc(
|
||||
key='super_fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
|
||||
key="super_fine_dust",
|
||||
field="x.com.samsung.da.items",
|
||||
value_fn=lambda items: sensor_item_value(items, "SuperFineDust"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_LEVEL_CHECK = Capability(
|
||||
href='/airlevelcheck/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/airlevelcheck/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='periodic_air_sensing',
|
||||
field='x.com.samsung.da.periodicSensingActivationState',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
key="periodic_air_sensing",
|
||||
field="x.com.samsung.da.periodicSensingActivationState",
|
||||
icon="mdi:radar",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda value: str(value).lower() == "on",
|
||||
),
|
||||
SensorDesc(
|
||||
key='air_sensing_state',
|
||||
field='x.com.samsung.da.sensingState',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
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=_timestamp,
|
||||
key="last_air_sensing_time",
|
||||
field="x.com.samsung.da.lastSensingTime",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
value_fn=epoch_to_utc,
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_level',
|
||||
field='x.com.samsung.da.lastSensingLevel',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
key="last_air_sensing_level",
|
||||
field="x.com.samsung.da.lastSensingLevel",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key='automatic_ventilation_state',
|
||||
field='x.com.samsung.da.autoExeState',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
key="automatic_ventilation_state",
|
||||
field="x.com.samsung.da.autoExeState",
|
||||
icon="mdi:fan-auto",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AUTO_VENTILATION = Capability(
|
||||
href='/autoventilation/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/autoventilation/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='auto_ventilation_action',
|
||||
field='action',
|
||||
icon='mdi:fan-auto',
|
||||
key="auto_ventilation_action",
|
||||
field="action",
|
||||
icon="mdi:fan-auto",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -326,11 +308,11 @@ AUTO_VENTILATION = Capability(
|
||||
COVERAGE = [
|
||||
Capability(href=href)
|
||||
for href in (
|
||||
'/power/0',
|
||||
'/power/vs/0',
|
||||
'/mode/vs/0',
|
||||
'/personality/presence/vs/0',
|
||||
'/availablecontrolsets/vs/0',
|
||||
'/da/softreset/vs/0',
|
||||
"/power/0",
|
||||
"/power/vs/0",
|
||||
"/mode/vs/0",
|
||||
"/personality/presence/vs/0",
|
||||
"/availablecontrolsets/vs/0",
|
||||
"/da/softreset/vs/0",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -15,19 +15,23 @@ docstring for that rule).
|
||||
|
||||
Resources verified against the issue #131 diagnostics dump.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
|
||||
|
||||
from .common import int_or_none
|
||||
from .common import parse_iso_utc as _parse_iso_utc
|
||||
|
||||
DUSTBAG = Capability(
|
||||
href='/component/station/dustbag/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/component/station/dustbag/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status',
|
||||
device_class='problem',
|
||||
icon='mdi:bag-personal',
|
||||
value_fn=lambda v: v == 'full'),
|
||||
BinarySensorDesc(
|
||||
key="dustbag_full",
|
||||
field="x.com.samsung.da.status",
|
||||
device_class="problem",
|
||||
icon="mdi:bag-personal",
|
||||
value_fn=lambda v: v == "full",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -36,40 +40,57 @@ DUSTBAG = Capability(
|
||||
# families' filterUsage, which always ships filterCapacity) -- exposed as a
|
||||
# plain diagnostic count rather than guessing a unit.
|
||||
DUSTBAG_USAGE = Capability(
|
||||
href='/component/station/dustbagusage/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/component/station/dustbagusage/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage',
|
||||
icon='mdi:counter', entity_category='diagnostic',
|
||||
state_class='total_increasing', value_fn=int_or_none),
|
||||
SensorDesc(
|
||||
key="dustbag_usage",
|
||||
field="x.com.samsung.da.dustbagUsage",
|
||||
icon="mdi:counter",
|
||||
entity_category="diagnostic",
|
||||
state_class="total_increasing",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
DUSTBIN_SETTING = Capability(
|
||||
href='/setting/dustbin/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/setting/dustbin/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty',
|
||||
icon='mdi:delete-empty',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'dustbin', 'vs', '0'],
|
||||
{'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})),
|
||||
SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose',
|
||||
icon='mdi:door-sliding',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'dustbin', 'vs', '0'],
|
||||
{'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})),
|
||||
SelectDesc(key='discharging_time', field='x.com.samsung.da.desiredDischargingTime',
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedDischargingTime',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'dustbin', 'vs', '0'],
|
||||
{'x.com.samsung.da.desiredDischargingTime': p})),
|
||||
SwitchDesc(
|
||||
key="auto_empty",
|
||||
field="x.com.samsung.da.autoEmpty",
|
||||
icon="mdi:delete-empty",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "dustbin", "vs", "0"],
|
||||
{"x.com.samsung.da.autoEmpty": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="dustbin_auto_close",
|
||||
field="x.com.samsung.da.autoClose",
|
||||
icon="mdi:door-sliding",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "dustbin", "vs", "0"],
|
||||
{"x.com.samsung.da.autoClose": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="discharging_time",
|
||||
field="x.com.samsung.da.desiredDischargingTime",
|
||||
icon="mdi:timer-outline",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedDischargingTime",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "dustbin", "vs", "0"],
|
||||
{"x.com.samsung.da.desiredDischargingTime": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -78,32 +99,60 @@ DUSTBIN_SETTING = Capability(
|
||||
# rather than a binary_sensor, so no polarity/semantic is asserted that
|
||||
# might be wrong.
|
||||
CLEANSTATION_STATUS = Capability(
|
||||
href='/status/cleanstation/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/status/cleanstation/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status',
|
||||
icon='mdi:home-lightning-bolt', entity_category='diagnostic'),
|
||||
SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus',
|
||||
icon='mdi:broom', entity_category='diagnostic'),
|
||||
SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive',
|
||||
icon='mdi:lightbulb-on-outline',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['status', 'cleanstation', 'vs', '0'],
|
||||
{'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})),
|
||||
SensorDesc(key='uvc_operation_time', field='x.com.samsung.da.uvcOperationTime',
|
||||
icon='mdi:timer-sand', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(key='uvc_total_operation_time',
|
||||
field='x.com.samsung.da.uvcTotalOperationTime',
|
||||
icon='mdi:timer-sand', entity_category='diagnostic',
|
||||
state_class='total_increasing', value_fn=int_or_none),
|
||||
SensorDesc(key='uvc_finished_time', field='x.com.samsung.da.uvcFinishedTime',
|
||||
device_class='timestamp', entity_category='diagnostic',
|
||||
value_fn=_parse_iso_utc),
|
||||
SensorDesc(key='uvc_emitted_time', field='x.com.samsung.da.emittedTime',
|
||||
device_class='timestamp', entity_category='diagnostic',
|
||||
value_fn=_parse_iso_utc),
|
||||
SensorDesc(
|
||||
key="cleanstation_status",
|
||||
field="x.com.samsung.da.status",
|
||||
icon="mdi:home-lightning-bolt",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="stick_status",
|
||||
field="x.com.samsung.da.stickStatus",
|
||||
icon="mdi:broom",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SwitchDesc(
|
||||
key="uvc_intensive_mode",
|
||||
field="x.com.samsung.da.uvcIntensive",
|
||||
icon="mdi:lightbulb-on-outline",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["status", "cleanstation", "vs", "0"],
|
||||
{"x.com.samsung.da.uvcIntensive": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="uvc_operation_time",
|
||||
field="x.com.samsung.da.uvcOperationTime",
|
||||
icon="mdi:timer-sand",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="uvc_total_operation_time",
|
||||
field="x.com.samsung.da.uvcTotalOperationTime",
|
||||
icon="mdi:timer-sand",
|
||||
entity_category="diagnostic",
|
||||
state_class="total_increasing",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="uvc_finished_time",
|
||||
field="x.com.samsung.da.uvcFinishedTime",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_parse_iso_utc,
|
||||
),
|
||||
SensorDesc(
|
||||
key="uvc_emitted_time",
|
||||
field="x.com.samsung.da.emittedTime",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_parse_iso_utc,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,177 +14,130 @@ specific controls (wash settings, drum-clean tracking, dispenser dosing) are
|
||||
here; they read washer-only fields off the same shared /course/vs/0 options
|
||||
array.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from .laundry import (
|
||||
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
||||
bool_option_exists,
|
||||
bool_option_switch,
|
||||
cycle_options,
|
||||
cycle_select,
|
||||
drum_clean_cycles_remaining,
|
||||
drum_clean_last_cleaned,
|
||||
hex_pairs,
|
||||
option_value,
|
||||
option_write,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
#
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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',
|
||||
href="/washer/vs/0",
|
||||
entities=(
|
||||
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
|
||||
icon='mdi:thermometer-water',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedWaterTemperature',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
|
||||
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
||||
icon='mdi:sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedSpinLevel',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
|
||||
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
|
||||
icon='mdi:water-sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedRinseCycles',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
|
||||
SelectDesc(
|
||||
key="wash_temperature",
|
||||
field="x.com.samsung.da.waterTemperature",
|
||||
icon="mdi:thermometer-water",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedWaterTemperature",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["washer", "vs", "0"],
|
||||
{"x.com.samsung.da.waterTemperature": p},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="spin_speed",
|
||||
field="x.com.samsung.da.spinLevel",
|
||||
icon="mdi:sync",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedSpinLevel",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["washer", "vs", "0"],
|
||||
{"x.com.samsung.da.spinLevel": p},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="rinse_cycles",
|
||||
field="x.com.samsung.da.rinseCycles",
|
||||
icon="mdi:water-sync",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedRinseCycles",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["washer", "vs", "0"],
|
||||
{"x.com.samsung.da.rinseCycles": p},
|
||||
),
|
||||
),
|
||||
# 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.
|
||||
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
icon='mdi:tumble-dryer',
|
||||
entity_category='config',
|
||||
translation_key='washer_dry_level',
|
||||
options_field='x.com.samsung.da.supportedDryLevel',
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
rep.get('x.com.samsung.da.supportedDryLevel')),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
|
||||
# resource itself (issue #22). Self-gates off on plain washers,
|
||||
# which never report supportedDryLevel.
|
||||
SelectDesc(
|
||||
key="dry_level",
|
||||
field="x.com.samsung.da.dryLevel",
|
||||
icon="mdi:tumble-dryer",
|
||||
entity_category="config",
|
||||
translation_key="washer_dry_level",
|
||||
options_field="x.com.samsung.da.supportedDryLevel",
|
||||
exists_fn=lambda rep, resources: bool(rep.get("x.com.samsung.da.supportedDryLevel")),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["washer", "vs", "0"],
|
||||
{"x.com.samsung.da.dryLevel": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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, from the same options[] array as the
|
||||
# selected course. DrumCleanProposal_<N> is the wash-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: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
|
||||
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
|
||||
# is the last-clean timestamp (verified against the same screenshot's "10
|
||||
# days ago"); no explicit timezone field accompanies it on this resource,
|
||||
# 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).
|
||||
def _drum_clean_cycles_remaining(rep):
|
||||
opts = rep.get('x.com.samsung.da.options') or []
|
||||
proposal = option_value(opts, 'DrumCleanProposal')
|
||||
washed = option_value(opts, 'WashingTimes')
|
||||
if proposal is None or washed is None:
|
||||
return None
|
||||
try:
|
||||
return max(int(proposal) - int(washed), 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _drum_clean_last_cleaned(rep):
|
||||
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
|
||||
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
|
||||
# #258) since both families report identical DrumCleanProposal_/
|
||||
# WashingTimes_/DrumCleanLog_ tokens on the same options[] array.
|
||||
|
||||
|
||||
# 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}')
|
||||
rep = resources.get("/course/vs/0") or {}
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
|
||||
return hex_pairs(raw) if raw else []
|
||||
|
||||
|
||||
@@ -193,21 +146,20 @@ def _level_options(prefix):
|
||||
|
||||
|
||||
def _dosing_level(prefix):
|
||||
"""Current dose code, normalized to the `Supported<prefix>` code format.
|
||||
"""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."""
|
||||
|
||||
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)."""
|
||||
def fn(rep):
|
||||
opts = rep.get('x.com.samsung.da.options')
|
||||
opts = rep.get("x.com.samsung.da.options")
|
||||
raw = option_value(opts, prefix)
|
||||
if raw is None:
|
||||
return None
|
||||
supported_raw = option_value(opts, f'Supported{prefix}')
|
||||
supported_raw = option_value(opts, f"Supported{prefix}")
|
||||
try:
|
||||
target = int(raw, 16)
|
||||
except (TypeError, ValueError):
|
||||
@@ -219,67 +171,60 @@ def _dosing_level(prefix):
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return raw
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
def _level_write(prefix):
|
||||
def write(p, rep, href=None):
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
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')
|
||||
native = format(int(p, 16), "X")
|
||||
except (TypeError, ValueError):
|
||||
native = p
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write(prefix, native),
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write(prefix, native),
|
||||
}
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _dosing_low(prefix):
|
||||
return lambda rep: option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
||||
return lambda rep: (
|
||||
option_value(rep.get("x.com.samsung.da.options"), prefix) not in (None, "Off")
|
||||
)
|
||||
|
||||
|
||||
# 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."""
|
||||
if p != 'On':
|
||||
`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 []
|
||||
current = option_value(opts, 'Course')
|
||||
opts = rep.get("x.com.samsung.da.options") or []
|
||||
current = option_value(opts, "Course")
|
||||
courses = cycle_options(resources)
|
||||
if not current or current not in courses:
|
||||
return None
|
||||
@@ -289,75 +234,99 @@ def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
pairs = hex_pairs(raw)
|
||||
if len(pairs) != len(courses):
|
||||
return None
|
||||
if pairs[courses.index(current)] != 'F0':
|
||||
if pairs[courses.index(current)] != "F0":
|
||||
return f"{key}_unavailable_for_cycle"
|
||||
return None
|
||||
|
||||
return bool_option_switch(
|
||||
key, icon, prefix,
|
||||
entity_category='config', gate_on_presence=True, validate_fn=validate)
|
||||
key, icon, prefix, entity_category="config", gate_on_presence=True, validate_fn=validate
|
||||
)
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
|
||||
table_href='/st/washercourse/vs/0'),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
|
||||
icon='mdi:washing-machine-alert',
|
||||
state_class='measurement',
|
||||
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=_drum_clean_cycles_remaining),
|
||||
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
|
||||
icon='mdi:calendar-clock',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=_drum_clean_last_cleaned),
|
||||
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
|
||||
translation_key='detergent_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevelCtrl'),
|
||||
write_fn=_level_write('DetergentLevelCtrl')),
|
||||
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
|
||||
translation_key='detergent_water_hardness',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevel2Ctrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
|
||||
write_fn=_level_write('DetergentLevel2Ctrl')),
|
||||
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
|
||||
translation_key='softener_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevelCtrl'),
|
||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
|
||||
translation_key='softener_concentration',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevel2Ctrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
|
||||
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
||||
BinarySensorDesc(key='detergent_low', device_class='problem',
|
||||
icon='mdi:alert-circle-outline',
|
||||
exists_fn=bool_option_exists('DetergentAlarm'),
|
||||
rep_fn=_dosing_low('DetergentAlarm')),
|
||||
BinarySensorDesc(key='softener_low', device_class='problem',
|
||||
icon='mdi:alert-circle-outline',
|
||||
exists_fn=bool_option_exists('SoftenerAlarm'),
|
||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
|
||||
'BubbleSoak', 'BubbleSoakSet'),
|
||||
_bool_option_switch('pre_wash', 'mdi:washing-machine',
|
||||
'PreWashSetting', 'PreWashAvailableSet'),
|
||||
_bool_option_switch('intensive', 'mdi:washing-machine',
|
||||
'IntensiveSetting', 'IntensiveAvailableSet'),
|
||||
cycle_select(
|
||||
translation_key="washer_cycle",
|
||||
icon="mdi:washing-machine",
|
||||
table_href="/st/washercourse/vs/0",
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
unit="cycles",
|
||||
icon="mdi:washing-machine-alert",
|
||||
state_class="measurement",
|
||||
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=drum_clean_cycles_remaining,
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_last_cleaned",
|
||||
device_class="timestamp",
|
||||
icon="mdi:calendar-clock",
|
||||
entity_category="diagnostic",
|
||||
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=drum_clean_last_cleaned,
|
||||
),
|
||||
SelectDesc(
|
||||
key="detergent_quantity",
|
||||
icon="mdi:cup-water",
|
||||
translation_key="detergent_quantity",
|
||||
entity_category="config",
|
||||
options=_level_options("DetergentLevelCtrl"),
|
||||
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevelCtrl")(resources)),
|
||||
rep_fn=_dosing_level("DetergentLevelCtrl"),
|
||||
write_fn=_level_write("DetergentLevelCtrl"),
|
||||
),
|
||||
SelectDesc(
|
||||
key="detergent_water_hardness",
|
||||
icon="mdi:water-opacity",
|
||||
translation_key="detergent_water_hardness",
|
||||
entity_category="config",
|
||||
options=_level_options("DetergentLevel2Ctrl"),
|
||||
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevel2Ctrl")(resources)),
|
||||
rep_fn=_dosing_level("DetergentLevel2Ctrl"),
|
||||
write_fn=_level_write("DetergentLevel2Ctrl"),
|
||||
),
|
||||
SelectDesc(
|
||||
key="softener_quantity",
|
||||
icon="mdi:flask-outline",
|
||||
translation_key="softener_quantity",
|
||||
entity_category="config",
|
||||
options=_level_options("SoftenerLevelCtrl"),
|
||||
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevelCtrl")(resources)),
|
||||
rep_fn=_dosing_level("SoftenerLevelCtrl"),
|
||||
write_fn=_level_write("SoftenerLevelCtrl"),
|
||||
),
|
||||
SelectDesc(
|
||||
key="softener_concentration",
|
||||
icon="mdi:flask-plus-outline",
|
||||
translation_key="softener_concentration",
|
||||
entity_category="config",
|
||||
options=_level_options("SoftenerLevel2Ctrl"),
|
||||
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevel2Ctrl")(resources)),
|
||||
rep_fn=_dosing_level("SoftenerLevel2Ctrl"),
|
||||
write_fn=_level_write("SoftenerLevel2Ctrl"),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="detergent_low",
|
||||
device_class="problem",
|
||||
icon="mdi:alert-circle-outline",
|
||||
exists_fn=bool_option_exists("DetergentAlarm"),
|
||||
rep_fn=_dosing_low("DetergentAlarm"),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="softener_low",
|
||||
device_class="problem",
|
||||
icon="mdi:alert-circle-outline",
|
||||
exists_fn=bool_option_exists("SoftenerAlarm"),
|
||||
rep_fn=_dosing_low("SoftenerAlarm"),
|
||||
),
|
||||
_bool_option_switch("bubble_soak", "mdi:chart-bubble", "BubbleSoak", "BubbleSoakSet"),
|
||||
_bool_option_switch(
|
||||
"pre_wash", "mdi:washing-machine", "PreWashSetting", "PreWashAvailableSet"
|
||||
),
|
||||
_bool_option_switch(
|
||||
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,216 +4,242 @@ issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
|
||||
|
||||
Resources verified against the issue #90 and #196 diagnostics dumps.
|
||||
"""
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
|
||||
|
||||
from .common import int_or_none
|
||||
from .common import parse_iso_utc as _parse_iso_utc
|
||||
|
||||
DISPENSE = Capability(
|
||||
href='/setting/waterpurifier/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/setting/waterpurifier/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType',
|
||||
icon='mdi:cup-water',
|
||||
options_field='x.com.samsung.da.supportedTypes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'waterpurifier', 'vs', '0'],
|
||||
{'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.
|
||||
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
|
||||
icon='mdi:thermometer',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedHotTemperatures',
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep)
|
||||
or 'x.com.samsung.da.supportedHotTemperatures' in rep),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'waterpurifier', 'vs', '0'],
|
||||
{'x.com.samsung.da.tempDesiredHotWater': p})),
|
||||
SelectDesc(
|
||||
key="dispense_type",
|
||||
field="x.com.samsung.da.desiredType",
|
||||
icon="mdi:cup-water",
|
||||
options_field="x.com.samsung.da.supportedTypes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "waterpurifier", "vs", "0"],
|
||||
{"x.com.samsung.da.desiredType": p},
|
||||
),
|
||||
),
|
||||
# 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",
|
||||
icon="mdi:thermometer",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedHotTemperatures",
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or "x.com.samsung.da.supportedHotTemperatures" in rep
|
||||
),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "waterpurifier", "vs", "0"],
|
||||
{"x.com.samsung.da.tempDesiredHotWater": p},
|
||||
),
|
||||
),
|
||||
# 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.
|
||||
NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity',
|
||||
icon='mdi:cup-water',
|
||||
value_fn=int_or_none,
|
||||
range_field='x.com.samsung.da.desiredCapacityRange',
|
||||
step_fn=lambda rep: int_or_none(
|
||||
rep.get('x.com.samsung.da.capacityResolution')) or 1,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['setting', 'waterpurifier', 'vs', '0'],
|
||||
{'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})),
|
||||
BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus',
|
||||
icon='mdi:cup-water',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
# 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",
|
||||
icon="mdi:cup-water",
|
||||
value_fn=int_or_none,
|
||||
range_field="x.com.samsung.da.desiredCapacityRange",
|
||||
step_fn=lambda rep: int_or_none(rep.get("x.com.samsung.da.capacityResolution")) or 1,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["setting", "waterpurifier", "vs", "0"],
|
||||
{"x.com.samsung.da.desiredCapacity": str(round(float(p)))},
|
||||
),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="pouring",
|
||||
field="x.com.samsung.da.pourStatus",
|
||||
icon="mdi:cup-water",
|
||||
value_fn=lambda v: v == "On",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
STATUS = Capability(
|
||||
href='/status/waterpurifier/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/status/waterpurifier/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status',
|
||||
icon='mdi:water-pump',
|
||||
entity_category='diagnostic'),
|
||||
BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus',
|
||||
device_class='door',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'Open'),
|
||||
SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod',
|
||||
icon='mdi:calendar-sync',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime',
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='sterilize_last_time', device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: _parse_iso_utc(
|
||||
rep.get('x.com.samsung.da.sterilizeLastTime'))),
|
||||
SensorDesc(key='sterilize_plan_time', device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: _parse_iso_utc(
|
||||
rep.get('x.com.samsung.da.sterilizePlanTime'))),
|
||||
SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime',
|
||||
icon='mdi:timer-sand',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="waterpurifier_status",
|
||||
field="x.com.samsung.da.status",
|
||||
icon="mdi:water-pump",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="filter_door_status",
|
||||
field="x.com.samsung.da.filterDoorStatus",
|
||||
device_class="door",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v == "Open",
|
||||
),
|
||||
SensorDesc(
|
||||
key="sterilize_period",
|
||||
field="x.com.samsung.da.sterilizePeriod",
|
||||
icon="mdi:calendar-sync",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="sterilize_run_time",
|
||||
field="x.com.samsung.da.sterilizeRunTime",
|
||||
icon="mdi:timer-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="sterilize_last_time",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizeLastTime")),
|
||||
),
|
||||
SensorDesc(
|
||||
key="sterilize_plan_time",
|
||||
device_class="timestamp",
|
||||
entity_category="diagnostic",
|
||||
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizePlanTime")),
|
||||
),
|
||||
SensorDesc(
|
||||
key="filter_clean_remain_time",
|
||||
field="x.com.samsung.da.filterCleanRemainTime",
|
||||
icon="mdi:timer-sand",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
FAVORITE_CAPACITY = Capability(
|
||||
href='/favorite/capacity/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/favorite/capacity/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity',
|
||||
icon='mdi:star-outline',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'capacity', 'vs', '0'],
|
||||
{'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})),
|
||||
SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity',
|
||||
icon='mdi:cup-water',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.capacityList',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'capacity', 'vs', '0'],
|
||||
{'x.com.samsung.da.defaultCapacity': p})),
|
||||
SwitchDesc(
|
||||
key="favorite_capacity_enabled",
|
||||
field="x.com.samsung.da.switchCapacity",
|
||||
icon="mdi:star-outline",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "capacity", "vs", "0"],
|
||||
{"x.com.samsung.da.switchCapacity": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="favorite_capacity",
|
||||
field="x.com.samsung.da.defaultCapacity",
|
||||
icon="mdi:cup-water",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.capacityList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "capacity", "vs", "0"],
|
||||
{"x.com.samsung.da.defaultCapacity": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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."""
|
||||
rep = resources.get('/status/lock/vs/0')
|
||||
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
|
||||
if not rep:
|
||||
return False
|
||||
return 'x.com.samsung.da.hotwaterLock' not in rep
|
||||
return "x.com.samsung.da.hotwaterLock" not in rep
|
||||
|
||||
|
||||
FAVORITE_HOTWATER = Capability(
|
||||
href='/favorite/hotwater/vs/0',
|
||||
poll_tier='cold',
|
||||
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.
|
||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
|
||||
device_class='lock',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != 'Unlocked',
|
||||
exists_fn=lambda rep, resources: (
|
||||
'x.com.samsung.da.switchHotwater' in rep
|
||||
and _status_lock_definitely_lacks_hotwater_field(resources)),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'hotwater', 'vs', '0'],
|
||||
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
# Issue #196: `supportedList` is only the four *fixed* presets
|
||||
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
|
||||
# the user add one custom value to their own display list via its
|
||||
# "temperatures to display" editor (a wheel picker bounded by
|
||||
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
|
||||
# and that custom value shows up in `showList`
|
||||
# (['40', '50', '75', '85', '90'] here) but never in
|
||||
# `supportedList`. Reading options from `supportedList` meant a
|
||||
# unit whose current default *was* that custom value rendered as
|
||||
# "unknown" -- not a coverage gap, just the wrong field. `showList`
|
||||
# is a superset of `supportedList` that always includes whatever
|
||||
# the current default actually is, custom or not.
|
||||
SelectDesc(key='favorite_hotwater_temperature',
|
||||
field='x.com.samsung.da.favorite.defaultTemperature',
|
||||
icon='mdi:thermometer',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.favorite.showList',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'hotwater', 'vs', '0'],
|
||||
{'x.com.samsung.da.favorite.defaultTemperature': p})),
|
||||
# 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.
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.switchHotwater",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
exists_fn=lambda rep, resources: (
|
||||
"x.com.samsung.da.switchHotwater" in rep
|
||||
and _status_lock_definitely_lacks_hotwater_field(resources)
|
||||
),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "hotwater", "vs", "0"],
|
||||
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
|
||||
),
|
||||
),
|
||||
# Issue #196: `supportedList` is only the four fixed presets -- 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",
|
||||
icon="mdi:thermometer",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.favorite.showList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "hotwater", "vs", "0"],
|
||||
{"x.com.samsung.da.favorite.defaultTemperature": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
|
||||
# on this resource, unlike the rest of the water-purifier surface.
|
||||
COFFEE = Capability(
|
||||
href='/favorite/coffee/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/favorite/coffee/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate',
|
||||
icon='mdi:coffee-outline',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'coffee', 'vs', '0'],
|
||||
{'favorite.activate': 'On' if p == 'On' else 'Off'})),
|
||||
SensorDesc(key='coffee_brew_status', field='brew.status',
|
||||
icon='mdi:coffee-outline',
|
||||
entity_category='diagnostic'),
|
||||
SwitchDesc(
|
||||
key="favorite_coffee_enabled",
|
||||
field="favorite.activate",
|
||||
icon="mdi:coffee-outline",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["favorite", "coffee", "vs", "0"],
|
||||
{"favorite.activate": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="coffee_brew_status",
|
||||
field="brew.status",
|
||||
icon="mdi:coffee-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -221,143 +247,167 @@ COFFEE = Capability(
|
||||
# the full state domain isn't confirmed, so this stays a plain diagnostic
|
||||
# sensor rather than an enum with an invented state table.
|
||||
CUP_STATE = Capability(
|
||||
href='/cup/state/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/cup/state/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(key='cup_state', field='water.cup.state',
|
||||
icon='mdi:cup-outline', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="cup_state",
|
||||
field="water.cup.state",
|
||||
icon="mdi:cup-outline",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
href="/settings/sound/mode/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SelectDesc(key='sound_mode', translation_key='water_purifier_sound_mode',
|
||||
field='mode',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedModes',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
translation_key="water_purifier_sound_mode",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "mode", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_OUTPUT = Capability(
|
||||
href='/settings/sound/output/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/settings/sound/output/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='sound_output', field='deviceType',
|
||||
icon='mdi:volume-high', entity_category='diagnostic'),
|
||||
SensorDesc(
|
||||
key="sound_output",
|
||||
field="deviceType",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
# No confirmed write contract (no sibling field advertising this as
|
||||
# user-settable) -- surfaced read-only per the 'don't guess' rule.
|
||||
BinarySensorDesc(key='alarm_in_mute', field='alarmInMute',
|
||||
icon='mdi:volume-mute',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
BinarySensorDesc(
|
||||
key="alarm_in_mute",
|
||||
field="alarmInMute",
|
||||
icon="mdi:volume-mute",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: str(v).lower() == "true",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
poll_tier='cold',
|
||||
href="/settings/sound/volume/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
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,
|
||||
value_fn=int_or_none,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['settings', 'sound', 'volume', 'vs', '0'],
|
||||
{'level': str(int(p))})),
|
||||
NumberDesc(
|
||||
key="sound_volume",
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
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,
|
||||
value_fn=int_or_none,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "volume", "vs", "0"],
|
||||
{"level": str(int(p))},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
href="/statistic/pour/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(key='last_pour_type', field='last.type',
|
||||
icon='mdi:cup-water', entity_category='diagnostic'),
|
||||
SensorDesc(key='last_pour_capacity', field='last.capacity',
|
||||
icon='mdi:cup-water', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(
|
||||
key="last_pour_type",
|
||||
field="last.type",
|
||||
icon="mdi:cup-water",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="last_pour_capacity",
|
||||
field="last.capacity",
|
||||
icon="mdi:cup-water",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
LOCK = Capability(
|
||||
href='/status/lock/vs/0',
|
||||
poll_tier='warm',
|
||||
href="/status/lock/vs/0",
|
||||
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.
|
||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
|
||||
device_class='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),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock',
|
||||
device_class='lock',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != 'Unlocked',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock',
|
||||
device_class='lock',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != 'Unlocked',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
# above (issue #144); see the comment there. A stub rep ({}) still
|
||||
# counts as "present" here, matching entity.py's own default.
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.hotwaterLock",
|
||||
device_class="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,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["status", "lock", "vs", "0"],
|
||||
{"x.com.samsung.da.hotwaterLock": "Locked" if p == "On" else "Unlocked"},
|
||||
),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="coldwater_lock",
|
||||
field="x.com.samsung.da.coldwaterLock",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["status", "lock", "vs", "0"],
|
||||
{"x.com.samsung.da.coldwaterLock": "Locked" if p == "On" else "Unlocked"},
|
||||
),
|
||||
),
|
||||
SwitchDesc(
|
||||
key="buzz_lock",
|
||||
field="x.com.samsung.da.buzzLock",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["status", "lock", "vs", "0"],
|
||||
{"x.com.samsung.da.buzzLock": "Locked" if p == "On" else "Unlocked"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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).
|
||||
'/mode/vs/0',
|
||||
# Static support-flags blob (automation.supported.modes/options) -- no
|
||||
# live "current automation setting" field to expose.
|
||||
'/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.
|
||||
'/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
|
||||
'/recipe/coffee/deletion/vs/0', # empty {} on this dump
|
||||
# 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 -- no live "current setting" field.
|
||||
"/automation/waterpurifier/vs/0",
|
||||
# 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", # allowed custom-recipe slot IDs
|
||||
"/recipe/coffee/vs/0", # same shape, no per-recipe content
|
||||
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
|
||||
]
|
||||
|
||||
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
"""A Capability binds one OCF resource href to the entities it produces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .entities import SamsungEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class Capability:
|
||||
href: Optional[str] = None
|
||||
href: str | None = None
|
||||
entities: tuple[SamsungEntityDescription, ...] = ()
|
||||
poll_tier: str = 'cold' # 'hot' | 'warm' | 'cold'
|
||||
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
|
||||
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
|
||||
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
|
||||
poll_tier: str = "cold" # 'hot' | 'warm' | 'cold'
|
||||
rt_filter: str | None = None # bind only if rt_filter in rep.get('rt', ())
|
||||
href_prefix: str | None = None # pattern caps only: bind only if href starts with this
|
||||
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
|
||||
# Rep field holding this instance's device-given name (e.g. an ice
|
||||
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
|
||||
# name prefix in place of the href-derived instance label. Does not
|
||||
# affect key_override/unique_id -- only what's shown in the UI.
|
||||
name_field: Optional[str] = None
|
||||
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
|
||||
name_field: str | None = None
|
||||
match_fn: Callable[[dict, dict], bool] | None = None # match_fn(rep, resources) -> bool
|
||||
# Rare optional hook — only operational-state-style resources use this.
|
||||
on_observation: Optional[Callable[[dict, dict], None]] = None
|
||||
project: Optional[Callable[[dict, dict], dict]] = None
|
||||
on_observation: Callable[[dict, dict], None] | None = None
|
||||
project: Callable[[dict, dict], dict] | None = None
|
||||
|
||||
@@ -11,10 +11,11 @@ doesn't have that filter) is *not* a gap — a maintainer already looked at
|
||||
that href and decided how to handle it. Only hrefs absent from the registry
|
||||
entirely are reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable, Optional
|
||||
|
||||
from .capability import Capability
|
||||
from .entities import SamsungEntityDescription
|
||||
@@ -26,24 +27,23 @@ class BoundEntity:
|
||||
href: str
|
||||
capability: Capability
|
||||
desc: SamsungEntityDescription
|
||||
instance: str = ''
|
||||
key_override: Optional[str] = None
|
||||
instance_name: Optional[str] = None
|
||||
instance: str = ""
|
||||
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
|
||||
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
||||
_derive_name, which applies the same transform to an href-derived key."""
|
||||
return s.replace('_', ' ').title()
|
||||
return s.replace("_", " ").title()
|
||||
|
||||
|
||||
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
||||
def _instance_name(cap: Capability, rep: dict) -> str | None:
|
||||
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
|
||||
for use as a display-name prefix, or None if the cap doesn't declare
|
||||
one or the device didn't report it."""
|
||||
@@ -57,15 +57,20 @@ def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
||||
|
||||
def instance_suffix(href: str) -> str:
|
||||
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
|
||||
tail = href.rstrip('/').rsplit('/', 1)[-1]
|
||||
if tail.isdigit() and tail != '0':
|
||||
return f'_{tail}'
|
||||
return ''
|
||||
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||
if tail.isdigit() and tail != "0":
|
||||
return f"_{tail}"
|
||||
return ""
|
||||
|
||||
|
||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
key_prefix: Optional[str] = None,
|
||||
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
|
||||
def _bind(
|
||||
cap: Capability,
|
||||
href: str,
|
||||
inst: str,
|
||||
inst_name: str | None,
|
||||
key_prefix: str | None = None,
|
||||
subdevice: Subdevice = MAIN,
|
||||
) -> list[BoundEntity]:
|
||||
"""Build one BoundEntity per entity on `cap`, sharing the instance/
|
||||
key-prefix/instance-name computed once by the caller.
|
||||
|
||||
@@ -74,10 +79,15 @@ def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
actually reads/writes (identity for MAIN, so single-subdevice devices are
|
||||
unaffected -- see subdevices.py)."""
|
||||
return [
|
||||
BoundEntity(href=subdevice.to_actual(href), capability=cap, desc=desc,
|
||||
instance=inst,
|
||||
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
|
||||
instance_name=inst_name, subdevice=subdevice)
|
||||
BoundEntity(
|
||||
href=subdevice.to_actual(href),
|
||||
capability=cap,
|
||||
desc=desc,
|
||||
instance=inst,
|
||||
key_override=f"{key_prefix}_{desc.key}" if key_prefix else None,
|
||||
instance_name=inst_name,
|
||||
subdevice=subdevice,
|
||||
)
|
||||
for desc in cap.entities
|
||||
]
|
||||
|
||||
@@ -86,35 +96,30 @@ def discover(
|
||||
resources: dict[str, dict],
|
||||
registry: dict[str, list[Capability]],
|
||||
pattern_caps: Iterable[Capability] = (),
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
||||
log: Callable[[str], None] | None = None,
|
||||
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] = []
|
||||
|
||||
for href, rep in resources.items():
|
||||
if not isinstance(rep, dict):
|
||||
continue
|
||||
rts = rep.get('rt') or ()
|
||||
rts = rep.get("rt") or ()
|
||||
caps = registry.get(href) or []
|
||||
matched = False
|
||||
|
||||
@@ -142,10 +147,17 @@ def discover(
|
||||
continue
|
||||
inst = instance_suffix(href)
|
||||
# Auto-derive key prefix from href segments (skip digits and 'vs')
|
||||
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
|
||||
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
|
||||
subdevice=subdevice))
|
||||
src = (
|
||||
href[len(cap.href_prefix) :]
|
||||
if (cap.strip_prefix_in_key and cap.href_prefix)
|
||||
else href
|
||||
)
|
||||
segs = [s for s in src.strip("/").split("/") if s and not s.isdigit() and s != "vs"]
|
||||
out.extend(
|
||||
_bind(
|
||||
cap, href, inst, _instance_name(cap, rep), "_".join(segs), subdevice=subdevice
|
||||
)
|
||||
)
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
|
||||
@@ -6,17 +6,19 @@ presence gating in exists_fn; write logic in write_fn on command platforms;
|
||||
pre-write rejection (surfaced to the user, not just logged) in validate_fn
|
||||
where a description declares one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
from typing import Any
|
||||
|
||||
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
|
||||
WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
|
||||
# (payload, rep, resources) -> a translation key, or None to allow the
|
||||
# write. resources is the coordinator's full href->rep snapshot, for the same
|
||||
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
||||
# option list).
|
||||
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
|
||||
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -26,88 +28,86 @@ def _identity(v: Any) -> Any:
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SamsungEntityDescription:
|
||||
key: str
|
||||
field: str = ''
|
||||
field: str = ""
|
||||
# Defaults to `key`: entity names and states live in translations/, never
|
||||
# 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.
|
||||
translation_placeholders: Optional[Mapping[str, str]] = None
|
||||
# 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.
|
||||
use_instance_name: bool = False
|
||||
icon: Optional[str] = None
|
||||
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
|
||||
icon: str | None = None
|
||||
entity_category: str | None = None # 'diagnostic' | 'config' | None
|
||||
enabled_default: bool = True
|
||||
value_fn: Callable[[Any], Any] = _identity
|
||||
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
|
||||
rep_fn: Callable[[dict], Any] | None = None # replaces field+value_fn; receives full rep
|
||||
# (rep, resources): rep is this entity's own href's representation;
|
||||
# resources is the coordinator's full href->rep snapshot, for gating
|
||||
# presence on a sibling resource (e.g. laundry.cycle_options's source).
|
||||
exists_fn: Optional[Callable[[dict, dict], bool]] = None
|
||||
exists_fn: Callable[[dict, dict], bool] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SensorDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
state_class: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||
options: Optional[tuple] = None # required by HA when device_class == 'enum'
|
||||
# Opt-in: gate this sensor's reported value behind the user-configurable
|
||||
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
|
||||
# values that are expected to jitter around their "true" value between
|
||||
# device-side revisions -- not a general-purpose flag every sensor should set.
|
||||
device_class: str | None = None
|
||||
state_class: str | None = None
|
||||
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 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
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BinarySensorDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None # value_fn must return bool
|
||||
device_class: str | None = None # value_fn must return bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SelectDesc(SamsungEntityDescription):
|
||||
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
|
||||
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
|
||||
# callable form receives the coordinator's full href->rep resource
|
||||
# snapshot (not just this entity's own href) and returns raw device
|
||||
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||
options_field: Optional[str] = None # resource field that contains the live options list
|
||||
options_field: str | None = None # resource field that contains the live options list
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SwitchDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
device_class: str | None = None
|
||||
write_fn: WriteFn = None
|
||||
validate_fn: ValidateFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ButtonDesc(SamsungEntityDescription):
|
||||
payload: str = ''
|
||||
payload: str = ""
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class NumberDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||
native_min: Optional[float] = None
|
||||
native_max: Optional[float] = None
|
||||
step: Optional[float] = 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).
|
||||
native_min_fn: Optional[Callable[[dict], float]] = None
|
||||
native_max_fn: Optional[Callable[[dict], float]] = None
|
||||
step_fn: Optional[Callable[[dict], float]] = None
|
||||
range_field: Optional[str] = None # resource field containing [min, max] list
|
||||
device_class: str | None = None
|
||||
unit: str | None = None
|
||||
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
|
||||
native_min: float | None = None
|
||||
native_max: float | None = None
|
||||
step: float | None = None
|
||||
# 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
|
||||
range_field: str | None = None # resource field containing [min, max] list
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@@ -118,29 +118,36 @@ 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, same (kind, value) -> (path_segs,
|
||||
# body) write_fn shape as ClimateDesc/FanDesc.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
PLATFORM_OF: dict[type, str] = {
|
||||
SensorDesc: 'sensor',
|
||||
BinarySensorDesc: 'binary_sensor',
|
||||
SelectDesc: 'select',
|
||||
SwitchDesc: 'switch',
|
||||
ButtonDesc: 'button',
|
||||
NumberDesc: 'number',
|
||||
TimeDesc: 'time',
|
||||
ClimateDesc: 'climate',
|
||||
FanDesc: 'fan',
|
||||
SensorDesc: "sensor",
|
||||
BinarySensorDesc: "binary_sensor",
|
||||
SelectDesc: "select",
|
||||
SwitchDesc: "switch",
|
||||
ButtonDesc: "button",
|
||||
NumberDesc: "number",
|
||||
TimeDesc: "time",
|
||||
ClimateDesc: "climate",
|
||||
FanDesc: "fan",
|
||||
WaterHeaterDesc: "water_heater",
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import cbor2
|
||||
|
||||
@@ -12,9 +12,74 @@ class DeviceIdentity:
|
||||
manufacturer: str
|
||||
model: str
|
||||
name: str
|
||||
serial: Optional[str]
|
||||
serial: str | None
|
||||
device_types: tuple[str, ...] = ()
|
||||
raw: dict[str, dict] = field(default_factory=dict)
|
||||
raw: dict[str, dict | list] = field(default_factory=dict)
|
||||
|
||||
|
||||
def is_placeholder_serial(serial: str) -> bool:
|
||||
"""True for a non-empty serialNum that isn't actually a real identity.
|
||||
|
||||
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 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 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 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"):
|
||||
return True
|
||||
upper = s.upper()
|
||||
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
|
||||
|
||||
|
||||
def resolve_serial(raw_serial: str | None, host: str) -> str:
|
||||
"""The device identity to mint registry keys from.
|
||||
|
||||
`raw_serial` is /information/vs/0's x.com.samsung.da.serialNum as the
|
||||
device reported it. Boards that report nothing usable fall back to the
|
||||
host, which is stable per install and unique across devices on one
|
||||
network -- see is_placeholder_serial for the two families that need it.
|
||||
"""
|
||||
s = (raw_serial or "").strip()
|
||||
if not s or is_placeholder_serial(s):
|
||||
return host
|
||||
return s
|
||||
|
||||
|
||||
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 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]
|
||||
return identity.model if identity else ""
|
||||
|
||||
|
||||
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 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}"
|
||||
|
||||
|
||||
def _get(sess, path) -> dict:
|
||||
@@ -45,17 +110,15 @@ 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')
|
||||
rt = d.get("rt")
|
||||
if isinstance(rt, str):
|
||||
rt = [rt]
|
||||
if not isinstance(rt, (list, tuple)):
|
||||
@@ -63,30 +126,26 @@ def _device_types(d: dict) -> tuple[str, ...]:
|
||||
return tuple(t for t in rt if isinstance(t, str))
|
||||
|
||||
|
||||
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
|
||||
p = _get(sess, ['oic', 'p'])
|
||||
d = _get(sess, ['oic', 'd'])
|
||||
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.
|
||||
res = _get_links(sess, ['oic', 'res'])
|
||||
# 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',
|
||||
model=p.get('mnmo') or '',
|
||||
name=d.get('n') or '',
|
||||
manufacturer=p.get("mnmn") or "Samsung",
|
||||
model=p.get("mnmo") or "",
|
||||
name=d.get("n") or "",
|
||||
serial=serial,
|
||||
device_types=_device_types(d),
|
||||
# Kept whole rather than field-by-field: these resources are outside
|
||||
# the /device/0 dump diagnostics already captures, and we don't yet
|
||||
# know which of their fields will turn out to identify a device type.
|
||||
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res},
|
||||
# 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},
|
||||
)
|
||||
|
||||
@@ -10,29 +10,33 @@ under — new device types will have unknown-shaped data we can't fully
|
||||
enumerate in advance, so this errs on catching the field by name rather
|
||||
than only redacting inside hrefs we already recognize.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
REDACTED = "**REDACTED**"
|
||||
|
||||
_SENSITIVE_SUBSTRINGS = (
|
||||
'mac', 'serial', 'token', 'login', 'account', 'email',
|
||||
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
|
||||
"mac",
|
||||
"serial",
|
||||
"token",
|
||||
"login",
|
||||
"account",
|
||||
"email",
|
||||
"userid",
|
||||
"deviceid",
|
||||
"uuid",
|
||||
"duid",
|
||||
"password",
|
||||
"secret",
|
||||
)
|
||||
|
||||
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
|
||||
# with bare one- and two-letter keys that the rules above cannot see, being
|
||||
# far too short to match on -- 'di' alone is a substring of 'condition',
|
||||
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
|
||||
#
|
||||
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
|
||||
# number above.
|
||||
# 'n' -- /oic/d's device name. Free text the owner can set from the
|
||||
# SmartThings app, so it may well carry a person's name. Nothing
|
||||
# in the /device/0 dump has ever exposed it; it only became
|
||||
# reachable when diagnostics started reporting /oic/d, and the
|
||||
# device-type signal we actually want from that resource is `rt`,
|
||||
# which is not redacted.
|
||||
_SENSITIVE_EXACT = frozenset({'di', 'pi', 'n'})
|
||||
# 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"})
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
|
||||
@@ -7,6 +7,7 @@ Raises ValueError at import if any href group contains an unfiltered cap
|
||||
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
|
||||
in a group with multiple caps).
|
||||
"""
|
||||
|
||||
from .capabilities import ALL
|
||||
from .capability import Capability
|
||||
|
||||
@@ -32,8 +33,7 @@ def _build() -> dict[str, list[Capability]]:
|
||||
# Validate: every group with >1 cap must have all caps filtered
|
||||
for href, caps in out.items():
|
||||
if len(caps) > 1:
|
||||
unfiltered = [c for c in caps
|
||||
if c.rt_filter is None and c.match_fn is None]
|
||||
unfiltered = [c for c in caps if c.rt_filter is None and c.match_fn is None]
|
||||
if unfiltered:
|
||||
raise ValueError(
|
||||
f"duplicate capability href {href!r} has unfiltered cap(s); "
|
||||
|
||||
@@ -1,101 +1,62 @@
|
||||
"""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`.
|
||||
|
||||
Both 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
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
import cbor2
|
||||
|
||||
from .batch import parse_device0_batch
|
||||
from .by_type._base import DeviceRegistry
|
||||
|
||||
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
|
||||
_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).
|
||||
_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)
|
||||
|
||||
|
||||
@@ -104,116 +65,110 @@ 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'
|
||||
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
|
||||
kind: str # 'main' | 'indexed' | 'prefixed'
|
||||
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
seed_path: tuple[str, ...]
|
||||
flat_hrefs: tuple[str, ...] = ()
|
||||
|
||||
def to_actual(self, canonical: str) -> str:
|
||||
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
|
||||
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.
|
||||
if tail == '0':
|
||||
return f'{head}{sep}{self.key}'
|
||||
if self.kind == "indexed":
|
||||
head, sep, tail = canonical.rpartition("/")
|
||||
# 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
|
||||
if self.kind == 'prefixed':
|
||||
return f'/{self.key}{canonical}'
|
||||
if self.kind == "prefixed":
|
||||
return f"/{self.key}{canonical}"
|
||||
return canonical
|
||||
|
||||
def to_canonical(self, actual: str) -> Optional[str]:
|
||||
def to_canonical(self, actual: str) -> str | None:
|
||||
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
|
||||
if self.kind == 'indexed':
|
||||
head, sep, tail = actual.rpartition('/')
|
||||
if self.kind == "indexed":
|
||||
head, sep, tail = actual.rpartition("/")
|
||||
if tail == self.key:
|
||||
return f'{head}{sep}0'
|
||||
return f"{head}{sep}0"
|
||||
return None
|
||||
if self.kind == 'prefixed':
|
||||
prefix = f'/{self.key}'
|
||||
if actual.startswith(prefix + '/'):
|
||||
return actual[len(prefix):]
|
||||
if self.kind == "prefixed":
|
||||
prefix = f"/{self.key}"
|
||||
if actual.startswith(prefix + "/"):
|
||||
return actual[len(prefix) :]
|
||||
return None
|
||||
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)."""
|
||||
if self.kind == 'main':
|
||||
"""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}_'
|
||||
if self.kind == 'prefixed':
|
||||
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
|
||||
return f'subdevice_{slug}_'
|
||||
return ''
|
||||
if self.kind == "indexed":
|
||||
return f"subdevice{self.key}_"
|
||||
if self.kind == "prefixed":
|
||||
slug = re.sub(r"[^a-zA-Z0-9]", "", self.key)
|
||||
return f"subdevice_{slug}_"
|
||||
return ""
|
||||
|
||||
|
||||
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
|
||||
MAIN = Subdevice(kind="main", key="", seed_path=("device", "0"))
|
||||
|
||||
|
||||
def canonical_view(
|
||||
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
|
||||
subdevice: Subdevice,
|
||||
resources: dict[str, dict],
|
||||
subdevices: list[Subdevice],
|
||||
) -> dict[str, dict]:
|
||||
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
|
||||
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)
|
||||
}
|
||||
if subdevice.kind == "main":
|
||||
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
|
||||
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
|
||||
return {
|
||||
canon: resources[actual]
|
||||
@@ -227,17 +182,15 @@ 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':
|
||||
if subdevice.kind != "prefixed":
|
||||
return batch
|
||||
prefix = f'/{subdevice.key}'
|
||||
prefix = f"/{subdevice.key}"
|
||||
return {
|
||||
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
|
||||
(href if href.startswith(prefix + "/") else f"{prefix}{href}"): rep
|
||||
for href, rep in batch.items()
|
||||
}
|
||||
|
||||
@@ -247,18 +200,17 @@ 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 []):
|
||||
for entry in oic_res or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if 'links' in entry:
|
||||
for link in entry.get('links') or []:
|
||||
if "links" in entry:
|
||||
for link in entry.get("links") or []:
|
||||
if isinstance(link, dict):
|
||||
yield link
|
||||
elif 'href' in entry:
|
||||
elif "href" in entry:
|
||||
yield entry
|
||||
|
||||
|
||||
@@ -266,14 +218,13 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
|
||||
"""('device', '1') -> '/device/1' -- the leading-slash href form
|
||||
`probe_log` and diagnostics report, built from the path-segment form
|
||||
`sess.get` takes."""
|
||||
return '/' + '/'.join(path_segs)
|
||||
return "/" + "/".join(path_segs)
|
||||
|
||||
|
||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
"""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)
|
||||
if code == 0x45 and pl:
|
||||
@@ -293,10 +244,8 @@ def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
||||
|
||||
def _get_property(sess, path_segs: tuple[str, ...]) -> 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."""
|
||||
batch). Used for `/multidevice/vs/0`: listed in `/oic/res` but absent
|
||||
from `/device/0`'s batch, so it needs its own RETRIEVE."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
@@ -305,165 +254,186 @@ def enumerate_subdevices(
|
||||
sess,
|
||||
resources: dict[str, dict],
|
||||
oic_res_links,
|
||||
probe_log: Optional[Callable[[str, bool], None]] = None,
|
||||
) -> tuple[list['Subdevice'], dict[str, dict]]:
|
||||
probe_log: Callable[[str, bool], None] | None = None,
|
||||
) -> 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".
|
||||
|
||||
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, and probing it twice would materialize the same physical
|
||||
# subdevice as two Subdevice candidates.
|
||||
probed_ids: set[str] = set()
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
probe_log(seed_href, bool(batch))
|
||||
|
||||
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
||||
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
|
||||
'x.com.samsung.da.subdeviceIdList')
|
||||
# Tolerate anything but a list of strings -- this field is redaction-prone
|
||||
# (it matches the 'deviceid' substring rule in redact.py) and the existing
|
||||
# airconditioner_fac_bora fixture carries the literal string
|
||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
|
||||
# crash -- issue #177 is additive, it must never break an already-working
|
||||
# single-climate-entity device.
|
||||
ids = raw_ids if isinstance(raw_ids, list) else []
|
||||
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
|
||||
seed = (sub_id, 'device', '0')
|
||||
def _probe_prefixed(sub_id: str) -> None:
|
||||
"""Materialize one UUID-prefixed subdevice candidate -- shared by
|
||||
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
|
||||
/oic/res link prefixes) below, which differ only in where the UUID
|
||||
came from."""
|
||||
if sub_id.lower() in probed_ids:
|
||||
return
|
||||
probed_ids.add(sub_id.lower())
|
||||
seed = (sub_id, "device", "0")
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if batch:
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
||||
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
continue
|
||||
# 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
|
||||
return
|
||||
# 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.
|
||||
# device's siblings share the master's own resource surface -- so
|
||||
# probe every href the master answered this cycle, individually,
|
||||
# under this UUID's prefix, and keep whichever answer. Each is a
|
||||
# plain tolerated-404 RETRIEVE.
|
||||
#
|
||||
# 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):
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
actual = f'/{sub_id}{href}'
|
||||
rep = _get_property(sess, tuple(actual.strip('/').split('/')))
|
||||
_probed(actual, bool(rep))
|
||||
actual = f"/{sub_id}{href}"
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
|
||||
_probed(actual, rep)
|
||||
if rep:
|
||||
flat_hrefs.append(href)
|
||||
fetched[actual] = rep
|
||||
if not flat_hrefs:
|
||||
continue
|
||||
subdevices.append(Subdevice(
|
||||
kind='prefixed', key=sub_id, seed_path=(),
|
||||
flat_hrefs=tuple(flat_hrefs),
|
||||
))
|
||||
return
|
||||
subdevices.append(
|
||||
Subdevice(
|
||||
kind="prefixed",
|
||||
key=sub_id,
|
||||
seed_path=(),
|
||||
flat_hrefs=tuple(flat_hrefs),
|
||||
)
|
||||
)
|
||||
|
||||
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
||||
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
|
||||
# Tolerate anything but a list of strings -- this field is
|
||||
# redaction-prone (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)
|
||||
|
||||
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
||||
# (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 every UUID path prefix seen in
|
||||
# /oic/res is treated as a candidate. _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
|
||||
}
|
||||
)
|
||||
for sub_id in linked:
|
||||
_probe_prefixed(sub_id)
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted({
|
||||
int(m.group(1))
|
||||
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||
for m in [_INDEXED_HREF_RE.match(link.get('href', ''))]
|
||||
if m and int(m.group(1)) >= 1
|
||||
})
|
||||
indices = sorted(
|
||||
{
|
||||
int(m.group(1))
|
||||
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||
for m in [_INDEXED_HREF_RE.match(link.get("href", ""))]
|
||||
if m and int(m.group(1)) >= 1
|
||||
}
|
||||
)
|
||||
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:
|
||||
seed = ('device', str(n))
|
||||
seed = ("device", str(n))
|
||||
batch = _get_batch(sess, seed)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
continue
|
||||
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
|
||||
subdevice = Subdevice(kind="indexed", key=str(n), seed_path=seed)
|
||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
|
||||
# board lists it in /oic/res but it never appears in /device/0's batch,
|
||||
# so it needs its own RETRIEVE. It's a plain corroborating count
|
||||
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
||||
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
||||
# into the merged resources dict like any other href (see
|
||||
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
|
||||
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
|
||||
# liveness check decides materialization correctly without it, and only
|
||||
# this one board family is known to expose it at all. Whether it agrees
|
||||
# with the number of subdevices actually materialized is the
|
||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||
# not this function's.
|
||||
multidevice_seed = ('multidevice', 'vs', '0')
|
||||
# /multidevice/vs/0: listed in /oic/res on some boards but never in
|
||||
# /device/0's batch, so it needs its own RETRIEVE. A plain corroborating
|
||||
# count (numofsubdevice), confirmed read-only -- captured for
|
||||
# diagnostics only, folded into the merged resources dict like any
|
||||
# other href (see airconditioner._AC_IGNORED). Not a gate:
|
||||
# discover_partitioned's entity-level liveness check decides
|
||||
# materialization without it.
|
||||
multidevice_seed = ("multidevice", "vs", "0")
|
||||
multidevice = _get_property(sess, multidevice_seed)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched['/multidevice/vs/0'] = 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`).
|
||||
_METER_STATE_CLASSES = frozenset({'total', 'total_increasing'})
|
||||
_METER_DEVICE_CLASSES = frozenset({'energy', 'water', 'gas'})
|
||||
# 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"})
|
||||
|
||||
|
||||
def _is_meter(desc) -> bool:
|
||||
@@ -471,123 +441,104 @@ def _is_meter(desc) -> bool:
|
||||
constants above. Only SensorDesc carries either attribute; everything
|
||||
else answers False through the getattr defaults."""
|
||||
return (
|
||||
getattr(desc, 'state_class', None) in _METER_STATE_CLASSES
|
||||
or getattr(desc, 'device_class', None) in _METER_DEVICE_CLASSES
|
||||
getattr(desc, "state_class", None) in _METER_STATE_CLASSES
|
||||
or getattr(desc, "device_class", None) in _METER_DEVICE_CLASSES
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
return any(
|
||||
not b.desc.entity_category and not _is_meter(b.desc)
|
||||
and state.get(_key(b)) is not None
|
||||
not b.desc.entity_category and not _is_meter(b.desc) and state.get(_key(b)) is not None
|
||||
for b in bound
|
||||
)
|
||||
|
||||
|
||||
def discover_partitioned(
|
||||
resources: dict[str, dict],
|
||||
subdevices: list['Subdevice'],
|
||||
resolve_registry: Callable[..., object],
|
||||
subdevices: list[Subdevice],
|
||||
resolve_registry: Callable[..., DeviceRegistry | None],
|
||||
fallback_capabilities: dict,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
tier_log: Optional[Callable[[str, str], None]] = None,
|
||||
log: Callable[[str], None] | None = None,
|
||||
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)
|
||||
caps, pats = (
|
||||
(reg.capabilities, reg.pattern_capabilities) if reg is not None
|
||||
(reg.capabilities, reg.pattern_capabilities)
|
||||
if reg is not None
|
||||
else (fallback_capabilities, [])
|
||||
)
|
||||
# MAIN is never gated -- the config entry's own physical connection
|
||||
@@ -602,7 +553,8 @@ def discover_partitioned(
|
||||
view = canonical_view(su, resources, subdevices)
|
||||
su_reg = resolve_registry(view) or reg
|
||||
su_caps, su_pats = (
|
||||
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
|
||||
(su_reg.capabilities, su_reg.pattern_capabilities)
|
||||
if su_reg is not None
|
||||
else (fallback_capabilities, [])
|
||||
)
|
||||
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
|
||||
@@ -610,12 +562,19 @@ def discover_partitioned(
|
||||
if _has_live_primary_entity(probe_bound, probe_state):
|
||||
materialized.append(su)
|
||||
bound = bound + discover(
|
||||
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
|
||||
view,
|
||||
su_caps,
|
||||
su_pats,
|
||||
log=log,
|
||||
tier_log=tier_log,
|
||||
subdevice=su,
|
||||
)
|
||||
else:
|
||||
skipped.append(SkippedSubdevice(
|
||||
subdevice=su,
|
||||
hrefs=tuple(sorted({b.href for b in probe_bound})),
|
||||
))
|
||||
skipped.append(
|
||||
SkippedSubdevice(
|
||||
subdevice=su,
|
||||
hrefs=tuple(sorted({b.href for b in probe_bound})),
|
||||
)
|
||||
)
|
||||
|
||||
return bound, device_type_name, materialized, skipped
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"""Select platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import SelectDesc
|
||||
|
||||
from .catalog import translated_states
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import SelectDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -30,7 +30,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
|
||||
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||
|
||||
|
||||
def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
@@ -42,70 +42,60 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
knows are normalized -- an unrecognized (or future) vendor value keeps
|
||||
its own readable form rather than becoming an untranslatable slug.
|
||||
"""
|
||||
direct = value.lower().replace(' ', '_')
|
||||
direct = value.lower().replace(" ", "_")
|
||||
if direct in known:
|
||||
return direct
|
||||
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
|
||||
snake = _CAMEL_BOUNDARY_RE.sub("_", value).lower().replace(" ", "_")
|
||||
return snake if snake in known else None
|
||||
|
||||
|
||||
def _display(value, translation_key: Optional[str]):
|
||||
def _display(value, translation_key: str | 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)
|
||||
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.
|
||||
# No state table for this key: either untranslated, or its
|
||||
# options deliberately aren't (an unrecognized course table).
|
||||
return value
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
||||
return value.replace("_", " ").title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(" ", value)
|
||||
|
||||
|
||||
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SelectDesc = bound.desc
|
||||
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]
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
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 {}
|
||||
@@ -114,7 +104,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
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._attr_options
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Sensor platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -11,12 +13,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .observe import MODE_OBSERVE, MODE_POLL
|
||||
from .registry.entities import SensorDesc
|
||||
|
||||
from .const import CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DOMAIN
|
||||
from .const import (
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .observe import MODE_OBSERVE, MODE_POLL
|
||||
from .registry.entities import SensorDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -25,7 +30,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [
|
||||
entities: list[SensorEntity] = [
|
||||
LocalThingsSensor(coordinator, b)
|
||||
for b in coordinator.bound
|
||||
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
|
||||
@@ -35,20 +40,21 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SensorDesc = bound.desc
|
||||
desc = cast(SensorDesc, bound.desc)
|
||||
self._attr_native_unit_of_measurement = desc.unit
|
||||
self._attr_device_class = desc.device_class
|
||||
self._attr_state_class = desc.state_class
|
||||
self._attr_device_class = (
|
||||
SensorDeviceClass(desc.device_class) if desc.device_class else None
|
||||
)
|
||||
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
|
||||
if desc.options:
|
||||
self._attr_options = list(desc.options)
|
||||
self._hysteresis_value = None
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: SensorDesc = self._bound.desc
|
||||
desc = cast(SensorDesc, self._bound.desc)
|
||||
if desc.unit_fn is not None:
|
||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||
return self._attr_native_unit_of_measurement
|
||||
@@ -56,7 +62,8 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
if not self._bound.desc.hysteresis:
|
||||
desc = cast(SensorDesc, self._bound.desc)
|
||||
if not desc.hysteresis:
|
||||
return raw
|
||||
return self._apply_hysteresis(raw)
|
||||
|
||||
@@ -75,6 +82,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
immediately -- only in-between jitter while a value already exists
|
||||
on both sides gets held back.
|
||||
"""
|
||||
assert self.coordinator.config_entry is not None
|
||||
threshold_min = self.coordinator.config_entry.options.get(
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
|
||||
)
|
||||
@@ -95,11 +103,11 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
|
||||
Disabled by default — it's for troubleshooting, not everyday use."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = 'connection_mode'
|
||||
_attr_translation_key = "connection_mode"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = [MODE_OBSERVE, MODE_POLL]
|
||||
_attr_options = [MODE_OBSERVE, MODE_POLL] # noqa: RUF012 -- HA `_attr_*` convention
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Switch platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import SwitchDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import SwitchDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -27,18 +27,19 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SwitchDesc = bound.desc
|
||||
self._attr_device_class = desc.device_class
|
||||
self._attr_device_class = (
|
||||
SwitchDeviceClass(desc.device_class) if desc.device_class else None
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, 'On')
|
||||
await self.coordinator.async_send_command(self._bound, "On")
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, 'Off')
|
||||
await self.coordinator.async_send_command(self._bound, "Off")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Time platform for Local Things."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
@@ -8,11 +9,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import TimeDesc
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import TimeDesc
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -29,7 +29,6 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsTime(LocalThingsEntity, TimeEntity):
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime.time | None:
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"auto_clean_running": {
|
||||
"name": "Auto clean running"
|
||||
},
|
||||
"after_run_active": {
|
||||
"name": "After run active"
|
||||
},
|
||||
@@ -114,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"
|
||||
},
|
||||
@@ -188,6 +197,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
"sensing_interval": {
|
||||
"name": "AI Purify interval"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
@@ -199,6 +211,9 @@
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropical night mode"
|
||||
},
|
||||
"zone_target_temperature": {
|
||||
"name": "Zone target temperature"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -272,9 +287,9 @@
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"80": "Delicate",
|
||||
"83": "Express 60",
|
||||
"83": "Normal",
|
||||
"84": "Heavy",
|
||||
"86": "Normal",
|
||||
"86": "Express 60",
|
||||
"90": "Self clean",
|
||||
"0e": "AI Wash",
|
||||
"07": "Pre blast",
|
||||
@@ -319,7 +334,10 @@
|
||||
"1e": "Outdoor",
|
||||
"1f": "Mixed load",
|
||||
"2b": "Self Tub Dry",
|
||||
"4c": "Air Refresh"
|
||||
"4c": "Air Refresh",
|
||||
"51": "Eco Cotton",
|
||||
"53": "AI Dry+",
|
||||
"4e": "Self Dry"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -328,6 +346,15 @@
|
||||
"favorite_hotwater_temperature": {
|
||||
"name": "Favorite hot water temperature"
|
||||
},
|
||||
"filter_alarm_time": {
|
||||
"name": "Filter alarm interval",
|
||||
"state": {
|
||||
"180": "180 hours",
|
||||
"300": "300 hours",
|
||||
"500": "500 hours",
|
||||
"700": "700 hours"
|
||||
}
|
||||
},
|
||||
"finish_sound": {
|
||||
"name": "Finish sound",
|
||||
"state": {
|
||||
@@ -476,6 +503,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": {
|
||||
@@ -603,9 +638,37 @@
|
||||
"none": "Off",
|
||||
"cupboard": "Cupboard"
|
||||
}
|
||||
},
|
||||
"zone_mode": {
|
||||
"name": "Zone mode"
|
||||
},
|
||||
"watertank_light_color": {
|
||||
"name": "Water tank light color",
|
||||
"state": {
|
||||
"warmwhite": "Warm white",
|
||||
"naturalwhite": "Natural white",
|
||||
"coolwhite": "Cool white",
|
||||
"green": "Green",
|
||||
"purple": "Purple",
|
||||
"blue": "Blue",
|
||||
"orange": "Orange",
|
||||
"pink": "Pink"
|
||||
}
|
||||
},
|
||||
"watertank_light_brightness": {
|
||||
"name": "Water tank light brightness"
|
||||
},
|
||||
"air_filter_pm1_threshold": {
|
||||
"name": "PM1 filter alarm threshold"
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Freezer temperature"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"auto_clean_progress": {
|
||||
"name": "Auto clean progress"
|
||||
},
|
||||
"after_run_progress": {
|
||||
"name": "After run progress"
|
||||
},
|
||||
@@ -945,6 +1008,18 @@
|
||||
"icestatus_stop": "Idle",
|
||||
"icestatus_run": "Making ice"
|
||||
}
|
||||
},
|
||||
"zone_temperature": {
|
||||
"name": "Zone temperature"
|
||||
},
|
||||
"watertank_full_alarm_status": {
|
||||
"name": "Water tank full alarm status"
|
||||
},
|
||||
"air_filter_pm1_usage": {
|
||||
"name": "PM1 filter usage"
|
||||
},
|
||||
"air_filter_pm1_usage_hours": {
|
||||
"name": "PM1 filter usage hours"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
@@ -972,6 +1047,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"
|
||||
},
|
||||
@@ -1106,6 +1187,21 @@
|
||||
},
|
||||
"wrinkle_prevent": {
|
||||
"name": "Wrinkle prevent"
|
||||
},
|
||||
"zone_power": {
|
||||
"name": "Zone power"
|
||||
},
|
||||
"away_mode": {
|
||||
"name": "Away mode"
|
||||
},
|
||||
"watertank_light": {
|
||||
"name": "Water tank light"
|
||||
},
|
||||
"uv_led": {
|
||||
"name": "UV LED"
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilation alarm"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1126,6 +1222,17 @@
|
||||
},
|
||||
"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": {
|
||||
"dhw": {
|
||||
"name": "Hot water"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1157,10 +1264,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"title": "Appliance type not recognized",
|
||||
"description": "This appliance's type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
||||
"description": "This appliance reported model \"{model}\", which isn't a type LocalThings recognizes yet. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"no_response": "No response from that IP address — nothing came back on any port in the appliance's local API range (UDP 49152-49160). Check the IP address is correct, the appliance is powered on and on the same network as Home Assistant, and that no firewall is dropping UDP traffic to it.",
|
||||
"ports_closed": "That IP address is reachable, but it actively refused every port in the local API range (UDP 49152-49160). Either it isn't a Samsung appliance, or it's on older firmware that only talks to Samsung's cloud over TCP 8888, which this integration can't use.",
|
||||
"no_dtls_server": "Ports in the local API range are reachable at that address, but nothing there answered a DTLS handshake. Check that the IP address belongs to the appliance and not another device on your network.",
|
||||
"handshake_timeout": "The appliance answered on its local API port but never finished the DTLS handshake. It is usually still holding the session from a previous attempt — wait about a minute and try again.",
|
||||
"cert_rejected": "The appliance rejected the certificate. The CA certificate and key are most likely not the AC14K_M CA this appliance trusts, or they don't pair. The Home Assistant log records the exact alert the appliance sent.",
|
||||
"handshake_failed": "The appliance refused the DTLS handshake for a reason unrelated to the certificate, most likely a protocol or cipher mismatch. The Home Assistant log records the exact alert it sent.",
|
||||
"cloud_unreachable": "Couldn't reach Samsung's cloud gateway to fetch the UUID needed to mint this device's certificate. Check Home Assistant's internet access and try again.",
|
||||
"unexpected_response": "Connected to the device successfully, but it didn't return a usable device description. It may not be an appliance this integration supports. The Home Assistant log records what it sent.",
|
||||
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
|
||||
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
|
||||
"unknown": "Unexpected error. Check the Home Assistant logs for details."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"auto_clean_running": {
|
||||
"name": "Auto clean actief"
|
||||
},
|
||||
"after_run_active": {
|
||||
"name": "Nadraaien actief"
|
||||
},
|
||||
@@ -114,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"
|
||||
},
|
||||
@@ -188,6 +197,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Instelpunt"
|
||||
},
|
||||
"sensing_interval": {
|
||||
"name": "AI Purify-interval"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Instelpunt"
|
||||
},
|
||||
@@ -199,6 +211,9 @@
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropische nachtmodus"
|
||||
},
|
||||
"zone_target_temperature": {
|
||||
"name": "Doeltemperatuur zone"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -272,9 +287,9 @@
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"80": "Delicaat",
|
||||
"83": "Express 60",
|
||||
"83": "Normaal",
|
||||
"84": "Intensief",
|
||||
"86": "Normaal",
|
||||
"86": "Express 60",
|
||||
"90": "Zelfreiniging",
|
||||
"0e": "AI Wash",
|
||||
"07": "Voorspoelen",
|
||||
@@ -319,7 +334,10 @@
|
||||
"1e": "Outdoor",
|
||||
"1f": "Gemengde was",
|
||||
"2b": "Zelf trommel drogen",
|
||||
"4c": "Opfrissen"
|
||||
"4c": "Opfrissen",
|
||||
"51": "Eco katoen",
|
||||
"53": "AI drogen+",
|
||||
"4e": "Zelf drogen"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -328,6 +346,15 @@
|
||||
"favorite_hotwater_temperature": {
|
||||
"name": "Favoriete temperatuur warm water"
|
||||
},
|
||||
"filter_alarm_time": {
|
||||
"name": "Filteralarminterval",
|
||||
"state": {
|
||||
"180": "180 uur",
|
||||
"300": "300 uur",
|
||||
"500": "500 uur",
|
||||
"700": "700 uur"
|
||||
}
|
||||
},
|
||||
"finish_sound": {
|
||||
"name": "Eindgeluid",
|
||||
"state": {
|
||||
@@ -476,6 +503,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": {
|
||||
@@ -603,9 +638,37 @@
|
||||
"none": "Uit",
|
||||
"cupboard": "Kastdroog"
|
||||
}
|
||||
},
|
||||
"zone_mode": {
|
||||
"name": "Zonemodus"
|
||||
},
|
||||
"watertank_light_color": {
|
||||
"name": "Kleur van het waterreservoirlicht",
|
||||
"state": {
|
||||
"warmwhite": "Warm wit",
|
||||
"naturalwhite": "Natuurlijk wit",
|
||||
"coolwhite": "Koel wit",
|
||||
"green": "Groen",
|
||||
"purple": "Paars",
|
||||
"blue": "Blauw",
|
||||
"orange": "Oranje",
|
||||
"pink": "Roze"
|
||||
}
|
||||
},
|
||||
"watertank_light_brightness": {
|
||||
"name": "Helderheid van het waterreservoirlicht"
|
||||
},
|
||||
"air_filter_pm1_threshold": {
|
||||
"name": "PM1-filteralarmdrempel"
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatuur vriesgedeelte"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"auto_clean_progress": {
|
||||
"name": "Auto clean voortgang"
|
||||
},
|
||||
"after_run_progress": {
|
||||
"name": "Voortgang nadraaien"
|
||||
},
|
||||
@@ -945,6 +1008,18 @@
|
||||
"icestatus_stop": "Inactief",
|
||||
"icestatus_run": "IJs maken"
|
||||
}
|
||||
},
|
||||
"zone_temperature": {
|
||||
"name": "Zonetemperatuur"
|
||||
},
|
||||
"watertank_full_alarm_status": {
|
||||
"name": "Status alarm waterreservoir vol"
|
||||
},
|
||||
"air_filter_pm1_usage": {
|
||||
"name": "PM1-filtergebruik"
|
||||
},
|
||||
"air_filter_pm1_usage_hours": {
|
||||
"name": "PM1-filtergebruik in uren"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
@@ -972,6 +1047,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"
|
||||
},
|
||||
@@ -1106,6 +1187,21 @@
|
||||
},
|
||||
"wrinkle_prevent": {
|
||||
"name": "Kreukpreventie"
|
||||
},
|
||||
"zone_power": {
|
||||
"name": "Zonevoeding"
|
||||
},
|
||||
"away_mode": {
|
||||
"name": "Afwezigheidsmodus"
|
||||
},
|
||||
"watertank_light": {
|
||||
"name": "Waterreservoirlicht"
|
||||
},
|
||||
"uv_led": {
|
||||
"name": "Uv-led"
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilatiealarm"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1126,6 +1222,17 @@
|
||||
},
|
||||
"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": {
|
||||
"dhw": {
|
||||
"name": "Warm water"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1157,10 +1264,18 @@
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"title": "Apparaattype niet herkend",
|
||||
"description": "Het apparaattype van dit apparaat kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||
"description": "Dit apparaat meldt model \"{model}\", een type dat LocalThings nog niet herkent. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"no_response": "Geen reactie van dat IP-adres — er kwam niets terug op enige poort in het bereik van de lokale API van het apparaat (UDP 49152-49160). Controleer of het IP-adres klopt, of het apparaat aanstaat en op hetzelfde netwerk zit als Home Assistant, en of geen firewall het UDP-verkeer ernaartoe blokkeert.",
|
||||
"ports_closed": "Dat IP-adres is bereikbaar, maar weigerde actief elke poort in het bereik van de lokale API (UDP 49152-49160). Het is óf geen Samsung-apparaat, óf het draait oudere firmware die alleen via TCP 8888 met de cloud van Samsung praat, wat deze integratie niet kan gebruiken.",
|
||||
"no_dtls_server": "De poorten in het bereik van de lokale API zijn bereikbaar op dat adres, maar niets daar beantwoordde een DTLS-handshake. Controleer of het IP-adres bij het apparaat hoort en niet bij een ander apparaat in je netwerk.",
|
||||
"handshake_timeout": "Het apparaat antwoordde op zijn lokale API-poort, maar voltooide de DTLS-handshake niet. Meestal houdt het de sessie van een eerdere poging nog vast — wacht ongeveer een minuut en probeer het opnieuw.",
|
||||
"cert_rejected": "Het apparaat heeft het certificaat geweigerd. Het CA-certificaat en de sleutel zijn waarschijnlijk niet van de AC14K_M-CA die dit apparaat vertrouwt, of ze horen niet bij elkaar. Het Home Assistant-logboek bevat de precieze alert die het apparaat stuurde.",
|
||||
"handshake_failed": "Het apparaat weigerde de DTLS-handshake om een reden die niets met het certificaat te maken heeft, hoogstwaarschijnlijk een protocol- of cipher-mismatch. Het Home Assistant-logboek bevat de precieze alert die het stuurde.",
|
||||
"cloud_unreachable": "Kon de cloudgateway van Samsung niet bereiken om de UUID op te halen die nodig is om het certificaat van dit apparaat aan te maken. Controleer de internettoegang van Home Assistant en probeer het opnieuw.",
|
||||
"unexpected_response": "Verbinding met het apparaat is gelukt, maar het gaf geen bruikbare apparaatbeschrijving terug. Mogelijk is het geen apparaat dat deze integratie ondersteunt. Het Home Assistant-logboek bevat wat het stuurde.",
|
||||
"cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.",
|
||||
"invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.",
|
||||
"unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Water heater platform for Local Things.
|
||||
|
||||
Second composite entity in this integration (see climate.py's module
|
||||
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`: 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
|
||||
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 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").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
STATE_HEAT_PUMP,
|
||||
STATE_HIGH_DEMAND,
|
||||
STATE_PERFORMANCE,
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
from .registry.capabilities.ehs import (
|
||||
HREF_DHW_MODE as MODE_HREF,
|
||||
)
|
||||
from .registry.capabilities.ehs import (
|
||||
HREF_DHW_POWER as POWER_HREF,
|
||||
)
|
||||
from .registry.capabilities.ehs import (
|
||||
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
|
||||
)
|
||||
from .registry.entities import WaterHeaterDesc
|
||||
|
||||
_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 module docstring.
|
||||
_DEVICE_TO_STATE: dict[str, str] = {
|
||||
"Eco": STATE_ECO,
|
||||
"Std": STATE_HEAT_PUMP,
|
||||
"Force": STATE_HIGH_DEMAND,
|
||||
"Power": STATE_PERFORMANCE,
|
||||
}
|
||||
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
|
||||
|
||||
# Read-side lookup, case-folded: 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()}
|
||||
|
||||
|
||||
def _to_state(code) -> str | None:
|
||||
if code is None:
|
||||
return None
|
||||
return _DEVICE_TO_STATE_CI.get(str(code).lower())
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
LocalThingsWaterHeater(coordinator, b)
|
||||
for b in coordinator.bound
|
||||
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
|
||||
)
|
||||
|
||||
|
||||
def _num(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _first(value):
|
||||
"""Samsung `modes` is a single-element list on this resource. Return the
|
||||
first element of a list, else the value itself."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
|
||||
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
||||
"""Composite water_heater entity for a Samsung EHS DHW loop."""
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
# No _attr_name here: unlike climate.py's AC, this is one loop of a
|
||||
# two-loop device and takes a catalog name through translation_key.
|
||||
self._attr_supported_features = (
|
||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||
| WaterHeaterEntityFeature.ON_OFF
|
||||
)
|
||||
# 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,
|
||||
translated through this bound entity's own subdevice (issue #177),
|
||||
same as climate.py's identical helper."""
|
||||
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||
|
||||
def _is_on(self) -> bool:
|
||||
return str(self._rep(POWER_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
|
||||
|
||||
def _supported(self) -> list[str]:
|
||||
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
|
||||
|
||||
def _warn_unmapped(self, code: str) -> None:
|
||||
if code in self._warned_unmapped:
|
||||
return
|
||||
self._warned_unmapped.add(code)
|
||||
_LOGGER.warning(
|
||||
"%s: device DHW mode %r has no HA mapping and was dropped; "
|
||||
"please file an issue with your diagnostics dump",
|
||||
self.entity_id,
|
||||
code,
|
||||
)
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
raw = self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.unit")
|
||||
return (
|
||||
UnitOfTemperature.FAHRENHEIT
|
||||
if normalize_temp_unit(raw, "°C") == "°F"
|
||||
else UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.current"))
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
|
||||
|
||||
def _range(self) -> list | None:
|
||||
"""The device's own (minimum, maximum) pair, or None. Both ends
|
||||
together or neither -- 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"))
|
||||
return [lo, hi] if (lo is not None and hi is not None) else None
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
r = self._range()
|
||||
return r[0] if r else super().min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
r = self._range()
|
||||
return r[1] if r else super().max_temp
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float:
|
||||
# `is None`, not `or` -- `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
|
||||
|
||||
# -- operation mode -------------------------------------------------------
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
if not self._is_on():
|
||||
return STATE_OFF
|
||||
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||
mapped = _to_state(code)
|
||||
if code is not None and mapped is None:
|
||||
self._warn_unmapped(code)
|
||||
return mapped
|
||||
|
||||
@property
|
||||
def operation_list(self) -> list[str]:
|
||||
modes = [STATE_OFF]
|
||||
for code in self._supported():
|
||||
mapped = _to_state(code)
|
||||
if mapped is None:
|
||||
self._warn_unmapped(code)
|
||||
continue
|
||||
if mapped not in modes:
|
||||
modes.append(mapped)
|
||||
return modes
|
||||
|
||||
# -- writes ---------------------------------------------------------------
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
# HA's water_heater.set_temperature service 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)
|
||||
if operation_mode == STATE_OFF:
|
||||
return
|
||||
temp = kwargs.get("temperature")
|
||||
if temp is None:
|
||||
return
|
||||
await self.coordinator.async_send_command(self._bound, ("temperature", temp))
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
if operation_mode == STATE_OFF:
|
||||
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||
return
|
||||
device = _STATE_TO_DEVICE.get(operation_mode)
|
||||
if device is None:
|
||||
return
|
||||
if not self._is_on():
|
||||
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||
await self.coordinator.async_send_command(self._bound, ("mode", device))
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ("power", True))
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ("power", False))
|
||||
@@ -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.
|
||||
@@ -3,3 +3,36 @@ requires-python = ">=3.13"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"F", # pyflakes
|
||||
"W", # pycodestyle warnings
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"SIM", # flake8-simplify
|
||||
"RUF", # ruff-specific
|
||||
"ASYNC", # flake8-async
|
||||
"LOG", # flake8-logging
|
||||
"G", # flake8-logging-format
|
||||
"PIE", # flake8-pie
|
||||
"RET", # flake8-return
|
||||
"PERF", # perflint
|
||||
"N", # pep8-naming
|
||||
]
|
||||
ignore = [
|
||||
"N818", # exception name doesn't need an Error suffix in this codebase
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["custom_components"]
|
||||
|
||||
[tool.ty.environment]
|
||||
python-version = "3.13"
|
||||
|
||||
@@ -6,7 +6,11 @@ pytest-homeassistant-custom-component>=0.13.316
|
||||
|
||||
# Integration runtime deps, needed to import the component under test
|
||||
# (also declared in custom_components/localthings/manifest.json).
|
||||
smartthings-local>=0.1.0
|
||||
smartthings-local>=0.1.2
|
||||
cbor2>=5.4.6
|
||||
pyOpenSSL>=23.0
|
||||
cryptography>=41.0
|
||||
|
||||
# Lint / format / type-check tooling, pinned to match CI.
|
||||
ruff==0.16.1
|
||||
ty==0.0.65
|
||||
|
||||
+23
-19
@@ -3,16 +3,17 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def _resources_from_dump(dump: dict) -> dict[str, dict]:
|
||||
from custom_components.localthings.registry.batch import parse_device0_batch
|
||||
return parse_device0_batch(dump['device0'])
|
||||
|
||||
return parse_device0_batch(dump["device0"])
|
||||
|
||||
|
||||
def _load_device(name: str) -> dict[str, dict]:
|
||||
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
|
||||
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
|
||||
return _resources_from_dump(data)
|
||||
|
||||
|
||||
@@ -33,10 +34,10 @@ def _load_device_full(name: str):
|
||||
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
|
||||
FakeCoapSession answers both shapes off the same href key.
|
||||
"""
|
||||
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
|
||||
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
|
||||
resources = _resources_from_dump(data)
|
||||
seeds = {**data.get('seeds', {}), **data.get('probes', {})}
|
||||
return resources, data.get('oic_res', []), seeds
|
||||
seeds = {**data.get("seeds", {}), **data.get("probes", {})}
|
||||
return resources, data.get("oic_res", []), seeds
|
||||
|
||||
|
||||
class FakeCoapSession:
|
||||
@@ -57,11 +58,12 @@ class FakeCoapSession:
|
||||
self.seeds = seeds or {}
|
||||
|
||||
def get(self, path, timeout=None):
|
||||
href = '/' + '/'.join(path)
|
||||
href = "/" + "/".join(path)
|
||||
body = self.seeds.get(href)
|
||||
if body is None:
|
||||
return 0x84, b'' # 4.04 not found -- tolerated absence
|
||||
return 0x84, b"" # 4.04 not found -- tolerated absence
|
||||
import cbor2
|
||||
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
def pace(self):
|
||||
@@ -87,14 +89,18 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
from custom_components.localthings.registry.subdevices import (
|
||||
discover_partitioned, enumerate_subdevices,
|
||||
discover_partitioned,
|
||||
enumerate_subdevices,
|
||||
)
|
||||
|
||||
sess = FakeCoapSession(seeds)
|
||||
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
full_resources = {**resources, **extra}
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
full_resources, candidates, resolve, CAPABILITIES,
|
||||
full_resources,
|
||||
candidates,
|
||||
resolve,
|
||||
CAPABILITIES,
|
||||
)
|
||||
return bound, materialized, skipped, full_resources, device_type_name
|
||||
|
||||
@@ -102,8 +108,8 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
def _load_resources(ip: str) -> dict[str, dict]:
|
||||
"""Legacy IP-based loader — maps known IPs to named fixtures."""
|
||||
_ip_to_name = {
|
||||
'10.0.0.129': 'dishwasher',
|
||||
'10.0.0.254': 'refrigerator',
|
||||
"10.0.0.129": "dishwasher",
|
||||
"10.0.0.254": "refrigerator",
|
||||
}
|
||||
name = _ip_to_name.get(ip)
|
||||
if name is None:
|
||||
@@ -113,17 +119,17 @@ def _load_resources(ip: str) -> dict[str, dict]:
|
||||
|
||||
@pytest.fixture
|
||||
def dishwasher_resources() -> dict[str, dict]:
|
||||
return _load_device('dishwasher')
|
||||
return _load_device("dishwasher")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fridge_resources() -> dict[str, dict]:
|
||||
return _load_device('refrigerator')
|
||||
return _load_device("refrigerator")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def washer_resources() -> dict[str, dict]:
|
||||
return _load_device('washer')
|
||||
return _load_device("washer")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -134,8 +140,6 @@ def all_device_fixtures() -> dict[str, dict[str, dict]]:
|
||||
device -- so a newly added dump exercises them automatically.
|
||||
"""
|
||||
return {
|
||||
path.name[:-len('_device.json')]: _resources_from_dump(
|
||||
json.loads(path.read_text())
|
||||
)
|
||||
for path in sorted(FIXTURES.glob('*_device.json'))
|
||||
path.name[: -len("_device.json")]: _resources_from_dump(json.loads(path.read_text()))
|
||||
for path in sorted(FIXTURES.glob("*_device.json"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "224",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "224",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"112",
|
||||
"224",
|
||||
"336",
|
||||
"448"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustPM1filter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterCapacity": "3",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Month",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"notresetable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"SpeedClean",
|
||||
"QuietClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "12",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"11",
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15",
|
||||
"16"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"Max"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "NotSupported",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"NotSupported"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/subdevices/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.subdeviceIdList": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "AC_V_0002_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "27.0",
|
||||
"x.com.samsung.da.current": "28.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 28.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
18.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 27.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "400.000000",
|
||||
"x.com.samsung.da.instantaneousPower": "14.000000",
|
||||
"x.com.samsung.da.cumulativePower": "882861",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeDate": "1785798000",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"energy": 400.0,
|
||||
"power": 14.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"AIComfort",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"ArtificialWorking_Off",
|
||||
"welcomecare_Off",
|
||||
"Volume_Mute",
|
||||
"Operation_Family",
|
||||
"ComfortAICooling_Off",
|
||||
"OnTimer_0",
|
||||
"OffTimer_0",
|
||||
"Sleep_0",
|
||||
"Booster_Off",
|
||||
"OutdoorSilence_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_250",
|
||||
"StopAutoClean_Idle",
|
||||
"Weather_Off",
|
||||
"Service_Off",
|
||||
"SmartCoolClean_Off",
|
||||
"ProgressSmartClean_0",
|
||||
"OutDoorVentil_Off",
|
||||
"InDoorVentil_Off",
|
||||
"FreezeAlarmSetting_Off",
|
||||
"DesiredFreezeAlarm_240",
|
||||
"ProtectOperation_Off",
|
||||
"EnergySaveIcon_Off",
|
||||
"ExtendOptionCode_16975",
|
||||
"OptionCode_2589",
|
||||
"RacInfo_First",
|
||||
"ModelInfo_16K_BORA_VENT2",
|
||||
"RacInfo_None_Second",
|
||||
"OutdoorConnection_Connected",
|
||||
"DurationOn_0",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"welcomecareElapsedTime_0",
|
||||
"welcomecareThresholdTemp_0",
|
||||
"welcomecareStartDate_0000",
|
||||
"welcomecareEndDate_0000",
|
||||
"welcomecareSeason_None"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_FAC_TIME_23K|10250841|600007110017112BCE00CE2200937000",
|
||||
"x.com.samsung.da.description": "TP1X_FAC_TIME_23K",
|
||||
"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": "AF2",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02517A260330",
|
||||
"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": "02508A23020200,02509A24030701",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "02502A23011300,02299A10000300"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "LED",
|
||||
"x.com.samsung.da.number": "02510A22121900,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "BLERemoteController",
|
||||
"x.com.samsung.da.number": "22021760"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "1138000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_2.0",
|
||||
"AI_2.0",
|
||||
"DeviceTypeMaster",
|
||||
"CLEAN_OPTION_SUPPORT",
|
||||
"DR",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "71"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "1970-01-01T00:00:00Z",
|
||||
"durationminutes": 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": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runn/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000B4012C0000000D04000000",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/uvled/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "On",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": "0",
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedeviceinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.didList": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AFA-WW-TP1-23-AFXX00",
|
||||
"versions": [
|
||||
"10260330"
|
||||
],
|
||||
"visVersion": "260330"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "845110250241FFFFFFFF",
|
||||
"versions": [
|
||||
"23011300",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230113"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511025084110250941",
|
||||
"versions": [
|
||||
"23020200",
|
||||
"24030701"
|
||||
],
|
||||
"visVersion": "240307"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04511025024110229941",
|
||||
"versions": [
|
||||
"23011300",
|
||||
"10000300"
|
||||
],
|
||||
"visVersion": "230113"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110240441FFFFFFFF",
|
||||
"versions": [
|
||||
"23121900",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "231219"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045110251041FFFFFFFF",
|
||||
"versions": [
|
||||
"22121900",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "221219"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.progress": "1",
|
||||
"x.com.samsung.da.result": "NA",
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start",
|
||||
"Cancel"
|
||||
],
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1DFFFFFFFF121E121EFFFFFFFFFFFF01000000013F0100013F000000001C0C",
|
||||
"x.com.samsung.da.id": "FAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ventilation/setting/vs/0",
|
||||
"rep": {
|
||||
"alarm": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "Off",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "224",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "224",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"224",
|
||||
"300",
|
||||
"500",
|
||||
"700"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"cleaningTime": "30",
|
||||
"minCleaningTime": "10",
|
||||
"maxCleaningTime": "60",
|
||||
"cleaningTimeUnit": "10",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"TimedClean",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"TimedClean",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "WaterTankFull_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "145.000000",
|
||||
"x.com.samsung.da.cumulativePower": "101100",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Smart",
|
||||
"Max",
|
||||
"High",
|
||||
"Medium",
|
||||
"Quiet",
|
||||
"ClothesDrying"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Quiet"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Volume_Mute",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"operationNumber": "1",
|
||||
"displayCondition": "Enable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA_AC_DHM_01001_0000|10283341|77000000001700000A00000000000000",
|
||||
"x.com.samsung.da.description": "TP1X_DA_AC_DHM_01001_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AY0",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02544A260401",
|
||||
"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": "02833A26010600,FFFFFFFFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "02590A10000100,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Touch IC",
|
||||
"x.com.samsung.da.number": "02552A23020200,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"DR",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_RAC_GLOBAL_2.0",
|
||||
"AI_Heat"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "63",
|
||||
"x.com.samsung.da.desiredHumidity": "60",
|
||||
"increment": "5",
|
||||
"x.com.samsung.da.fivepercentHumidity": "64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "04001E46070A5A00000000000000",
|
||||
"x.com.samsung.da.id": "DHM",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-06-24",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ADH-WW-TP1-23-AY7100",
|
||||
"versions": [
|
||||
"18260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "042110283341FFFFFFFF",
|
||||
"versions": [
|
||||
"26010600",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260106"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "042110259041FFFFFFFF",
|
||||
"versions": [
|
||||
"10000100",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "100001"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "042110255241FFFFFFFF",
|
||||
"versions": [
|
||||
"23020200",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230202"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "3F1E461E461E46FFFFFFFF1E4600",
|
||||
"x.com.samsung.da.id": "DHM",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/display/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"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": "/welcome/humidity/vs/0",
|
||||
"rep": {
|
||||
"requestId": "0000",
|
||||
"operatingStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/watertank/lighting/vs/0",
|
||||
"rep": {
|
||||
"status": "On",
|
||||
"colorOption": "NaturalWhite",
|
||||
"mode": "High",
|
||||
"waterfullAlarmStatus": "On",
|
||||
"colorSupportedList": [
|
||||
"WarmWhite",
|
||||
"NaturalWhite",
|
||||
"CoolWhite",
|
||||
"Green",
|
||||
"Purple",
|
||||
"Blue",
|
||||
"Orange",
|
||||
"Pink"
|
||||
],
|
||||
"modeSupportedList": [
|
||||
"High",
|
||||
"Low"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
{
|
||||
"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": {}
|
||||
},
|
||||
{
|
||||
"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": "211700",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1785693600",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1785686400",
|
||||
"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_0165",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_16",
|
||||
"AiOption_On",
|
||||
"MixedLoadBell_Disable",
|
||||
"MixedLoadBellNoti_Nothing",
|
||||
"LaundryOutTime_0",
|
||||
"SeamlessControl_Enable",
|
||||
"KidsLockBypass_On",
|
||||
"WashingTimes_25",
|
||||
"DrumCleanProposal_25",
|
||||
"DetergentOnce_1",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_5",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_3",
|
||||
"DetergentTotal_0",
|
||||
"SpecialFunction_20",
|
||||
"AvailableDelayTime_215",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_B214DCB4003C",
|
||||
"WrinklePreventRunning_Off",
|
||||
"QuickDrive_Off",
|
||||
"SendToDevice_Off",
|
||||
"GMT_04",
|
||||
"DrumLight_Off",
|
||||
"DryTimeSet_FF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060007FE020007FE020007FE0000000000000000",
|
||||
"EnergyLevelSet_05030403050302040503050203030402010205",
|
||||
"MostUsed_16D31E",
|
||||
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0",
|
||||
"MixedLoadBellSet_0202FF0202FFFFFF02FFFFFFFFFFFFFFFFFF",
|
||||
"EmptyingTheBucket_Nothing",
|
||||
"SavingModeCondition_01010202171D0300",
|
||||
"SavingMode_Off",
|
||||
"QuickDriveSet_F0000000F000000000000000000000000000",
|
||||
"GeoFenceAlarm",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_2023-12-03T22:07:18|2024-02-09T15:18:59|2024-07-07T23:06:16|2024-10-19T19:29:45|2025-01-26T16:10:23|2025-05-10T19:59:59|2025-07-13T14:58:26|2026-01-09T21:27:43|2026-03-21T18:52:46|2026-05-24T21:38:08",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"116D31E29D31E23D00017D31E18D21E19D20C1DD3181BD3181CD31E21D4101AD1021ED31820D10227D00025D00024D0004CD21C2BD000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cycleInterfaceEnabled": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "03:35:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "03:35:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20317441|30010102001611000AA3031F00000000",
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DV9400B/DC92-03267A_2C56",
|
||||
"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": "DR1",
|
||||
"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|20317441|30010102001611000AA3031F00000000",
|
||||
"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_20317441221114080FFFFF203267412210245204FFFF(01652031744120326741_30221118)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03174A22111408,03267A22102452",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Firmware_2_DB_2032244122050903042FFFFFFFFFFFFFFFFFFFFFFFFE(016520322441FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03224A22050903,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+02:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.wrinklePrevent": "Off",
|
||||
"x.com.samsung.da.dryLevel": "Normal",
|
||||
"x.com.samsung.da.supportedDryLevel": [
|
||||
"None",
|
||||
"Damp",
|
||||
"Less",
|
||||
"Normal",
|
||||
"More"
|
||||
],
|
||||
"x.com.samsung.da.dryTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedDryTime": [
|
||||
"00:00:00",
|
||||
"00:20:00",
|
||||
"00:30:00",
|
||||
"00:40:00",
|
||||
"00:50:00",
|
||||
"01:00:00",
|
||||
"01:30:00",
|
||||
"02:00:00",
|
||||
"02:30:00",
|
||||
"03:00:00",
|
||||
"04:00:00"
|
||||
],
|
||||
"x.com.samsung.da.dryerType": "Electricity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/dryercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.dryerMode": "Table_03_Course_16",
|
||||
"x.com.samsung.da.st.courseTable": "Table_03"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.editCourseList": "EditCourseList_161B1D29231718191C211A1E202725244C2B",
|
||||
"x.com.samsung.da.fixedCourseList": "FixedCourseList_1618202B"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "true",
|
||||
"x.com.samsung.da.aiCourse": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
|
||||
"x.com.samsung.da.modelCode": "M(None),W(DV90BB7445GES7)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/welcomemsg/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/personalcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.courses": [
|
||||
"F1_00",
|
||||
"F2_00",
|
||||
"F3_00",
|
||||
"F4_00",
|
||||
"F5_00",
|
||||
"F6_00",
|
||||
"F7_00",
|
||||
"F8_00",
|
||||
"F9_00",
|
||||
"FA_00"
|
||||
],
|
||||
"x.com.samsung.da.maxCourseNum": "10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/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": "01652031744120326741",
|
||||
"versions": [
|
||||
"22111408",
|
||||
"22102452"
|
||||
],
|
||||
"visVersion": "221114"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "016520322441FFFFFFFF",
|
||||
"versions": [
|
||||
"22050903",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220509"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/buzzersound/vs/0",
|
||||
"rep": {
|
||||
"supportedBuzzerSound": [
|
||||
"Off",
|
||||
"On"
|
||||
],
|
||||
"setBuzzerSound": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "CZ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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/Prague",
|
||||
"offset": "+02:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+759
@@ -0,0 +1,759 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-01T21:32:45",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/indoor/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.type": "Water",
|
||||
"x.com.samsung.da.desired": "5.0",
|
||||
"x.com.samsung.da.current": "30.0",
|
||||
"x.com.samsung.da.maximum": "25.0",
|
||||
"x.com.samsung.da.minimum": "5.0",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.offset": "0.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.type": "Water",
|
||||
"x.com.samsung.da.unit": "Celsius",
|
||||
"x.com.samsung.da.desired": "40.0",
|
||||
"x.com.samsung.da.current": "38.0",
|
||||
"x.com.samsung.da.maximum": "62.0",
|
||||
"x.com.samsung.da.minimum": "40.0",
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "2448844",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativePowerType": "individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Heat",
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OutdoorTemp_80",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit",
|
||||
"Thermostat_CoolHeat_Off"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Thermostat_CoolHeat",
|
||||
"DurationOn",
|
||||
"KeyInputPermit",
|
||||
"ModePermit",
|
||||
"Volume",
|
||||
"OutdoorTemp"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Eco",
|
||||
"Std",
|
||||
"Power",
|
||||
"Force"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Eco"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Thermostat_Dhw_Off",
|
||||
"KeyInputPermit_On"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Thermostat_Dhw",
|
||||
"KeyInputPermit"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"causeSource": "SMTS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000",
|
||||
"x.com.samsung.da.description": "TP1X_DA_AC_EHS_01001_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AE0",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02504A260402",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "02501A24062401,FFFFFFFFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "02572A23081000,02549A10000800"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0001003200FA0B0190026C0C0000CE32",
|
||||
"x.com.samsung.da.id": "EHS",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AEH-WW-TP1-22-AE6000",
|
||||
"versions": [
|
||||
"17260402"
|
||||
],
|
||||
"visVersion": "260402"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "040010250141FFFFFFFF",
|
||||
"versions": [
|
||||
"24062401",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240624"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04001025724110254941",
|
||||
"versions": [
|
||||
"23081000",
|
||||
"10000800"
|
||||
],
|
||||
"visVersion": "230810"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/London",
|
||||
"offset": "+01:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0EFFFFFFFF05190F370B283E",
|
||||
"x.com.samsung.da.id": "EHS",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/outgoing/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.away": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "12300000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "20010000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "00"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "02572A230810",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": "000000000000"
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"info": "000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ehsfsv/vs/0",
|
||||
"rep": {
|
||||
"items": [
|
||||
{
|
||||
"setting": "0407010A01017202EE02260101"
|
||||
},
|
||||
{
|
||||
"setting": "0408010A010096017200960101"
|
||||
},
|
||||
{
|
||||
"setting": "041B010A0101F402BC026C0101"
|
||||
},
|
||||
{
|
||||
"setting": "041C010A01012C019001900101"
|
||||
},
|
||||
{
|
||||
"setting": "07DB010A01FF380032FFE20101"
|
||||
},
|
||||
{
|
||||
"setting": "07DC010A01006400C800960101"
|
||||
},
|
||||
{
|
||||
"setting": "07E5010A0100AA02EE01540101"
|
||||
},
|
||||
{
|
||||
"setting": "07E6010A0100AA02EE014A0101"
|
||||
},
|
||||
{
|
||||
"setting": "07EF010A0100AA02EE028A0101"
|
||||
},
|
||||
{
|
||||
"setting": "07F0010A0100AA02EE00AA0101"
|
||||
},
|
||||
{
|
||||
"setting": "082B0101000000000400000101"
|
||||
},
|
||||
{
|
||||
"setting": "082C0101000000000400000101"
|
||||
},
|
||||
{
|
||||
"setting": "082D0101000001000400040101"
|
||||
},
|
||||
{
|
||||
"setting": "0BC30101000000000200020101"
|
||||
},
|
||||
{
|
||||
"setting": "0BFF0101000000000100000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FAB0101000000000100000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FAC010A01FF6A00C800000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FB50101000000000200000101"
|
||||
},
|
||||
{
|
||||
"setting": "0FCA010A010032009600640101"
|
||||
},
|
||||
{
|
||||
"setting": "0FDD0101000000000100000101"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/ehscycle/vs/0",
|
||||
"rep": {
|
||||
"indoor": [
|
||||
{
|
||||
"cycledata": "4B055455050500000000000000000000000000000004CCA70000005D05054B",
|
||||
"datetime": "2026-08-01T21:23:08"
|
||||
},
|
||||
{
|
||||
"cycledata": "4B055454050500000000000000000000000000000004CCAC0000005D05054B",
|
||||
"datetime": "2026-08-01T21:28:08"
|
||||
}
|
||||
],
|
||||
"outdoor": [
|
||||
{
|
||||
"cycledata": "00000000555A4A53490207D000000000FFFF00480046325700000000",
|
||||
"datetime": "2026-08-01T21:23:08"
|
||||
},
|
||||
{
|
||||
"cycledata": "00000000555A4952490207D000000000000000480046325600000000",
|
||||
"datetime": "2026-08-01T21:28:08"
|
||||
}
|
||||
],
|
||||
"unit": "Celsius"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/dhwdisplay/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.dhwdisplay": "Show"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/actions/zone1/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/actions/dhw/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "0",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Sun"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "1",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Mon"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "2",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Tue"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "3",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Wed"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "4",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Thu"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "5",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Fri"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.timelineId": "6",
|
||||
"x.com.samsung.da.module": "DHW",
|
||||
"x.com.samsung.da.temperatureType": "Water",
|
||||
"x.com.samsung.da.dayofWeek": [
|
||||
"Sat"
|
||||
],
|
||||
"x.com.samsung.da.timeblocks": [
|
||||
{
|
||||
"x.com.samsung.da.timeblockId": "0",
|
||||
"x.com.samsung.da.start": [
|
||||
{
|
||||
"x.com.samsung.da.time": "03:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Std",
|
||||
"x.com.samsung.da.desired": "45.0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.end": [
|
||||
{
|
||||
"x.com.samsung.da.time": "05:00",
|
||||
"x.com.samsung.da.power": "On",
|
||||
"x.com.samsung.da.mode": "Eco",
|
||||
"x.com.samsung.da.desired": "40.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
+2
@@ -6,6 +6,8 @@
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"clean_level",
|
||||
"climate",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
@@ -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,12 +3,14 @@
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"auto_clean_progress_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_alarm_time",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"auto_clean_progress_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_alarm_time",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
"air_monitoring",
|
||||
"alarm_code",
|
||||
"auto_clean_legacy",
|
||||
"auto_clean_progress_legacy",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"filter_alarm_time",
|
||||
"filter_time",
|
||||
"good_sleep",
|
||||
"humidity",
|
||||
|
||||
+8
-2
@@ -2,12 +2,17 @@
|
||||
"state_keys": [
|
||||
"absence_power_saving_active",
|
||||
"absence_power_saving_mode",
|
||||
"air_filter_pm1_status",
|
||||
"air_filter_pm1_usage",
|
||||
"air_filter_pm1_usage_hours",
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
@@ -27,6 +32,7 @@
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
"tropical_night_mode",
|
||||
"uv_led"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user