Add code comment guidelines; dramatically trim excessive comments
CONTRIBUTING.md gains a "Code comments" section: comment the why not the what, keep it to a sentence or two with a pointer to the load-bearing evidence, don't re-derive a sibling's already-documented reasoning, and move failed-attempt investigation logs out of inline comments. Applied that policy across the codebase: condensed sprawling module docstrings, per-entity essays, and multi-paragraph rationale blocks down to their load-bearing conclusions, while preserving the actual "why" (issue numbers, calibration evidence, gotchas, don't-guess rationale). No functional code changed — verified via diff review, ruff, ty, and the full pytest suite (1211 passed). One inline investigation log (the AC filter-reset "tried and failed" notes) moved to docs/investigations/ac-filter-reset.md rather than being deleted, per the new guideline on where that kind of record belongs.
This commit is contained in:
@@ -19,6 +19,40 @@ file covers how changes get committed.
|
|||||||
assistant, or tool that helped produce the change. The commit is
|
assistant, or tool that helped produce the change. The commit is
|
||||||
attributed entirely to the accountable human.
|
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
|
## For AI coding agents
|
||||||
|
|
||||||
See `AGENTS.md`.
|
See `AGENTS.md`.
|
||||||
|
|||||||
@@ -23,25 +23,19 @@ def _serial_from_unique_id(entry: ConfigEntry) -> str:
|
|||||||
|
|
||||||
The config flow has always keyed the entry's unique_id on the serial the
|
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
|
probe read (`localthings_<serial>`), so that string is the identity the
|
||||||
entry's registry entries were minted from -- there is no need to reach the
|
entry's registry entries were minted from -- no need to reach the device
|
||||||
device to recover it. Anything we can't recover one from resolves to the
|
to recover it. Anything unrecoverable resolves to the host, matching
|
||||||
host, which is what the coordinator seeded such an entry with anyway.
|
what the coordinator seeded such an entry with anyway.
|
||||||
|
|
||||||
The recovered string goes back through resolve_serial rather than being
|
The recovered string goes back through resolve_serial rather than being
|
||||||
taken at face value, because the unique_id records what the flow believed
|
taken at face value: entries created before the placeholder rules
|
||||||
at the time it ran, not what the registry holds now. Entries created
|
(issues #83/#189) were keyed on the placeholder itself, while the
|
||||||
before the placeholder rules landed (issues #83/#189) were keyed on the
|
coordinator has since resolved those same boards to the host.
|
||||||
placeholder itself -- `localthings_Nothing(SVC)`, `localthings_FFFF...` --
|
Re-keying onto the placeholder would reintroduce the collision those
|
||||||
while the coordinator has since been resolving those same boards to the
|
issues are about -- two units of a family sharing the same placeholder
|
||||||
host. Re-keying the registry onto the placeholder to match the unique_id
|
would share entity unique_ids again. A later wrinkle, same root cause:
|
||||||
would reintroduce the collision those issues are about: two units of that
|
for a stretch the flow wrote `host:port` while the coordinator wrote
|
||||||
family report the *same* placeholder, so they'd share entity unique_ids
|
`host`; collapsed here to the coordinator's form too.
|
||||||
again.
|
|
||||||
|
|
||||||
A later wrinkle, same root cause: for a stretch the two sides disagreed on
|
|
||||||
which fallback to use, the flow writing `host:port` while the coordinator
|
|
||||||
wrote `host`. Collapse that to the coordinator's form too -- the registry
|
|
||||||
is what has to keep working.
|
|
||||||
"""
|
"""
|
||||||
host = entry.data[CONF_HOST]
|
host = entry.data[CONF_HOST]
|
||||||
prefix = f"{DOMAIN}_"
|
prefix = f"{DOMAIN}_"
|
||||||
@@ -59,24 +53,24 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
|||||||
"""Re-key registry entries this entry minted from the placeholder identity.
|
"""Re-key registry entries this entry minted from the placeholder identity.
|
||||||
|
|
||||||
Before the identity moved onto the config entry, the coordinator seeded
|
Before the identity moved onto the config entry, the coordinator seeded
|
||||||
`device_serial` with the host and only replaced it after the first
|
`device_serial` with the host and only replaced it after the first poll.
|
||||||
successful poll. Anything that registered in between -- the connection-mode
|
Anything that registered in between -- the connection-mode sensor
|
||||||
sensor especially, since it is added unconditionally rather than from
|
especially, added unconditionally rather than from `bound` -- was
|
||||||
`bound` -- was written into the registry keyed on the IP address
|
written into the registry keyed on the IP permanently, orphaned the
|
||||||
permanently, and was orphaned the moment the serial-keyed identity
|
moment the serial-keyed identity appeared (issue #236). Deleting the
|
||||||
appeared (issue #236). Deleting the orphans by hand didn't help: the next
|
orphans by hand didn't help: the next restart that lost the same race
|
||||||
restart that lost the same race recreated them.
|
recreated them.
|
||||||
|
|
||||||
Rewriting beats deleting where it's possible -- an entity keeps its
|
Rewriting beats deleting where possible -- an entity keeps its
|
||||||
entity_id, name, area and every automation that references it. It's only
|
entity_id, name, area and automations. Only possible when the
|
||||||
possible when the serial-keyed key is still free, though; where both exist
|
serial-keyed key is still free; where both exist the placeholder-keyed
|
||||||
the placeholder-keyed one is the dead duplicate (it has been unavailable
|
one is the dead duplicate (unavailable since the restart that created
|
||||||
since the restart that created it), so it goes.
|
it), so it goes.
|
||||||
"""
|
"""
|
||||||
host = entry.data[CONF_HOST]
|
host = entry.data[CONF_HOST]
|
||||||
if serial == host:
|
if serial == host:
|
||||||
# A board with no usable serial resolves *to* the host, so its keys
|
# A board with no usable serial resolves to the host, so its keys
|
||||||
# were never placeholders -- there is nothing here to re-key.
|
# were never placeholders.
|
||||||
return
|
return
|
||||||
|
|
||||||
ent_reg = er.async_get(hass)
|
ent_reg = er.async_get(hass)
|
||||||
@@ -106,11 +100,9 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
|||||||
existing = dev_reg.async_get_device(identifiers=fresh)
|
existing = dev_reg.async_get_device(identifiers=fresh)
|
||||||
if existing is not None and existing.id != device.id:
|
if existing is not None and existing.id != device.id:
|
||||||
# Removing a device takes its entities with it. Anything still
|
# Removing a device takes its entities with it. Anything still
|
||||||
# attached here came through the pass above re-keyed rather than
|
# attached here was re-keyed rather than removed above -- the
|
||||||
# removed -- i.e. it's the surviving copy, not a duplicate -- so
|
# surviving copy, not a duplicate -- so move it onto the device
|
||||||
# move it onto the device it now belongs to first. Otherwise the
|
# it now belongs to before the removal destroys it too.
|
||||||
# rewrite that was supposed to preserve an entity_id, name and
|
|
||||||
# area destroys them a few lines later.
|
|
||||||
for entity in er.async_entries_for_device(
|
for entity in er.async_entries_for_device(
|
||||||
ent_reg, device.id, include_disabled_entities=True
|
ent_reg, device.id, include_disabled_entities=True
|
||||||
):
|
):
|
||||||
@@ -127,13 +119,13 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
|||||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Migrate an entry to the current version.
|
"""Migrate an entry to the current version.
|
||||||
|
|
||||||
v1 -> v2 stores the device's identity on the entry so the coordinator can
|
v1 -> v2 stores the device's identity on the entry so the coordinator
|
||||||
key its registry entries before the first poll (issue #236), and repairs
|
can key its registry entries before the first poll (issue #236), and
|
||||||
whatever the old placeholder-keyed registration already orphaned.
|
repairs whatever the old placeholder-keyed registration already
|
||||||
|
orphaned.
|
||||||
"""
|
"""
|
||||||
if entry.version > 2:
|
if entry.version > 2:
|
||||||
# Downgrade: this release doesn't know the newer entry's shape.
|
return False # downgrade: this release doesn't know the newer shape
|
||||||
return False
|
|
||||||
|
|
||||||
if entry.version == 1:
|
if entry.version == 1:
|
||||||
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
|
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
|
||||||
@@ -155,31 +147,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
try:
|
try:
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
# `_poll_once` deliberately leaves the session up on a `TimeoutError`
|
# `_poll_once` deliberately leaves the session up on a TimeoutError
|
||||||
# (the transfer may just be slow -- see its docstring), so a refresh
|
# (see its docstring), so a refresh failing that way leaves a live,
|
||||||
# that fails that way ends here with a live, bound UDP socket that
|
# bound UDP socket nothing would ever close. HA retries setup with a
|
||||||
# nothing would ever close. HA retries setup on its own backoff with
|
# new coordinator, and the source port is fixed by design
|
||||||
# a *new* coordinator, and each device's source port is fixed by
|
# (`_local_source_port`), so an abandoned socket would squat the
|
||||||
# design (`_local_source_port`), so an abandoned socket squats the
|
# exact port the next attempt binds.
|
||||||
# exact port the next attempt binds -- SO_REUSEADDR lets that bind
|
|
||||||
# succeed, leaving two sockets racing for the device's datagrams.
|
|
||||||
await coordinator.async_close()
|
await coordinator.async_close()
|
||||||
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
|
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
||||||
# #254). `async_close` otherwise only runs via `async_unload_entry`, and
|
# #254): HA doesn't unload entries on a plain Core restart, so a restart
|
||||||
# HA does not unload entries on a plain Core restart -- so a restart left
|
# left the previous run's association orphaned, making the next
|
||||||
# the previous run's association orphaned on the appliance, which is what
|
# handshake time out. Complements the fixed source port, which covers
|
||||||
# makes the *next* run's handshake time out. Complements the fixed source
|
# the unclean-exit case this can't.
|
||||||
# port, which covers the unclean-exit case this cannot (see
|
|
||||||
# `_local_source_port`).
|
|
||||||
#
|
#
|
||||||
# A coroutine listener, not one that spawns its own task: the event bus
|
# A coroutine listener, not one that spawns its own task: the event bus
|
||||||
# runs it as a hass-tracked job, so the close is awaited by the
|
# runs it as a hass-tracked job, awaited by `async_block_till_done()`
|
||||||
# `async_block_till_done()` inside `hass.async_stop`. A detached task
|
# inside `hass.async_stop`. A detached task would likely be cancelled
|
||||||
# would likely be cancelled mid-shutdown -- the exact no-close_notify
|
# mid-shutdown -- the exact case this exists to prevent.
|
||||||
# case this exists to prevent.
|
|
||||||
async def _async_close_on_stop(_event: Event) -> None:
|
async def _async_close_on_stop(_event: Event) -> None:
|
||||||
await coordinator.async_close()
|
await coordinator.async_close()
|
||||||
|
|
||||||
@@ -198,31 +185,26 @@ async def async_remove_config_entry_device(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Allow deleting a device this entry no longer provides (issue #214).
|
"""Allow deleting a device this entry no longer provides (issue #214).
|
||||||
|
|
||||||
Defining this at all is what makes Home Assistant offer the "Delete
|
Defining this at all is what makes HA offer the "Delete device" action;
|
||||||
device" action for our devices; without it a device registry entry
|
without it, a device belonging to a loaded config entry can never be
|
||||||
belonging to a loaded config entry can never be removed from the UI. That
|
removed from the UI. That matters because a subdevice's HA device
|
||||||
matters because a subdevice's HA device outlives the discovery that
|
outlives the discovery that created it: a candidate materialized under
|
||||||
created it: a candidate that materialized under an older release (issue
|
an older release (issue #214's phantom second air conditioner, born
|
||||||
#214's phantom second air conditioner, born from an unused /device/1 slot
|
from an unused slot reporting the appliance's energy counter -- see
|
||||||
reporting the appliance's energy counter -- see
|
registry/subdevices.py's liveness gate) leaves a device entry nothing
|
||||||
registry/subdevices.py's liveness gate) leaves a device entry behind that
|
recreates or cleans up once the gate stops materializing it. Same for a
|
||||||
nothing recreates and nothing cleans up once the gate stops materializing
|
sibling a firmware update stops exposing.
|
||||||
it. Same for a sibling that a firmware update stops exposing.
|
|
||||||
|
|
||||||
Removal is refused for devices this entry *does* currently provide --
|
Removal is refused for devices this entry does currently provide -- HA
|
||||||
HA would recreate them on the next entity add, so allowing it would look
|
would recreate them on the next entity add. Deliberately no automatic
|
||||||
like the delete silently failed. Deliberately no automatic pruning at
|
pruning at discovery time: a sibling can fail to answer for a single
|
||||||
discovery time: subdevice enumeration is one-shot and a sibling can fail
|
poll (issue #205), so auto-removal would throw away a real subdevice's
|
||||||
to answer for a poll (issue #205 is exactly that on the reference
|
name/area/automations on a transient miss. The user gets the button;
|
||||||
hardware), so auto-removal would throw away a real subdevice's name,
|
the integration doesn't guess.
|
||||||
area and automation references 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:
|
if coordinator is None:
|
||||||
# Entry not loaded (or already unloaded) -- nothing is claiming this
|
return True # entry not loaded -- nothing claims this device
|
||||||
# device, so there's nothing to protect it from being removed.
|
|
||||||
return True
|
|
||||||
live = set(coordinator.device_info.get("identifiers") or set())
|
live = set(coordinator.device_info.get("identifiers") or set())
|
||||||
for subdevice in coordinator.subdevices:
|
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())
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
"""Climate platform for Local Things.
|
"""Climate platform for Local Things.
|
||||||
|
|
||||||
The first composite entity in this integration: a single HA climate card that
|
The first composite entity in this integration: a single HA climate card
|
||||||
unifies several OCF resources of a Samsung air conditioner. Unlike every other
|
that unifies several OCF resources of a Samsung air conditioner. Unlike
|
||||||
platform here (one descriptor -> one resource field), a climate entity reads
|
every other platform here (one descriptor -> one resource field), a climate
|
||||||
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
|
entity reads power, HVAC mode, current/target temperature, fan (wind)
|
||||||
direction) and the convenient-mode preset from *different* resources.
|
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
|
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the
|
||||||
still tracks it, and reads the sibling resources straight from the coordinator
|
registry still tracks it, and reads the sibling resources straight from the
|
||||||
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
|
coordinator snapshot via `coordinator.resource(href)`.
|
||||||
`number.py` (live range/unit) and `select.py` (options callable) already do.
|
|
||||||
|
|
||||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
|
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
|
||||||
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
|
the CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the
|
||||||
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and
|
right `(path_segs, body)`, and `async_send_command` applies the optimistic
|
||||||
applies the optimistic value/settle guard to that same href -- not the bound
|
value/settle guard to that resource's own href -- not the bound
|
||||||
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
|
`/mode/vs/0` href -- so one descriptor drives writes across power, mode,
|
||||||
back for, power, mode, temperature and wind resources alike.
|
temperature and wind resources alike.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -95,54 +95,40 @@ _SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
|||||||
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||||
"Cool": HVACMode.COOL,
|
"Cool": HVACMode.COOL,
|
||||||
"Dry": HVACMode.DRY,
|
"Dry": HVACMode.DRY,
|
||||||
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
|
# Fan-only is spelled 'Wind' on some boards and 'Fan' on others; both map
|
||||||
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
|
# to FAN_ONLY. _device_code_for_hvac() resolves the write-side code from
|
||||||
# reverse write can't rely on this map alone (two codes, one HA value) --
|
# the unit's own supportedModes, so this reverse map is only a fallback
|
||||||
# _device_code_for_hvac() resolves the code from the unit's own
|
# for a unit with no supportedModes at all. 'Fan' listed first so the
|
||||||
# supportedModes, so this is only a fallback for a unit reporting no
|
# {v: k} comprehension below has 'Wind' win that fallback (last-key-wins,
|
||||||
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse
|
# preserving the original single-spelling behavior).
|
||||||
# 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,
|
"Fan": HVACMode.FAN_ONLY,
|
||||||
"Wind": HVACMode.FAN_ONLY,
|
"Wind": HVACMode.FAN_ONLY,
|
||||||
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
|
# A single-setpoint "device decides" mode -> HA AUTO, not HEAT_COOL
|
||||||
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
|
# (which implies a two-setpoint heat+cool range these units don't have).
|
||||||
# and implies a two-setpoint heat+cool range these single-setpoint units
|
|
||||||
# (including cool-only models) don't have.
|
|
||||||
"Auto": HVACMode.AUTO,
|
"Auto": HVACMode.AUTO,
|
||||||
"Heat": HVACMode.HEAT,
|
"Heat": HVACMode.HEAT,
|
||||||
}
|
}
|
||||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
_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
|
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON): 'AIComfort'
|
||||||
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
|
# isn't a distinct thermodynamic operation like Cool/Dry/Heat, it's an AI
|
||||||
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
|
# overlay on the device's own 'Auto' -- the unit reports both as separate,
|
||||||
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
|
# mutually-exclusive supportedModes entries. hvac_mode reports AUTO (same as
|
||||||
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
|
# plain 'Auto') and a dedicated 'ai_comfort' preset carries the distinction.
|
||||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain
|
# Not reachable via async_set_hvac_mode -- entered/left only through the
|
||||||
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the
|
# preset, since there's no HVACMode value for it to write back to.
|
||||||
# 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"
|
_AI_COMFORT_MODE = "AIComfort"
|
||||||
PRESET_AI_COMFORT = "ai_comfort"
|
PRESET_AI_COMFORT = "ai_comfort"
|
||||||
|
|
||||||
# Codes that appear in /mode/vs/0's supportedModes but are option/capability
|
# Codes in /mode/vs/0's supportedModes that are option/capability flags, not
|
||||||
# flags rather than selectable thermodynamic operations -- dropped silently
|
# selectable thermodynamic operations -- dropped silently rather than
|
||||||
# (no _warn_unmapped call) rather than every owner of an affected unit
|
# tripping the issue #93 unmapped-code warning on every start.
|
||||||
# tripping the issue #93 warning on every start.
|
|
||||||
#
|
#
|
||||||
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K): also appears in
|
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K) also appears in
|
||||||
# /configuration/vs/0's x.com.samsung.da.airconOptionList alongside
|
# /configuration/vs/0's airconOptionList alongside other capability flags,
|
||||||
# PRODUCT_GLOBAL/AI_3.0/SingleCommand_1 -- clearly a capability flag, not a
|
# and the unit's own current `modes` never reported it active -- consistent
|
||||||
# mode, on that resource. The unit's own `modes` (current mode) never
|
# with an echoed capability flag, not a genuine mode. Unlike _AI_COMFORT_MODE,
|
||||||
# reported it as active across the reporter's logs, only ever a real
|
# not modeled as a preset either: nothing confirms it's user-selectable.
|
||||||
# thermodynamic mode -- consistent with it being echoed into supportedModes
|
|
||||||
# rather than genuinely selectable. Unlike _AI_COMFORT_MODE above, it isn't
|
|
||||||
# modeled as a preset: there's no confirmation it's user-selectable at all,
|
|
||||||
# so silently dropping it (rather than guessing a write contract) is the
|
|
||||||
# 'don't guess' rule applied to a mode code instead of a resource field.
|
|
||||||
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
|
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
|
||||||
|
|
||||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||||
@@ -166,11 +152,9 @@ _DEVICE_TO_SWING: dict[str, str] = {
|
|||||||
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
|
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
|
||||||
|
|
||||||
|
|
||||||
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
|
# Swing fallback via /wind/oscillation/vs/0 (issue #126): boards without
|
||||||
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
|
# WIND_DIRECTION_HREF report two independent Swing|Fix toggles instead of
|
||||||
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
|
# one combined code. Same HA vocabulary as _DEVICE_TO_SWING above.
|
||||||
# above (off/vertical/horizontal/both), just read from/written to a pair
|
|
||||||
# of fields rather than a single one.
|
|
||||||
def _oscillation_swing(rep: dict) -> str | None:
|
def _oscillation_swing(rep: dict) -> str | None:
|
||||||
vertical = rep.get("vertical")
|
vertical = rep.get("vertical")
|
||||||
horizontal = rep.get("horizontal")
|
horizontal = rep.get("horizontal")
|
||||||
@@ -189,13 +173,12 @@ def _oscillation_swing(rep: dict) -> str | None:
|
|||||||
|
|
||||||
def _wind_strength_label(code, rep: dict) -> str:
|
def _wind_strength_label(code, rep: dict) -> str:
|
||||||
"""Human label for a /wind/strength/vs/0 code from the device's own
|
"""Human label for a /wind/strength/vs/0 code from the device's own
|
||||||
modesName array (parallel-indexed with supportedModes), lowercased for
|
modesName array (parallel-indexed with supportedModes), lowercased --
|
||||||
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue
|
used only for codes _DEVICE_TO_FAN doesn't already cover (issue #155:
|
||||||
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the
|
a board using codes "0"/"31"-"35" instead of the "0"-"4" scale
|
||||||
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving
|
_DEVICE_TO_FAN was built from, with modesName giving the real labels).
|
||||||
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
|
Falls back to the raw code lowercased when modesName is absent or
|
||||||
preset_mode's dynamic code->str resolution. Falls back to the raw code
|
misaligned."""
|
||||||
lowercased when modesName is absent or misaligned."""
|
|
||||||
supported = rep.get("x.com.samsung.da.supportedModes") or []
|
supported = rep.get("x.com.samsung.da.supportedModes") or []
|
||||||
names = rep.get("x.com.samsung.da.modesName") or []
|
names = rep.get("x.com.samsung.da.modesName") or []
|
||||||
if code in supported and len(names) == len(supported):
|
if code in supported and len(names) == len(supported):
|
||||||
@@ -204,15 +187,10 @@ def _wind_strength_label(code, rep: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# Preset (convenient mode): resolved dynamically from the device's own
|
# Preset (convenient mode): resolved dynamically from the device's own
|
||||||
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
|
# /mode/convenient/vs/0 supportedModes -- no per-model table. Device 'Off'
|
||||||
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
|
# maps to PRESET_NONE; every other code is exposed lowercased and labelled
|
||||||
# exposed as its lowercased self and labelled in translations
|
# in translations, so any board's convenient modes surface without code
|
||||||
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>),
|
# changes, and an unlabelled code renders as its raw value.
|
||||||
# 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.)
|
|
||||||
def _preset_to_ha(code) -> str:
|
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()
|
||||||
|
|
||||||
@@ -248,12 +226,11 @@ def _num(value):
|
|||||||
def _temps_vs_item(rep: dict) -> dict:
|
def _temps_vs_item(rep: dict) -> dict:
|
||||||
"""First item of the vendor `/temperatures/vs/0` items[] array.
|
"""First item of the vendor `/temperatures/vs/0` items[] array.
|
||||||
|
|
||||||
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
|
Newer AC firmware (Tizen Lite) doesn't expose the OCF-standard
|
||||||
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
|
/temperature/current/0 + /temperature/desired/0 pair; it packs current/
|
||||||
/temperature/current/0 + /temperature/desired/0 pair; it reports current
|
desired/minimum/maximum/increment/unit into this one resource's
|
||||||
and target under a single `/temperatures/vs/0` resource whose
|
items[0] instead. Returns {} when absent, so callers fall through
|
||||||
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
|
cleanly.
|
||||||
increment/unit. 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):
|
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
||||||
@@ -264,16 +241,12 @@ def _temps_vs_item(rep: dict) -> dict:
|
|||||||
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||||
"""Composite climate entity for a Samsung air conditioner."""
|
"""Composite climate entity for a Samsung air conditioner."""
|
||||||
|
|
||||||
# translation_key comes from the ClimateDesc (base __init__ sets
|
# Opts out of the deprecated auto-added TURN_ON/OFF backwards compat.
|
||||||
# _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.
|
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
# Primary/main entity for the device: no name suffix, just the device name.
|
self._attr_name = None # primary entity: no name suffix
|
||||||
self._attr_name = None
|
|
||||||
self._attr_supported_features = (
|
self._attr_supported_features = (
|
||||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
| ClimateEntityFeature.FAN_MODE
|
| ClimateEntityFeature.FAN_MODE
|
||||||
@@ -283,9 +256,8 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
| ClimateEntityFeature.TURN_OFF
|
| ClimateEntityFeature.TURN_OFF
|
||||||
)
|
)
|
||||||
# (href, raw device code) pairs already logged by _warn_unmapped --
|
# (href, raw device code) pairs already logged by _warn_unmapped --
|
||||||
# these properties are read on every coordinator refresh, so an
|
# these properties are read on every refresh, so an un-deduped
|
||||||
# un-deduped warning would spam the log for any device with a
|
# warning would spam the log for a genuinely unrecognized code.
|
||||||
# genuinely unrecognized code.
|
|
||||||
self._warned_unmapped: set[tuple[str, str]] = set()
|
self._warned_unmapped: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
# -- resource helpers ---------------------------------------------------
|
# -- resource helpers ---------------------------------------------------
|
||||||
@@ -308,67 +280,51 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _legacy_airflow(self) -> dict:
|
def _legacy_airflow(self) -> dict:
|
||||||
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to
|
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel
|
||||||
use -- i.e. this board has no /wind/strength/vs/0.
|
to use -- i.e. this board has no /wind/strength/vs/0.
|
||||||
|
|
||||||
Delegates the board-generation test to is_legacy_board (the same
|
Delegates the board-generation test to is_legacy_board (the same
|
||||||
test capabilities/airconditioner.py's token entities are gated on)
|
test the token entities in capabilities/airconditioner.py use)
|
||||||
instead of re-implementing it. Uses self._resources (this unit's own
|
instead of re-implementing it, using self._resources (issue #177)
|
||||||
canonical view, issue #177 -- see LocalThingsEntity._resources)
|
rather than a presence dict built from coordinator.resource()'s
|
||||||
rather than a two-key presence dict built from coordinator.resource()'s
|
|
||||||
truthiness -- resource() collapses "href absent" and "href present
|
truthiness -- resource() collapses "href absent" and "href present
|
||||||
with an empty {} rep" to the same falsy value, while is_legacy_board
|
but empty" to the same falsy value, while is_legacy_board tests key
|
||||||
(and discover()'s own binding) test key membership, not truthiness. A
|
membership. Reads through self._rep, not coordinator.resource()
|
||||||
presence dict built from truthiness alone would disagree with the
|
directly, so a subdevice's own /airflow/vs/1 gets translated first,
|
||||||
token entities on a board reporting a genuinely empty /airflow/vs/0,
|
like every other sibling read below.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
if not is_legacy_board(self._resources):
|
if not is_legacy_board(self._resources):
|
||||||
return {}
|
return {}
|
||||||
return self._rep(AIRFLOW_HREF)
|
return self._rep(AIRFLOW_HREF)
|
||||||
|
|
||||||
def _legacy_preset(self) -> bool:
|
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
|
Reads the raw href directly rather than through self._rep's own
|
||||||
rep being empty alone: newer boards carry Comode tokens too, so a
|
CONVENIENT_HREF fallback -- that fallback IS the legacy_convenient()
|
||||||
momentarily empty /mode/convenient/vs/0 there must not silently switch
|
rep this method is deciding whether to use, so routing through it
|
||||||
the preset read (and write) over to the token path.
|
would make the resource never look empty.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
|
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:
|
def _rep(self, href: str) -> dict:
|
||||||
"""`href` is one of this module's canonical HREF_* constants --
|
"""`href` is one of this module's canonical HREF_* constants,
|
||||||
translated through this bound entity's own subdevice (issue #177) to
|
translated through this bound entity's own subdevice (issue #177)
|
||||||
the real, on-the-wire href before the single-href cache lookup
|
to the real on-the-wire href -- identity for MAIN."""
|
||||||
(identity for MAIN, so a device with no subdevices reads exactly the
|
|
||||||
href it always did)."""
|
|
||||||
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||||
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
||||||
return self._legacy_convenient()
|
return self._legacy_convenient()
|
||||||
return rep
|
return rep
|
||||||
|
|
||||||
def _is_on(self) -> bool:
|
def _is_on(self) -> bool:
|
||||||
# Prefer the vendor /power/vs/0 (present on every observed board and
|
# Prefer the vendor /power/vs/0 -- the OCF /power/0 is absent on many
|
||||||
# the resource writes target -- see airconditioner._climate_write).
|
# boards and a stale mirror on some, so reading it first showed
|
||||||
# The OCF /power/0 is absent on many boards and a stale mirror on
|
# pre-write state after a power toggle (issue #53).
|
||||||
# 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")
|
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
|
||||||
if power is not None:
|
if power is not None:
|
||||||
return str(power).lower() == "on"
|
return str(power).lower() == "on"
|
||||||
@@ -379,16 +335,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
|
|
||||||
def _warn_unmapped(self, href: str, code: str) -> None:
|
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||||
"""Log once per (href, code) when a device-reported mode has no
|
"""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
|
entry in the relevant device<->HA map, so a real gap surfaces in
|
||||||
in the log instead of silently vanishing (issue #93).
|
the log instead of silently vanishing (issue #93).
|
||||||
|
|
||||||
Falls back to `unique_id` when `entity_id` is unset (issue #235):
|
Falls back to `unique_id` when `entity_id` is unset (issue #235):
|
||||||
this fires during setup's first discovery pass, before the entity is
|
this can fire during setup's first discovery pass, before the
|
||||||
added to hass, so `entity_id` is always None at that point --
|
entity is added to hass, when entity_id is still None."""
|
||||||
indistinguishable across multiple same-type devices in the log.
|
|
||||||
`unique_id` is set eagerly in `__init__` (see entity.py), so it's
|
|
||||||
always available here even though `entity_id` isn't.
|
|
||||||
"""
|
|
||||||
key = (href, code)
|
key = (href, code)
|
||||||
if key in self._warned_unmapped:
|
if key in self._warned_unmapped:
|
||||||
return
|
return
|
||||||
@@ -420,11 +372,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
|
|
||||||
def _ocf_temp_authoritative(self) -> bool:
|
def _ocf_temp_authoritative(self) -> bool:
|
||||||
"""True when the OCF /temperature/{current,desired}/0 pair is the
|
"""True when the OCF /temperature/{current,desired}/0 pair is the
|
||||||
authoritative temperature channel -- signalled by
|
authoritative channel, signalled by /temperature/current/0 being
|
||||||
/temperature/current/0 being present. Those boards honour reads/
|
present. Those boards honor reads/writes on /temperature/desired/0
|
||||||
writes on /temperature/desired/0 and ignore the vendor
|
and ignore the vendor /temperatures/vs/0; boards without the pair
|
||||||
/temperatures/vs/0; boards without the pair (only a desired stub, or
|
are the reverse. Confirmed on live units of both kinds."""
|
||||||
nothing) are the reverse. Confirmed on live units of both kinds."""
|
|
||||||
return bool(self._rep(TEMP_CURRENT_HREF))
|
return bool(self._rep(TEMP_CURRENT_HREF))
|
||||||
|
|
||||||
def _temps_vs(self) -> dict:
|
def _temps_vs(self) -> dict:
|
||||||
@@ -597,11 +548,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
return _HVAC_TO_DEVICE.get(hvac_mode)
|
return _HVAC_TO_DEVICE.get(hvac_mode)
|
||||||
|
|
||||||
async def async_set_temperature(self, **kwargs) -> None:
|
async def async_set_temperature(self, **kwargs) -> None:
|
||||||
# HA's set_temperature service forwards an optional hvac_mode here; honour
|
# HA's set_temperature service can carry an optional hvac_mode;
|
||||||
# it (set the mode first -- that also powers the unit on when it was off),
|
# honor it (setting the mode also powers the unit on) so a dashboard
|
||||||
# matching the climate contract other integrations follow. Without this a
|
# "turn on to Auto 24" button doesn't set the setpoint alone.
|
||||||
# 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")
|
hvac_mode = kwargs.get("hvac_mode")
|
||||||
if hvac_mode is not None:
|
if hvac_mode is not None:
|
||||||
await self.async_set_hvac_mode(hvac_mode)
|
await self.async_set_hvac_mode(hvac_mode)
|
||||||
@@ -647,12 +596,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
supported = self._supported(WIND_STRENGTH_HREF)
|
supported = self._supported(WIND_STRENGTH_HREF)
|
||||||
device = _FAN_TO_DEVICE.get(fan_mode)
|
device = _FAN_TO_DEVICE.get(fan_mode)
|
||||||
# A static hit is only trustworthy if this unit's own supportedModes
|
# A static hit is only trustworthy if this unit's own supportedModes
|
||||||
# actually includes that code -- a board can use non-standard codes
|
# includes that code -- a board can use non-standard codes (issue
|
||||||
# (issue #155's "31"-"35") while still spelling a standard label
|
# #155) while still spelling a standard label in modesName, so the
|
||||||
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
|
# static guess could be a plausible code the device never
|
||||||
# return a plausible-looking code ('1'/'3') the device never
|
# advertised. Fall through to the live scan when it isn't one of
|
||||||
# advertised at all. Fall through to the live scan whenever the
|
# this unit's own codes.
|
||||||
# static guess isn't actually one of this unit's own codes.
|
|
||||||
if device is None or (supported and device not in supported):
|
if device is None or (supported and device not in supported):
|
||||||
rep = self._rep(WIND_STRENGTH_HREF)
|
rep = self._rep(WIND_STRENGTH_HREF)
|
||||||
for code in supported:
|
for code in supported:
|
||||||
|
|||||||
@@ -77,15 +77,10 @@ class CannotConnect(Exception):
|
|||||||
"""Base for every probe failure.
|
"""Base for every probe failure.
|
||||||
|
|
||||||
`error_key` selects which message the user sees. The subclasses below
|
`error_key` selects which message the user sees. The subclasses below
|
||||||
exist because "cannot connect" covered wildly different situations -- an
|
exist because "cannot connect" used to cover wildly different situations
|
||||||
IP with nothing on it, an appliance on cloud-only firmware, a device
|
(nothing at that IP, cloud-only firmware, a stale held session, a
|
||||||
that's simply still holding a session from the last attempt, and a device
|
rejected certificate) all under one unhelpful message. Raising this base
|
||||||
that answered and rejected our certificate all told the user the same
|
class directly is still valid for a failure that can't be narrowed down.
|
||||||
thing ("check the IP and the CA credentials"), which is only actionable
|
|
||||||
advice for one of them.
|
|
||||||
|
|
||||||
Raising this base class directly is still valid for a failure we can't
|
|
||||||
narrow down; it maps to that same generic message.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
error_key = "cannot_connect"
|
error_key = "cannot_connect"
|
||||||
@@ -144,11 +139,9 @@ class InvalidCA(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_samsung_uuid() -> str:
|
def _fetch_samsung_uuid() -> str:
|
||||||
"""Connect to Samsung's cloud gateway and extract the UUID from its TLS cert.
|
"""Connect to Samsung's cloud gateway and extract the UUID from its TLS
|
||||||
|
cert. Verification is disabled: Samsung's chain has a self-signed cert,
|
||||||
Verification is disabled because Samsung's chain contains a self-signed cert.
|
and we only need to read the UUID from the subject, not verify trust."""
|
||||||
We only need to read the UUID from the cert subject, not verify its trust.
|
|
||||||
"""
|
|
||||||
from cryptography import x509 as _x509
|
from cryptography import x509 as _x509
|
||||||
|
|
||||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
@@ -251,13 +244,11 @@ def _order_candidates(ports: list[int]) -> list[int]:
|
|||||||
return preferred + rest
|
return preferred + rest
|
||||||
|
|
||||||
|
|
||||||
# The kernel's way of saying the datagram never had anywhere to go: no route
|
# The kernel's way of saying the datagram never had anywhere to go: no route,
|
||||||
# to the network, or the host never answered ARP on our own LAN. Distinct from
|
# or the host never answered ARP. Distinct from ECONNREFUSED, which is a
|
||||||
# ECONNREFUSED, which is a *response* -- the host is there and told us the port
|
# response -- the host is there and told us the port is closed. Both leave a
|
||||||
# is closed. Both leave a port "not live", but they mean opposite things about
|
# port "not live", but mean opposite things about whether anything exists at
|
||||||
# whether anything exists at that address, which is the difference between
|
# that address.
|
||||||
# telling a user to check the IP and telling them their appliance is on
|
|
||||||
# cloud-only firmware.
|
|
||||||
_UNREACHABLE_ERRNOS = frozenset({errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN})
|
_UNREACHABLE_ERRNOS = frozenset({errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN})
|
||||||
|
|
||||||
|
|
||||||
@@ -273,24 +264,17 @@ class _SweepResult:
|
|||||||
def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResult:
|
def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResult:
|
||||||
"""Fast UDP liveness sweep -- the sweep's own verdict, nothing added.
|
"""Fast UDP liveness sweep -- the sweep's own verdict, nothing added.
|
||||||
|
|
||||||
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP
|
UDP is connectionless, but a connected UDP socket surfaces the ICMP
|
||||||
port-unreachable that a closed port returns as ECONNREFUSED on its next
|
port-unreachable a closed port returns as ECONNREFUSED on its next recv.
|
||||||
recv. So we send one probe datagram per port and watch for that error:
|
So we send one probe datagram per port and watch for that error:
|
||||||
|
ECONNREFUSED means closed; silence/data means possibly live. The
|
||||||
|
in-process equivalent of ``nmap -sU``: takes a nine-port range down to
|
||||||
|
the one or two worth a full DTLS handshake, bounded to ``timeout``.
|
||||||
|
|
||||||
* ECONNREFUSED -> port is closed (device actively rejected it)
|
Deliberately the raw verdict, with no preferred-port rescue folded in
|
||||||
* silence / any data -> port may be live (open|filtered); a candidate
|
(that's `_sweep_ports`) -- its shape is evidence about the host, and a
|
||||||
|
refusal vs. an unreachable are counted apart rather than both "not
|
||||||
This is the in-process equivalent of ``nmap -sU``: it lets us take a
|
live" for that reason (see _SweepResult).
|
||||||
nine-port range down to the one or two ports actually worth a full DTLS
|
|
||||||
handshake + /device/0 GET, and bounds the total wait to ``timeout``
|
|
||||||
instead of stalling on every dead port when a firewall swallows the ICMP
|
|
||||||
replies.
|
|
||||||
|
|
||||||
The result is deliberately the raw verdict, with no preferred-port rescue
|
|
||||||
folded in (that's `_sweep_ports`): its *shape* is evidence about the host,
|
|
||||||
and mixing a rescue into it would destroy that. Which is also why a
|
|
||||||
refusal and an unreachable are counted apart rather than both just being
|
|
||||||
"not live" -- see _SweepResult.
|
|
||||||
"""
|
"""
|
||||||
sockets: dict[int, socket.socket] = {}
|
sockets: dict[int, socket.socket] = {}
|
||||||
sel = selectors.DefaultSelector()
|
sel = selectors.DefaultSelector()
|
||||||
@@ -312,7 +296,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul
|
|||||||
sock.send(probe)
|
sock.send(probe)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
# Failing on the way out means the kernel already knows the
|
# Failing on the way out means the kernel already knows the
|
||||||
# datagram can't get there (no route, ARP never resolved).
|
# datagram can't get there.
|
||||||
_rule_out(port, exc)
|
_rule_out(port, exc)
|
||||||
sock.close()
|
sock.close()
|
||||||
continue
|
continue
|
||||||
@@ -329,8 +313,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul
|
|||||||
for key, _ in sel.select(timeout=remaining):
|
for key, _ in sel.select(timeout=remaining):
|
||||||
sock = sockets[key.data]
|
sock = sockets[key.data]
|
||||||
try:
|
try:
|
||||||
# Data back means live; an error means the port is
|
# Data back means live; an error rules the port out.
|
||||||
# closed or the host isn't there — either way, rule it out.
|
|
||||||
sock.recv(1)
|
sock.recv(1)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
_rule_out(key.data, exc)
|
_rule_out(key.data, exc)
|
||||||
@@ -349,18 +332,17 @@ def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepRes
|
|||||||
"""`(sweep, candidates)` -- what the host said, and what to actually try.
|
"""`(sweep, candidates)` -- what the host said, and what to actually try.
|
||||||
|
|
||||||
The sweep's ICMP-based verdict isn't reliable on every network path --
|
The sweep's ICMP-based verdict isn't reliable on every network path --
|
||||||
issue #192 captured a segregated-VLAN device where it called three ports
|
issue #192 captured a segregated-VLAN device where it called live ports
|
||||||
live that a concurrent nmap scan showed as closed, while the port nmap
|
that nmap showed closed, while the port nmap found genuinely open never
|
||||||
found genuinely open|filtered (49154, one of our historically confirmed
|
showed up as live at all. Rather than trust a wrong "not live" verdict
|
||||||
ports) never showed up as live at all. Rather than trust a wrong "not
|
on a port with strong prior evidence, the historically-confirmed ports
|
||||||
live" verdict on a port we already have strong prior evidence for, always
|
always get a real handshake attempt too (bounded cost: at most
|
||||||
give the historically-confirmed ports a real handshake attempt too.
|
len(PREFERRED_PROBE_PORTS) extra handshakes, only when the sweep
|
||||||
Bounded cost: at most len(PREFERRED_PROBE_PORTS) extra handshakes, only
|
disagrees with the prior).
|
||||||
when the sweep disagrees with the prior.
|
|
||||||
|
|
||||||
Both halves are returned rather than just the union because they answer
|
Both halves are returned, not just the union, since they answer
|
||||||
different questions: `candidates` is what to hand a handshake, `sweep` is
|
different questions: `candidates` is what to hand a handshake, `sweep`
|
||||||
what the host actually told us about itself.
|
is what the host actually told us about itself.
|
||||||
"""
|
"""
|
||||||
sweep = _find_live_ports(host, ports, timeout)
|
sweep = _find_live_ports(host, ports, timeout)
|
||||||
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in sweep.live]
|
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in sweep.live]
|
||||||
@@ -371,10 +353,10 @@ def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepRes
|
|||||||
class _PortScan:
|
class _PortScan:
|
||||||
"""What port detection learned about a host.
|
"""What port detection learned about a host.
|
||||||
|
|
||||||
`candidates` is what gets a full DTLS handshake. The other two are kept
|
`candidates` is what gets a full DTLS handshake. The other two are the
|
||||||
because they're the evidence behind a failure message: `confirmed` names
|
evidence behind a failure message: `confirmed` names ports a DTLS
|
||||||
ports a DTLS server was *proven* on, and `swept` is the UDP sweep's own
|
server was proven on, `swept` is the UDP sweep's own verdict (None
|
||||||
verdict (None when the sweep never had to run).
|
when the sweep never had to run).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
candidates: list[int]
|
candidates: list[int]
|
||||||
@@ -383,12 +365,10 @@ class _PortScan:
|
|||||||
|
|
||||||
|
|
||||||
def _clienthello_probe(host: str, port: int):
|
def _clienthello_probe(host: str, port: int):
|
||||||
"""One stateless DTLS ClientHello against `host:port`.
|
"""One stateless DTLS ClientHello against `host:port`. Imported lazily
|
||||||
|
so an install whose smartthings-local predates the probe (< 0.1.2)
|
||||||
Imported lazily so an install whose smartthings-local predates the probe
|
degrades to the UDP sweep at scan time rather than failing to load the
|
||||||
(< 0.1.2) degrades to the UDP sweep at scan time rather than failing to
|
config flow at all."""
|
||||||
load the config flow at all.
|
|
||||||
"""
|
|
||||||
from smartthings_local.protocol.dtls_probe import probe
|
from smartthings_local.protocol.dtls_probe import probe
|
||||||
|
|
||||||
return probe(
|
return probe(
|
||||||
@@ -406,17 +386,14 @@ def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
|
|||||||
|
|
||||||
smartthings-local's stateless probe sends one ClientHello and stops the
|
smartthings-local's stateless probe sends one ClientHello and stops the
|
||||||
moment the server proves itself with a HelloVerifyRequest, which per RFC
|
moment the server proves itself with a HelloVerifyRequest, which per RFC
|
||||||
6347 §4.2.1 the server answers *without* allocating association state. So
|
6347 §4.2.1 the server answers without allocating association state --
|
||||||
this identifies the device's real port in ~1 RTT, leaves nothing behind on
|
identifies the device's real port in ~1 RTT, far cheaper than throwing N
|
||||||
the appliance, and costs it far less than the alternative of throwing N
|
full certificate handshakes at it.
|
||||||
full certificate handshakes at it to find out.
|
|
||||||
|
|
||||||
The whole range goes out at once. That's safe in a way racing real
|
The whole range goes out at once, safely: each probe is bounded by
|
||||||
handshakes is not: each probe is bounded by CLIENTHELLO_PROBE_TIMEOUT_S
|
CLIENTHELLO_PROBE_TIMEOUT_S rather than DtlsCoapSession's 12s handshake
|
||||||
rather than DtlsCoapSession's 12s handshake timeout, so the pool's
|
timeout, so the pool's shutdown-and-wait on exit costs one probe's
|
||||||
shutdown-and-wait on exit costs one probe's budget, not the sum of the
|
budget, not the sum of the range.
|
||||||
range -- no `shutdown(wait=False)` and no losing threads left running
|
|
||||||
behind us.
|
|
||||||
"""
|
"""
|
||||||
with ThreadPoolExecutor(max_workers=min(len(ports), PROBE_MAX_WORKERS)) as ex:
|
with ThreadPoolExecutor(max_workers=min(len(ports), PROBE_MAX_WORKERS)) as ex:
|
||||||
results = list(ex.map(lambda port: _clienthello_probe(host, port), ports))
|
results = list(ex.map(lambda port: _clienthello_probe(host, port), ports))
|
||||||
@@ -432,19 +409,16 @@ def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
|
|||||||
def _scan_ports(host: str) -> _PortScan:
|
def _scan_ports(host: str) -> _PortScan:
|
||||||
"""Find the device's DTLS port, preferring proof over absence of evidence.
|
"""Find the device's DTLS port, preferring proof over absence of evidence.
|
||||||
|
|
||||||
The ClientHello probe is authoritative when it finds something: a port
|
The ClientHello probe is authoritative when it finds something: exactly
|
||||||
that answered one is running a DTLS server, so exactly one port gets the
|
one port gets the expensive certificate handshake instead of every port
|
||||||
expensive certificate handshake instead of every port the old UDP sweep
|
the old UDP sweep couldn't rule out (issue #211's 30-40s of 12s handshake
|
||||||
couldn't rule out (each of which cost a full 12s handshake timeout --
|
timeouts).
|
||||||
issue #211's 30-40s adds).
|
|
||||||
|
|
||||||
It stays a *gate*, not a replacement: when it confirms nothing we fall
|
It's a gate, not a replacement: when it confirms nothing, we fall back
|
||||||
back to the ICMP-based sweep, which is wrong in the opposite direction
|
to the ICMP-based sweep, which still surfaces a device the probe
|
||||||
(it reports everything it can't rule out) and so still surfaces a device
|
couldn't reach (a network path dropping the ClientHello, or an install
|
||||||
the probe couldn't reach -- e.g. a network path that drops our
|
on smartthings-local < 0.1.2). Issue #192's segregated-VLAN device is
|
||||||
ClientHello outright, or an install still on smartthings-local < 0.1.2.
|
why that fallback keeps its own preferred-port rescue.
|
||||||
Issue #192's segregated-VLAN device is the reason that fallback keeps its
|
|
||||||
own preferred-port rescue.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
confirmed = _clienthello_scan(host, PROBE_PORT_RANGE)
|
confirmed = _clienthello_scan(host, PROBE_PORT_RANGE)
|
||||||
@@ -472,12 +446,10 @@ def _scan_ports(host: str) -> _PortScan:
|
|||||||
return _PortScan(candidates, [], sweep)
|
return _PortScan(candidates, [], sweep)
|
||||||
|
|
||||||
|
|
||||||
# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and said
|
# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and
|
||||||
# no", as opposed to a protocol/cipher disagreement. decrypt_error belongs
|
# said no", as opposed to a protocol/cipher disagreement -- what an
|
||||||
# here: it's what a peer sends when CertificateVerify fails. These are the
|
# appliance sends when the CA behind the leaf isn't one it trusts, the
|
||||||
# alerts an appliance sends when the CA behind the leaf isn't one it trusts --
|
# single most common real setup mistake.
|
||||||
# the single most common real setup mistake, and the one the old blanket
|
|
||||||
# "check the IP and the CA credentials" message could never call out.
|
|
||||||
_CERT_ALERTS = frozenset(
|
_CERT_ALERTS = frozenset(
|
||||||
{
|
{
|
||||||
"bad_certificate",
|
"bad_certificate",
|
||||||
@@ -493,15 +465,13 @@ _CERT_ALERTS = frozenset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# OpenSSL renders a received fatal alert into its error text as e.g.
|
# OpenSSL renders a received fatal alert into its error text as e.g.
|
||||||
# "tlsv1 alert unknown ca" / "sslv3 alert bad certificate", which
|
# "tlsv1 alert unknown ca", which DtlsCoapSession.connect() wraps in a
|
||||||
# DtlsCoapSession.connect() wraps in a ConnectionError. Reading it back out
|
# ConnectionError. Reading it back tells us what the appliance objected to.
|
||||||
# tells us what the appliance actually objected to.
|
|
||||||
#
|
#
|
||||||
# Deliberately not the library's diagnostic probe (stateless=False), which
|
# Deliberately not the library's diagnostic probe (stateless=False): that
|
||||||
# would report the alert authoritatively: that mode drives the handshake far
|
# mode commits association state on the device, and an orphaned association
|
||||||
# enough to commit association state on the device, and an orphaned
|
# makes the next attempt time out (RFC 6347 §4.2.8) -- a bad trade on a
|
||||||
# association is exactly what makes the *next* attempt time out (RFC 6347
|
# path the user is about to retry.
|
||||||
# §4.2.8) -- a bad trade on a path the user is about to retry.
|
|
||||||
_ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)")
|
_ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)")
|
||||||
|
|
||||||
|
|
||||||
@@ -516,16 +486,12 @@ def _classify_handshake_failure(
|
|||||||
scan: _PortScan,
|
scan: _PortScan,
|
||||||
failures: list[tuple[int, Exception]],
|
failures: list[tuple[int, Exception]],
|
||||||
) -> CannotConnect:
|
) -> CannotConnect:
|
||||||
"""Turn "no port worked" into the most specific thing we can honestly say.
|
"""Turn "no port worked" into the most specific thing we can honestly
|
||||||
|
say, in rough order of how much the evidence tells us: an alert means
|
||||||
In rough order of how much the evidence tells us:
|
the appliance refused us on purpose (and says whether it was our
|
||||||
|
certificate); a confirmed DTLS port that then timed out is likely still
|
||||||
* An alert means the appliance is there, speaks DTLS, and refused us on
|
holding a session from a previous attempt; otherwise the sweep's own
|
||||||
purpose -- and the alert says whether it was about our certificate.
|
shape is the evidence.
|
||||||
* A confirmed DTLS port that then timed out is a device that is present
|
|
||||||
and healthy but wouldn't finish. Usually it's still holding the session
|
|
||||||
from a previous attempt, which clears on its own.
|
|
||||||
* Otherwise the sweep's own shape is the evidence -- see the rules below.
|
|
||||||
"""
|
"""
|
||||||
alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name]
|
alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name]
|
||||||
cert_alerts = [name for name in alerts if name in _CERT_ALERTS]
|
cert_alerts = [name for name in alerts if name in _CERT_ALERTS]
|
||||||
@@ -542,18 +508,18 @@ def _classify_handshake_failure(
|
|||||||
if sweep is None:
|
if sweep is None:
|
||||||
return CannotConnect(f"no port on {host} completed a handshake")
|
return CannotConnect(f"no port on {host} completed a handshake")
|
||||||
if sweep.unreachable and not sweep.refused:
|
if sweep.unreachable and not sweep.refused:
|
||||||
# The kernel never got the datagrams off the host, so nothing was
|
# Nothing was ever asked -- the kernel never got the datagrams off
|
||||||
# ever asked. Reporting "ports closed" here would be exactly wrong.
|
# the host, so "ports closed" would be exactly wrong.
|
||||||
return NoResponse(f"{host} is unreachable (ports {sweep.unreachable})")
|
return NoResponse(f"{host} is unreachable (ports {sweep.unreachable})")
|
||||||
if not sweep.live:
|
if not sweep.live:
|
||||||
# Every port answered ICMP port-unreachable: something is at that
|
# Every port answered ICMP port-unreachable: something is there and
|
||||||
# address and it is not exposing the local API.
|
# not exposing the local API.
|
||||||
return PortsClosed(
|
return PortsClosed(
|
||||||
f"{host} refused every port in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
|
f"{host} refused every port in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
|
||||||
)
|
)
|
||||||
if len(sweep.live) == len(PROBE_PORT_RANGE):
|
if len(sweep.live) == len(PROBE_PORT_RANGE):
|
||||||
# Not one refusal came back across a nine-port ephemeral range. A host
|
# Not one refusal across a nine-port range -- a host that's
|
||||||
# that is actually there answers for at least some of it.
|
# actually there answers for at least some of it.
|
||||||
return NoResponse(f"nothing at {host} responded on any probed port")
|
return NoResponse(f"nothing at {host} responded on any probed port")
|
||||||
return NoDtlsServer(f"ports on {host} are reachable but none answered a DTLS handshake")
|
return NoDtlsServer(f"ports on {host} are reachable but none answered a DTLS handshake")
|
||||||
|
|
||||||
@@ -586,15 +552,15 @@ def _read_device(sess, host: str, port: int) -> dict:
|
|||||||
|
|
||||||
/oic/d before /device/0, deliberately: the device's own OCF device-type
|
/oic/d before /device/0, deliberately: the device's own OCF device-type
|
||||||
declaration is the primary detection signal when a board populates it
|
declaration is the primary detection signal when a board populates it
|
||||||
(see registry/by_type's resolve()), and read_identity's three small GETs
|
(see registry/by_type's resolve()), and read_identity's three small
|
||||||
settle it long before the blockwise /device/0 dump lands. read_identity is
|
GETs settle it long before the blockwise /device/0 dump lands.
|
||||||
defensive on every GET it makes, so a device that answers neither /oic/p
|
read_identity is defensive on every GET, so a device answering neither
|
||||||
nor /oic/d just yields an empty device_types tuple and detection falls
|
/oic/p nor /oic/d falls through to the model-string/resource-signature
|
||||||
through to the model-string/resource-signature path.
|
path.
|
||||||
|
|
||||||
Everything the entry needs to name and key the device comes from here --
|
Everything the entry needs to name and key the device comes from here,
|
||||||
resolved serial, model, manufacturer, device type -- so the coordinator
|
so the coordinator never has to mint a registry key from a placeholder
|
||||||
never has to mint a registry key from a placeholder (issue #236).
|
(issue #236).
|
||||||
"""
|
"""
|
||||||
import cbor2
|
import cbor2
|
||||||
|
|
||||||
@@ -665,17 +631,15 @@ def _probe_and_validate(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Find the device's port, authenticate to it, and resolve its identity.
|
"""Find the device's port, authenticate to it, and resolve its identity.
|
||||||
|
|
||||||
Port detection runs first and needs no credentials at all, so an
|
Port detection runs first and needs no credentials, so an unreachable
|
||||||
unreachable host fails here rather than after a round trip to Samsung's
|
host fails here rather than after a round trip to Samsung's cloud.
|
||||||
cloud.
|
|
||||||
|
|
||||||
`existing_leaf` is another entry's already-minted leaf (issue #211).
|
`existing_leaf` is another entry's already-minted leaf (issue #211).
|
||||||
Every appliance accepts the same leaf -- CA `AC14K_M` plus the UUID from
|
Every appliance accepts the same leaf, so adding a second device can
|
||||||
Samsung's cloud cert -- so adding a second device can skip the fetch and
|
skip the fetch and mint entirely -- independent of Samsung-cloud
|
||||||
mint entirely, which makes it independent of Samsung-cloud reachability
|
reachability, not merely faster. If that reused leaf turns out to be
|
||||||
rather than merely faster. If that reused leaf turns out to be stale (the
|
stale (the UUID does rotate), a confirmed-live device rejecting it
|
||||||
UUID does rotate), a confirmed-live device rejecting it is unambiguous
|
re-mints and retries once, so the reuse stays self-correcting.
|
||||||
enough to re-mint and try once more, so the reuse stays self-correcting.
|
|
||||||
"""
|
"""
|
||||||
scan = _scan_ports(host)
|
scan = _scan_ports(host)
|
||||||
|
|
||||||
@@ -718,11 +682,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
def _create_entry(self, info: dict) -> ConfigFlowResult:
|
def _create_entry(self, info: dict) -> ConfigFlowResult:
|
||||||
"""Persist everything the probe resolved, identity included.
|
"""Persist everything the probe resolved, identity included.
|
||||||
|
|
||||||
The identity fields are not decoration: the coordinator seeds
|
The identity fields aren't decoration: the coordinator seeds
|
||||||
`device_serial` and its DeviceInfo from them at construction time, so
|
`device_serial` and its DeviceInfo from them at construction time,
|
||||||
entity unique_ids and device identifiers are correct from the very
|
so entity unique_ids are correct from the first entity that
|
||||||
first entity that registers -- even if the first poll is slow, or
|
registers, even if the first poll is slow or fails (issue #236).
|
||||||
fails outright (issue #236).
|
|
||||||
"""
|
"""
|
||||||
from .registry.identity import device_display_name
|
from .registry.identity import device_display_name
|
||||||
|
|
||||||
@@ -772,8 +735,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
except (CannotConnect, InvalidCA) as exc:
|
except (CannotConnect, InvalidCA) as exc:
|
||||||
# Every probe failure carries the message that fits it (see
|
# Every probe failure carries the message that fits it (see
|
||||||
# CannotConnect); the log line is where the specifics live,
|
# CannotConnect); the log line is where the specifics live.
|
||||||
# since the messages point users at it.
|
|
||||||
_LOGGER.warning("Probe of %s failed [%s]: %s", self._host, exc.error_key, exc)
|
_LOGGER.warning("Probe of %s failed [%s]: %s", self._host, exc.error_key, exc)
|
||||||
errors["base"] = exc.error_key
|
errors["base"] = exc.error_key
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -836,14 +798,11 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
|||||||
arbitrary resource href, so a user can pin down device-specific write
|
arbitrary resource href, so a user can pin down device-specific write
|
||||||
behavior without waiting on a new release.
|
behavior without waiting on a new release.
|
||||||
|
|
||||||
The remote-control override exists because most devices reject writes
|
The remote-control override exists because not every model actually
|
||||||
outright while remote control is off and a clear error beats a silent
|
enforces the block most devices do, so a user who's confirmed their
|
||||||
device-side rejection -- but not every model actually enforces that,
|
device accepts writes anyway can turn it off for just that device. The
|
||||||
so this lets a user who's confirmed their device accepts writes anyway
|
debug panel goes further, bypassing that block (and every write_fn/
|
||||||
turn the block off for just that device rather than it being
|
validate_fn) entirely.
|
||||||
hardcoded on for everyone. The debug panel goes further: it bypasses
|
|
||||||
that block (and every write_fn/validate_fn) entirely, sending exactly
|
|
||||||
the body the user types to whatever href they pick.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|||||||
@@ -20,86 +20,75 @@ CONF_CA_KEY_PEM = "ca_key_pem"
|
|||||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||||
|
|
||||||
# Device identity, resolved once by the config flow's probe and persisted on
|
# Device identity, resolved once by the config flow's probe and persisted
|
||||||
# the entry (issue #236). These are what the coordinator mints registry keys
|
# on the entry (issue #236) -- what the coordinator mints registry keys
|
||||||
# from at __init__ time, before any poll has happened -- see
|
# from at __init__ time, before any poll has happened. Without them,
|
||||||
# LocalThingsCoordinator.__init__. Without them the coordinator had to seed
|
# anything registering before the first poll (e.g. the connection-mode
|
||||||
# `device_serial` with the host and rebuild its DeviceInfo after the first
|
# sensor) got keyed on the IP address permanently.
|
||||||
# successful poll, so anything that registered in between (the connection-mode
|
|
||||||
# sensor, which is added unconditionally rather than from `bound`) was written
|
|
||||||
# into the entity/device registry keyed on the IP address permanently.
|
|
||||||
#
|
#
|
||||||
# CONF_SERIAL is the *resolved* serial -- registry.identity.resolve_serial's
|
# CONF_SERIAL is the resolved serial (registry.identity.resolve_serial's
|
||||||
# output, i.e. the host itself for a board that reports a placeholder serial
|
# output, the host itself for a placeholder-serial board -- issues
|
||||||
# (issues #83/#189) -- so it matches what _run_discovery computes on the first
|
# #83/#189), so it matches what _run_discovery computes on the first poll.
|
||||||
# poll exactly, and the device identity never changes underneath the registry.
|
|
||||||
CONF_SERIAL = "serial"
|
CONF_SERIAL = "serial"
|
||||||
CONF_MODEL = "model"
|
CONF_MODEL = "model"
|
||||||
CONF_MANUFACTURER = "manufacturer"
|
CONF_MANUFACTURER = "manufacturer"
|
||||||
CONF_DEVICE_TYPE = "device_type"
|
CONF_DEVICE_TYPE = "device_type"
|
||||||
|
|
||||||
# Options-flow key (entry.options, not entry.data): lets a user override the
|
# Options-flow key (entry.options, not entry.data): lets a user override
|
||||||
# device-wide remote-control-off write block for a specific device (issue
|
# the device-wide remote-control-off write block for a specific device
|
||||||
# #54). Some devices report remote control off yet still accept certain
|
# (issue #54). Some devices accept certain writes even while reporting
|
||||||
# writes (e.g. default detergent/softener dosing on a washer, applied even
|
# remote control off (e.g. a washer's default detergent dosing), so the
|
||||||
# to the built-in programs) -- the block exists to give a clear error
|
# blanket-block assumption doesn't hold everywhere. Defaults to False
|
||||||
# instead of a silent device-side rejection, but that assumption doesn't
|
# (block stays on).
|
||||||
# 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.
|
|
||||||
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
||||||
|
|
||||||
# Options-flow key: minimum change (in minutes) required before a
|
# Options-flow key: minimum change (in minutes) required before a
|
||||||
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
|
# hysteresis-gated timestamp sensor (currently just finish_time) reports a
|
||||||
# to report a new value. Devices commonly revise their own remaining-time
|
# new value. Devices commonly revise their remaining-time estimate by a
|
||||||
# estimate by a minute or two throughout a cycle, and finish_time = now() +
|
# minute or two throughout a cycle, and finish_time = now() + remaining
|
||||||
# remaining drifts by the poll interval between those revisions -- both push
|
# drifts with the poll interval between revisions -- both push a fresh
|
||||||
# a fresh state (and a recorder/logbook entry) far more often than the
|
# state far more often than the estimate is meaningfully different. 0
|
||||||
# estimate is meaningfully different. 0 disables the gate (every computed
|
# disables the gate.
|
||||||
# change is reported, today's behavior).
|
|
||||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
|
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
|
||||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
|
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
|
||||||
|
|
||||||
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
|
# The DTLS/CoAP local API binds somewhere in this ephemeral range,
|
||||||
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
|
# depending on firmware (newer builds answer on 49154/49155, older ones as
|
||||||
# been seen as low as 49153, so we sweep the whole range for a live UDP port
|
# low as 49153) -- swept for a live UDP port before the expensive DTLS
|
||||||
# before attempting the (expensive) DTLS handshake.
|
# handshake.
|
||||||
PROBE_PORT_RANGE = list(range(49152, 49161))
|
PROBE_PORT_RANGE = list(range(49152, 49161))
|
||||||
|
|
||||||
# Ports we've historically seen complete a DTLS handshake. When more than one
|
# Ports we've historically seen complete a DTLS handshake; tried first when
|
||||||
# port in the range looks live, these are tried first.
|
# more than one port in the range looks live.
|
||||||
PREFERRED_PROBE_PORTS = [49154, 49155]
|
PREFERRED_PROBE_PORTS = [49154, 49155]
|
||||||
|
|
||||||
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
|
# 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
|
# ICMP port-unreachable almost immediately; a live-but-silent port is only
|
||||||
# detected by this timeout elapsing, so keep it short. Only reached now as the
|
# detected by this timeout elapsing, so keep it short. Only reached as the
|
||||||
# fallback for when the ClientHello probe below confirms nothing.
|
# fallback for when the ClientHello probe below confirms nothing.
|
||||||
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
||||||
|
|
||||||
# Per-port budget for the DTLS ClientHello probe (smartthings-local >= 0.1.2),
|
# Per-port budget for the DTLS ClientHello probe (smartthings-local >=
|
||||||
# the primary port-detection gate. A real DTLS server answers with a
|
# 0.1.2), the primary port-detection gate. A real server answers with a
|
||||||
# HelloVerifyRequest in ~1 RTT, so a live port resolves well inside this; the
|
# HelloVerifyRequest in ~1 RTT; the budget only bounds how long a silent
|
||||||
# budget only bounds how long a *silent* port takes to give up, since the
|
# port takes to give up. 3s covers two retransmits on a slow LAN.
|
||||||
# probe services OpenSSL's retransmit timer rather than reading one dropped
|
|
||||||
# ClientHello as dead. 3s covers two retransmits on a slow LAN.
|
|
||||||
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
|
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
|
||||||
CLIENTHELLO_PROBE_RETRIES = 2
|
CLIENTHELLO_PROBE_RETRIES = 2
|
||||||
|
|
||||||
# The whole port range is probed at once: each stateless probe is bounded by
|
# The whole port range is probed at once: each stateless probe is bounded
|
||||||
# CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the sweep
|
# by CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the
|
||||||
# costs one probe's wall clock rather than the sum of the range. Capped so a
|
# 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.
|
# widened PROBE_PORT_RANGE can't spawn an unbounded thread pool.
|
||||||
PROBE_MAX_WORKERS = 12
|
PROBE_MAX_WORKERS = 12
|
||||||
|
|
||||||
# Deadline for the blockwise /device/0 GET during the config-flow probe. The
|
# Deadline for the blockwise /device/0 GET during the config-flow probe.
|
||||||
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
|
# The slowest device observed returns a full dump in ~8s.
|
||||||
# without stalling setup; it matches the per-resource read timeout elsewhere.
|
|
||||||
PROBE_GET_TIMEOUT_S = 10.0
|
PROBE_GET_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
# Base for the local (client-side) DTLS source port, distinct from the
|
# Base for the local (client-side) DTLS source port, distinct from the
|
||||||
# destination probe ports above. See coordinator._local_source_port for why a
|
# destination probe ports above -- see coordinator._local_source_port for
|
||||||
# fixed per-device source port matters and how the per-device offset is
|
# why a fixed per-device source port matters. Mirrors the upstream
|
||||||
# derived. Base mirrors the upstream smartthings-local reference bridge.
|
# smartthings-local reference bridge. Requires smartthings-local >= 0.1.1.
|
||||||
# Requires smartthings-local >= 0.1.1.
|
|
||||||
DTLS_LOCAL_PORT_BASE = 49700
|
DTLS_LOCAL_PORT_BASE = 49700
|
||||||
|
|
||||||
SUMMARY_INTERVAL_S = 30.0
|
SUMMARY_INTERVAL_S = 30.0
|
||||||
|
|||||||
@@ -70,9 +70,8 @@ _SEED_PATH = ["device", "0"]
|
|||||||
|
|
||||||
|
|
||||||
class _NoOpDescriptor:
|
class _NoOpDescriptor:
|
||||||
"""StateCache requires a descriptor with an on_observation hook. This
|
"""No-op: StateCache requires an on_observation hook; this integration
|
||||||
integration doesn't use per-capability observation hooks, so this is a
|
doesn't use per-capability observation hooks."""
|
||||||
deliberate no-op, not a placeholder for missing functionality."""
|
|
||||||
|
|
||||||
def on_observation(self, state: dict, href: str, rep: dict) -> None:
|
def on_observation(self, state: dict, href: str, rep: dict) -> None:
|
||||||
return None
|
return None
|
||||||
@@ -84,18 +83,15 @@ _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
|
|||||||
def _local_source_port(host: str) -> int:
|
def _local_source_port(host: str) -> int:
|
||||||
"""Deterministic UDP source port for this device's DTLS socket.
|
"""Deterministic UDP source port for this device's DTLS socket.
|
||||||
|
|
||||||
Binding the same source port on every (re)connect keeps the client on one
|
Binding the same source port across reconnects lets the appliance evict
|
||||||
5-tuple, so the appliance evicts an orphaned session left by a previous run
|
an orphaned session (unclean shutdown, no close_notify) at handshake
|
||||||
(unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347
|
time per RFC 6347 §4.2.8, instead of holding it 5-15 min. See
|
||||||
§4.2.8, instead of holding it for 5-15 min while the new session's reads
|
DTLS_LOCAL_PORT_BASE. Requires smartthings-local >= 0.1.1.
|
||||||
hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local
|
|
||||||
>= 0.1.1 (the version that added DtlsCoapSession(local_port=...)).
|
|
||||||
|
|
||||||
The port must be stable across restarts and unique per device on this HA
|
Must stay unique per device on this host too: the library's socket is
|
||||||
host: the library's socket is unconnected (recvfrom), so two devices
|
unconnected, so two devices sharing a port would mis-demux each other's
|
||||||
sharing a source port would mis-demux each other's datagrams. For the usual
|
datagrams. Last IPv4 octet as offset for the common case; a stable
|
||||||
dotted-IPv4 host we use the last octet as the offset (unique on a /24);
|
CRC32 fold otherwise.
|
||||||
anything else folds a stable CRC32 into the same 256-wide window.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
offset = int(ipaddress.IPv4Address(host)) & 0xFF
|
offset = int(ipaddress.IPv4Address(host)) & 0xFF
|
||||||
@@ -111,52 +107,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
device_info: DeviceInfo
|
device_info: DeviceInfo
|
||||||
device_serial: str
|
device_serial: str
|
||||||
|
|
||||||
# Class-level knobs (not instance attrs) so tests can shrink real-time
|
# Class-level so tests can shrink these via patch.object() without
|
||||||
# delays via `patch.object(LocalThingsCoordinator, ...)` without
|
# touching the production defaults.
|
||||||
# touching the production defaults these are computed from. Production
|
|
||||||
# code always sees these two values; only tests override them.
|
|
||||||
_SUBPOLL_STEP_S: float = SUMMARY_INTERVAL_S / 10 # 3.0 s
|
_SUBPOLL_STEP_S: float = SUMMARY_INTERVAL_S / 10 # 3.0 s
|
||||||
_OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S
|
_OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S
|
||||||
_RECONNECT_PAUSE_S: float = 5.0
|
_RECONNECT_PAUSE_S: float = 5.0
|
||||||
|
|
||||||
# A single reconnect is normal appliance-side behavior (see the
|
# A single reconnect is normal appliance behavior (README's "Known
|
||||||
# README's "Known device behavior" section) -- Samsung's firmware
|
# device behavior"); only escalate once they pile up in a trailing
|
||||||
# drops the DTLS session briefly every now and then, and the
|
# window (issue #119). Can't be a literal 60s: consecutive attempts are
|
||||||
# coordinator recovering from that on its own isn't something a user
|
# always >= one summary interval + _RECONNECT_PAUSE_S apart, so at most
|
||||||
# needs to see at WARNING. Only escalate once reconnects pile up
|
# ~2 could ever land in 60s regardless of how unhealthy the connection
|
||||||
# within a trailing window (issue #119).
|
# is. 300s/3 is reachable under normal polling and still a reasonable
|
||||||
#
|
# "actually broken" proxy.
|
||||||
# The window can't be a literal 60s: consecutive reconnect attempts are
|
|
||||||
# never closer together than one summary poll interval (SUMMARY_INTERVAL_S,
|
|
||||||
# 30s) plus _RECONNECT_PAUSE_S, so at most ~2 can ever land inside a 60s
|
|
||||||
# window regardless of how unhealthy the connection is -- a threshold of
|
|
||||||
# 5 there could never fire, silently downgrading every reconnect
|
|
||||||
# (including a persistently broken one) to INFO forever. 300s/3 instead:
|
|
||||||
# reachable under normal polling, and 3 reconnects inside 5 minutes is
|
|
||||||
# still a reasonable proxy for the README's "actually broken" case.
|
|
||||||
_RECONNECT_WARN_WINDOW_S: float = 300.0
|
_RECONNECT_WARN_WINDOW_S: float = 300.0
|
||||||
_RECONNECT_WARN_THRESHOLD: int = 3
|
_RECONNECT_WARN_THRESHOLD: int = 3
|
||||||
|
|
||||||
# A block-level ACK timeout on the summary GET doesn't prove the
|
# A block-level ACK timeout on the summary GET doesn't prove the session
|
||||||
# session is dead (see _poll_once) — require this many in a row
|
# is dead (see _poll_once) -- require this many in a row before treating
|
||||||
# before treating it as one. A single slow transfer on an otherwise
|
# it as one, so one slow transfer doesn't tear down a working OBSERVE
|
||||||
# fine session shouldn't tear down a working OBSERVE subscription.
|
# subscription.
|
||||||
_POLL_TIMEOUT_LIMIT: int = 3
|
_POLL_TIMEOUT_LIMIT: int = 3
|
||||||
|
|
||||||
# Timeouts for the two network round trips a write triggers: the PUT
|
# Named (not inline literals) so the write-settle window in
|
||||||
# itself (_do_put), then the confirming full /device/0 summary poll
|
# async_send_command can be sized to outlast both round trips a write
|
||||||
# async_send_command requests right after (_poll_once). Named here
|
# triggers: the PUT itself, then the confirming summary poll.
|
||||||
# (rather than left as inline literals) so the write-settle window
|
|
||||||
# below can be sized to always outlast both — see async_send_command.
|
|
||||||
_POST_TIMEOUT_S: float = 8.0
|
_POST_TIMEOUT_S: float = 8.0
|
||||||
_POLL_TIMEOUT_S: float = 35.0
|
_POLL_TIMEOUT_S: float = 35.0
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
# Per-device logger (module logger scoped to this device's host) so
|
# Per-device logger so every log line (including the base
|
||||||
# every log line — including the base DataUpdateCoordinator's own
|
# coordinator's and ObserveManager's) identifies which device it's
|
||||||
# messages and ObserveManager's — identifies which device it's
|
# about, instead of a shared module-level logger.
|
||||||
# about. A bare module-level logger is shared across every
|
|
||||||
# configured device, which makes multi-device logs ambiguous.
|
|
||||||
self._log = logging.getLogger(f"{__name__}.{entry.data[CONF_HOST]}")
|
self._log = logging.getLogger(f"{__name__}.{entry.data[CONF_HOST]}")
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
@@ -170,59 +152,40 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._identity: DeviceIdentity | None = None
|
self._identity: DeviceIdentity | None = None
|
||||||
self._discovered = False
|
self._discovered = False
|
||||||
self.bound = []
|
self.bound = []
|
||||||
# Sibling indoor subdevices discovered on this connection (issue
|
# Sibling indoor subdevices on this connection (issue #177); set
|
||||||
# #177) -- candidates set once, at first discovery, by
|
# once at first discovery, narrowed to the ones with live state (see
|
||||||
# _enumerate_subdevices_blocking; narrowed by _run_discovery to the
|
# subdevices.discover_partitioned). Never includes MAIN itself.
|
||||||
# ones that actually produced live primary state (see
|
|
||||||
# subdevices.discover_partitioned). MAIN itself is never in this list
|
|
||||||
# (see subdevices.canonical_view's docstring for why that's safe):
|
|
||||||
# it's the *other* subdevices sharing this DTLS session, if any.
|
|
||||||
self.subdevices: list[Subdevice] = []
|
self.subdevices: list[Subdevice] = []
|
||||||
# Candidates _run_discovery's gate rejected (an unused SmartThings
|
# Candidates the liveness gate rejected (e.g. an unused SmartThings
|
||||||
# slot that still answers its seed, e.g. the issue #177 reporter's
|
# slot that still answers its seed) -- surfaced in diagnostics.
|
||||||
# /device/2) -- surfaced in diagnostics alongside the materialized
|
|
||||||
# ones so a report shows what was found and why it didn't become an
|
|
||||||
# entity.
|
|
||||||
self._skipped_subdevices: list = []
|
self._skipped_subdevices: list = []
|
||||||
# Those rejected candidates' raw reps, kept aside for diagnostics
|
# Rejected candidates' raw reps, kept for diagnostics only (see
|
||||||
# only (see _live_subdevice_resources). They are deliberately not in the
|
# _live_subdevice_resources) -- never applied to the state cache, or
|
||||||
# state cache: nothing polls them again, so anything applied there
|
# they'd sit frozen at first-discovery value looking live.
|
||||||
# would sit frozen at its first-discovery value while looking as
|
|
||||||
# live as every other href in `last_resources`.
|
|
||||||
self._skipped_subdevice_resources: dict[str, dict] = {}
|
self._skipped_subdevice_resources: dict[str, dict] = {}
|
||||||
# /multidevice/vs/0's rep, if this board answers it -- a plain
|
# /multidevice/vs/0's rep if this board answers it -- corroborates
|
||||||
# subdevice count that corroborates the liveness gate without deciding it.
|
# the liveness gate without deciding it; kept outside `resources`.
|
||||||
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
|
|
||||||
self._multidevice: dict = {}
|
self._multidevice: dict = {}
|
||||||
# What each subdevice probe found, keyed by the seed href attempted --
|
# What each subdevice probe found, keyed by seed href -- lets
|
||||||
# surfaced in diagnostics so a report can tell "checked, nothing
|
# diagnostics distinguish "checked, nothing there" from "never
|
||||||
# there" apart from "never checked" (the same posture the
|
# checked".
|
||||||
# speculative-probe code this replaced documented in identity.py).
|
|
||||||
self._subdevice_probes: dict[str, bool] = {}
|
self._subdevice_probes: dict[str, bool] = {}
|
||||||
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.key).
|
# canonical_resources() memo; invalidated in _on_cache_changed so
|
||||||
# Invalidated in _on_cache_changed -- climate.py reads this on every
|
# climate.py's frequent per-property reads don't rebuild it from
|
||||||
# property access (is_legacy_board and friends), so it must not
|
# scratch each time.
|
||||||
# rebuild an O(hrefs) view from scratch on every single property.
|
|
||||||
self._canonical_cache: dict[tuple[str, str], dict] = {}
|
self._canonical_cache: dict[tuple[str, str], dict] = {}
|
||||||
self._cache = StateCache(_NoOpDescriptor())
|
self._cache = StateCache(_NoOpDescriptor())
|
||||||
self._cache.set_on_change(self._on_cache_changed)
|
self._cache.set_on_change(self._on_cache_changed)
|
||||||
self._observe = ObserveManager(self._cache, logger=self._log)
|
self._observe = ObserveManager(self._cache, logger=self._log)
|
||||||
self._push_pending = False
|
self._push_pending = False
|
||||||
self._push_pending_lock = threading.Lock()
|
self._push_pending_lock = threading.Lock()
|
||||||
# Identity comes from the config entry, resolved once by the config
|
# Identity is resolved once by the config flow's probe (issue #236).
|
||||||
# flow's probe (issue #236). `device_serial` mints *permanent*
|
# device_serial mints permanent registry keys, so it must be correct
|
||||||
# registry keys -- entity unique_ids (entity.py, sensor.py) and device
|
# before the first entity registers -- a placeholder corrected once
|
||||||
# identifiers (device_info_for) -- so it must be the device's real
|
# the first poll lands orphans the first device/entity pair instead.
|
||||||
# identity before the first entity registers, not a placeholder that
|
# The host fallback covers a pre-migration entry and matches what
|
||||||
# gets corrected once the first poll lands. Anything registered
|
# resolve_serial itself returns for a placeholder-serial board
|
||||||
# against a placeholder is keyed on it in the registry forever; when
|
# (issues #83/#189).
|
||||||
# the real identity showed up moments later, HA created a second
|
|
||||||
# device and a second entity and orphaned the first pair.
|
|
||||||
#
|
|
||||||
# The host fallback covers a config entry created before this was
|
|
||||||
# stored and whose migration couldn't recover it. It is also what
|
|
||||||
# resolve_serial itself returns for a board reporting a placeholder
|
|
||||||
# serial (issues #83/#189), so the two agree by construction.
|
|
||||||
self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
|
self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
|
||||||
self.device_info = DeviceInfo(
|
self.device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, self.device_serial)},
|
identifiers={(DOMAIN, self.device_serial)},
|
||||||
@@ -251,26 +214,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
return self._cache.snapshot()
|
return self._cache.snapshot()
|
||||||
|
|
||||||
def resource(self, href: str) -> dict:
|
def resource(self, href: str) -> dict:
|
||||||
"""A single href's current rep. Cheaper than `last_resources.get(href)`
|
"""A single href's rep. Cheaper than `last_resources.get(href)`,
|
||||||
for callers that only need one href — `last_resources` copies every
|
which copies every tracked href to build the snapshot dict."""
|
||||||
tracked href's rep to build the snapshot dict, while this is a
|
|
||||||
direct O(1) cache lookup."""
|
|
||||||
return self._cache.get(href) or {}
|
return self._cache.get(href) or {}
|
||||||
|
|
||||||
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
|
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||||
"""`subdevice`'s own view of the live snapshot, rewritten into the
|
"""`subdevice`'s view of the live snapshot, rewritten to canonical
|
||||||
canonical hrefs (issue #177) the registry/platforms are written
|
hrefs (issue #177, see subdevices.canonical_view). Any platform
|
||||||
against -- see subdevices.canonical_view. A platform property that
|
property that scans the whole resources dict (exists_fn,
|
||||||
needs the *whole* resources dict (as opposed to one href via
|
is_legacy_board, ...) must use this instead of `last_resources`, or a
|
||||||
`resource()`/`last_resources.get(href)`) must use this instead of
|
sibling subdevice's own `/mode/vs/1` could leak into MAIN's canonical
|
||||||
`last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak
|
`/mode/vs/0` view. Memoized per cache generation -- see
|
||||||
into MAIN's canonical `/mode/vs/0` view (or vice versa) under
|
_canonical_cache.
|
||||||
exists_fn/is_legacy_board-style checks that scan the whole dict.
|
|
||||||
|
|
||||||
Memoized per cache generation: climate.py calls this on every
|
|
||||||
property read (is_legacy_board and friends), and building it is
|
|
||||||
O(hrefs) -- _on_cache_changed clears the memo whenever the
|
|
||||||
snapshot actually changes, not on every property access.
|
|
||||||
"""
|
"""
|
||||||
view_key = (subdevice.kind, subdevice.key)
|
view_key = (subdevice.kind, subdevice.key)
|
||||||
cached = self._canonical_cache.get(view_key)
|
cached = self._canonical_cache.get(view_key)
|
||||||
@@ -281,18 +236,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
return view
|
return view
|
||||||
|
|
||||||
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
|
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
|
||||||
"""DeviceInfo for one logical subdevice sharing this connection
|
"""DeviceInfo for one logical subdevice on this connection (issue
|
||||||
(issue #177) -- the master's own (unchanged) device_info for MAIN, or
|
#177): the master's own device_info for MAIN, or a linked child
|
||||||
a linked child device for a discovered subdevice.
|
device otherwise.
|
||||||
|
|
||||||
Identifiers derive from the *master's* serial (device_serial) plus
|
Identifiers derive from the master's serial plus this subdevice's
|
||||||
this subdevice's stable key, never from whatever serial the
|
stable key, never the subdevice's own reported serial -- deterministic
|
||||||
subdevice itself reports (or fails to) -- deterministic across
|
across reconnects regardless of whether its identity resource
|
||||||
reconnects whether or not this subdevice's own identity resource
|
answered yet. `serial_number` is set from it when present anyway,
|
||||||
(/information/vs/<n>, or /<id>/information/vs/0) answered on the
|
but is informational only, not an identifier.
|
||||||
poll that first created the HA device. `serial_number` is set from
|
|
||||||
that resource when present anyway -- it's informational, not an
|
|
||||||
identifier.
|
|
||||||
"""
|
"""
|
||||||
if subdevice.kind == "main":
|
if subdevice.kind == "main":
|
||||||
return self.device_info
|
return self.device_info
|
||||||
@@ -304,12 +256,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
if model:
|
if model:
|
||||||
label = model.replace("_", " ").title()
|
label = model.replace("_", " ").title()
|
||||||
else:
|
else:
|
||||||
# This poll never got (or never will get) the subdevice's own
|
# No identity resource yet (or ever) for this subdevice -- fall
|
||||||
# identity resource -- fall back to a generic per-subdevice label
|
# back to a generic label. 'Subdevice <n>' only applies to an
|
||||||
# rather than leaving the device unnamed. 'Subdevice <n>' only
|
# indexed subdevice; UUID-prefixed ones are never more than one
|
||||||
# makes sense for an indexed subdevice (the key is a small
|
# per connection today.
|
||||||
# ordinal); UUID-prefixed subdevices are never more than one per
|
|
||||||
# connection today, so there's no ordinal to show.
|
|
||||||
label = (
|
label = (
|
||||||
f"Subdevice {subdevice.key}"
|
f"Subdevice {subdevice.key}"
|
||||||
if subdevice.kind == "indexed"
|
if subdevice.kind == "indexed"
|
||||||
@@ -392,25 +342,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
def _poll_once(self) -> dict[str, dict]:
|
def _poll_once(self) -> dict[str, dict]:
|
||||||
"""GET /device/0, return parsed resources. Blocking.
|
"""GET /device/0, return parsed resources. Blocking.
|
||||||
|
|
||||||
`sess.get()` raises `TimeoutError` when one block's ACK doesn't
|
A `TimeoutError` here means one block's ACK didn't arrive in time --
|
||||||
arrive in time — the transfer was progressing (earlier blocks
|
not that the session is dead (earlier blocks succeeded). Left open;
|
||||||
succeeded) and just didn't finish before the deadline on a slow
|
`_async_update_data` decides whether repeated timeouts warrant a
|
||||||
device. That does NOT prove the session is dead, so it's left
|
reconnect. Any other exception is unambiguous -- close immediately.
|
||||||
open here; `_async_update_data` decides whether repeated timeouts
|
|
||||||
(or a lack of them) warrant a reconnect. Anything else (a
|
|
||||||
`ConnectionError` from an explicitly closed/broken session, a bad
|
|
||||||
response code) is unambiguous — close immediately.
|
|
||||||
"""
|
"""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
self._connect_session()
|
self._connect_session()
|
||||||
sess = self._session
|
sess = self._session
|
||||||
assert sess is not None
|
assert sess is not None
|
||||||
try:
|
try:
|
||||||
# A slow device can still be mid-transfer (block 8, block 11)
|
# 35s gives a slow blockwise transfer room to finish instead of
|
||||||
# when a tighter deadline cuts it off — that's a poll that
|
# raising TimeoutError every cycle on an otherwise-fine device.
|
||||||
# would have succeeded, not a dead session. 35s gives a slow
|
|
||||||
# blockwise transfer room to actually finish instead of
|
|
||||||
# generating a TimeoutError every cycle.
|
|
||||||
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
|
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise
|
raise
|
||||||
@@ -425,21 +368,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"poll cbor decode: {e}") from e
|
raise RuntimeError(f"poll cbor decode: {e}") from e
|
||||||
result = parse_device0_batch(body) if isinstance(body, list) else {}
|
result = parse_device0_batch(body) if isinstance(body, list) else {}
|
||||||
# Refresh every already-enumerated sibling subdevice's seed collection
|
# Refresh every enumerated sibling's seed on this same poll (issue
|
||||||
# on this same summary poll (issue #177) -- without this, a subdevice's
|
# #177) so its state doesn't freeze at enumeration time.
|
||||||
# climate card would show only its enumeration-time snapshot forever.
|
|
||||||
for subdevice in self.subdevices:
|
for subdevice in self.subdevices:
|
||||||
result.update(self._poll_subdevice_seed(subdevice))
|
result.update(self._poll_subdevice_seed(subdevice))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
|
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||||
"""GET one subdevice's seed Collection and return its batch,
|
"""GET one subdevice's seed Collection, normalized to real hrefs. A
|
||||||
normalized to real hrefs. A sibling failing to answer is a debug
|
sibling failing to answer is a debug log, never a failed poll -- the
|
||||||
log, never a failed poll -- the master must not go unavailable
|
issue #177 reporter's /device/2 (an unused SmartThings slot) may not
|
||||||
because a sibling timed out or dropped off (e.g. the issue #177
|
always respond, and the master must not go unavailable for that.
|
||||||
reporter's /device/2, a SmartThings-unused component that may not
|
Blocking -- called from _poll_once, already in executor."""
|
||||||
always respond). Blocking -- called from _poll_once, already in
|
|
||||||
executor."""
|
|
||||||
sess = self._session
|
sess = self._session
|
||||||
if sess is None:
|
if sess is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -456,28 +396,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
|
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
|
||||||
"""Re-poll a flat-mode prefixed subdevice's hrefs individually
|
"""Re-poll a flat-mode subdevice's hrefs individually (issue #205) --
|
||||||
(issue #205) -- it has no Collection endpoint to batch-refresh
|
it has no Collection endpoint to batch-refresh through (see
|
||||||
through (see enumerate_subdevices' fallback), so each canonical
|
enumerate_subdevices' fallback), so each confirmed href gets its own
|
||||||
href confirmed at enumeration time gets its own GET under the
|
GET under the subdevice's prefix. A failing href just drops out of
|
||||||
subdevice's prefix. A href failing to answer this cycle just drops
|
the result, same posture as the Collection path above.
|
||||||
out of the result, same "never let a sibling's flakiness fail the
|
|
||||||
master's poll" posture as the Collection path above.
|
|
||||||
|
|
||||||
Takes `sess` from the caller (already None-checked there) rather
|
Takes `sess` from the caller rather than re-reading self._session --
|
||||||
than re-reading self._session -- async_close() can null that
|
async_close() can null it without holding _session_lock. Skips hrefs
|
||||||
without holding _session_lock, and pace()/get() both need a live
|
already covered by the hot/warm sub-poll tiers, which
|
||||||
session on every iteration, not just the first.
|
_run_subpolls refreshes every 3s/6s, strictly more current than
|
||||||
|
this once-per-summary-poll pass could offer."""
|
||||||
Skips any href already covered by the hot/warm sub-poll tiers
|
|
||||||
(self._hot_hrefs/_warm_hrefs, in the same actual/on-the-wire form
|
|
||||||
this method builds) -- those are already refreshed every 3s/6s by
|
|
||||||
_run_subpolls, strictly more current than this once-per-summary-poll
|
|
||||||
pass could offer, so re-fetching them here would only add GETs
|
|
||||||
without adding freshness. A subdevice with many confirmed hrefs
|
|
||||||
(unlike a Collection batch, which is always one GET regardless of
|
|
||||||
count) is otherwise a summary-poll cost that scales with its href
|
|
||||||
count."""
|
|
||||||
skip = set(self._hot_hrefs) | set(self._warm_hrefs)
|
skip = set(self._hot_hrefs) | set(self._warm_hrefs)
|
||||||
result: dict[str, dict] = {}
|
result: dict[str, dict] = {}
|
||||||
first = True
|
first = True
|
||||||
@@ -531,14 +460,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _run_subpolls(self, force: bool = False) -> None:
|
async def _run_subpolls(self, force: bool = False) -> None:
|
||||||
"""Poll hot/warm hrefs in the gaps between summary polls. Only
|
"""Poll hot/warm hrefs in the gaps between summary polls. No-op in
|
||||||
runs in poll-only mode — in observe-primary mode those hrefs are
|
observe-primary mode (those hrefs are already covered by push)
|
||||||
already covered by push notifications — unless `force` is set,
|
unless `force` is set -- set when this cycle's sweep found the
|
||||||
which this cycle's sweep found disagreeing with the cache on a
|
cache disagreeing with a still-live observe session (see
|
||||||
still-live observe session (see log_sweep_discrepancies): a
|
log_sweep_discrepancies): a bounded fallback for a channel gone
|
||||||
bounded, self-limiting fallback for a channel that's gone silent
|
silent without a reconnect."""
|
||||||
without a reconnect, without tearing down subscriptions that
|
|
||||||
would otherwise recover on their own once notifies resume."""
|
|
||||||
if self._observe.mode == MODE_OBSERVE and not force:
|
if self._observe.mode == MODE_OBSERVE and not force:
|
||||||
return
|
return
|
||||||
hot = self._hot_hrefs
|
hot = self._hot_hrefs
|
||||||
@@ -560,21 +487,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||||
"""One-time (first discovery only) probe for sibling indoor subdevices
|
"""One-time (first discovery only) probe for sibling indoor
|
||||||
sharing this connection (issue #177) -- see
|
subdevices on this connection (issue #177) -- see
|
||||||
registry.subdevices.enumerate_subdevices for the two detection
|
registry.subdevices.enumerate_subdevices for the two detection
|
||||||
patterns. Blocking -- runs in executor, under the session lock
|
patterns. Runs in executor, under the session lock.
|
||||||
(shares the same DTLS session _poll_once just used this cycle).
|
|
||||||
|
|
||||||
Sets self.subdevices to every *candidate* the probes turned up
|
Sets self.subdevices to every candidate found and returns
|
||||||
(self._subdevice_probes as a side effect too) and returns `resources`
|
`resources` merged with each candidate's seed, so this cycle's
|
||||||
merged with whatever each candidate's seed returned, so this cycle's
|
_run_discovery sees every candidate without a second round trip.
|
||||||
_run_discovery sees every candidate's state without a second poll
|
_run_discovery is what narrows this down to the ones actually live
|
||||||
round trip. `_run_discovery` is what narrows self.subdevices down to
|
(see discover_partitioned) -- this method can't tell an unused
|
||||||
the ones that are actually live (see discover_partitioned) -- this
|
SmartThings slot from a real sibling, only that something answered.
|
||||||
method doesn't know how to tell an unused SmartThings slot (the
|
|
||||||
issue #177 reporter's /device/2) from a real sibling, only that
|
|
||||||
something answered.
|
|
||||||
"""
|
"""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
self._connect_session()
|
self._connect_session()
|
||||||
@@ -592,28 +515,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.subdevices = subdevices
|
self.subdevices = subdevices
|
||||||
self._subdevice_probes = probes
|
self._subdevice_probes = probes
|
||||||
# /multidevice/vs/0 is corroborating metadata, not appliance state,
|
# /multidevice/vs/0 is corroborating metadata, not appliance state,
|
||||||
# and it is probed on *every* device -- so it must not join the
|
# and is probed on every device -- it must not join `resources`, or
|
||||||
# returned resources dict. Two things go wrong if it does. It would
|
# it would bind to nothing on families that don't ignore the href
|
||||||
# reach discovery on families whose registry doesn't ignore that
|
# (raising a spurious coverage-gap repair) and freeze in the cache
|
||||||
# href (only the AC one does), binding to nothing and raising a
|
# since it's never polled again (see _live_subdevice_resources).
|
||||||
# spurious "incomplete capability coverage" repair for every washer
|
# Kept aside for diagnostics and the numofsubdevice cross-check in
|
||||||
# or fridge whose firmware happens to answer it. And it is fetched
|
# _run_discovery instead.
|
||||||
# once here and never polled again, so applying it to the state
|
|
||||||
# cache would freeze it there exactly like a rejected candidate's
|
|
||||||
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
|
|
||||||
# for the numofsubdevice cross-check in _run_discovery instead.
|
|
||||||
self._multidevice = extra.pop("/multidevice/vs/0", {})
|
self._multidevice = extra.pop("/multidevice/vs/0", {})
|
||||||
return {**resources, **extra}
|
return {**resources, **extra}
|
||||||
|
|
||||||
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
|
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||||
"""`resources` minus every href belonging to a candidate subdevice the
|
"""`resources` minus every href belonging to a rejected subdevice
|
||||||
liveness gate rejected (issue #177).
|
candidate (issue #177). Called once, between _run_discovery and the
|
||||||
|
first cache apply, so a rejected slot's reps are seen by the gate
|
||||||
Called once, between _run_discovery and the first cache apply, so a
|
and then dropped rather than frozen into the cache forever. Kept in
|
||||||
rejected slot's reps are seen by the gate and then dropped rather
|
_skipped_subdevice_resources for diagnostics.
|
||||||
than frozen into the cache forever -- see the call site. The reps
|
|
||||||
themselves are kept in _skipped_subdevice_resources for diagnostics,
|
|
||||||
which is the only thing that still wants them.
|
|
||||||
"""
|
"""
|
||||||
if not self._skipped_subdevices:
|
if not self._skipped_subdevices:
|
||||||
return resources
|
return resources
|
||||||
@@ -638,16 +554,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Write this device's resolved identity back onto the config entry.
|
"""Write this device's resolved identity back onto the config entry.
|
||||||
|
|
||||||
For an entry added by the current config flow this is a no-op -- the
|
A no-op for an entry the current config flow already fully stored.
|
||||||
probe already stored all four. It matters for an entry migrated from
|
Matters for an entry migrated from before identity was stored: the
|
||||||
before they were stored: the first poll is where its model and device
|
first poll is where model/type become known, and persisting them
|
||||||
type become known, and persisting them means the *next* restart
|
means the next restart names the device fully instead of renaming it
|
||||||
registers the device fully named before any entity exists, instead of
|
again once a poll lands.
|
||||||
renaming it a second time once the poll lands.
|
|
||||||
|
|
||||||
Runs on the event loop (_run_discovery is called directly from
|
Runs on the event loop, which async_update_entry requires.
|
||||||
_async_update_data, not in an executor), which async_update_entry
|
|
||||||
requires.
|
|
||||||
"""
|
"""
|
||||||
identity = {
|
identity = {
|
||||||
CONF_SERIAL: serial,
|
CONF_SERIAL: serial,
|
||||||
@@ -662,10 +575,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
||||||
# Reported for diagnostics only -- it names the firmware generation
|
# Diagnostics only -- names the firmware generation (e.g. '7.0 Air
|
||||||
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
|
# conditioner' is Tizen Lite); doesn't route, since every device
|
||||||
# an issue. It does not route: only a minority of hardware reports it
|
# that reports it is already typed by modelNum.
|
||||||
# at all, and every device that does is already typed by its modelNum.
|
|
||||||
self.one_ui_version = (
|
self.one_ui_version = (
|
||||||
resources.get("/otninformation/vs/0", {})
|
resources.get("/otninformation/vs/0", {})
|
||||||
.get("swVersionInfo", {})
|
.get("swVersionInfo", {})
|
||||||
@@ -685,17 +597,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
description = info.get("x.com.samsung.da.description", "")
|
description = info.get("x.com.samsung.da.description", "")
|
||||||
|
|
||||||
# Partitioned discovery (issue #177): the main pass binds every href
|
# Partitioned discovery (issue #177): the main pass binds every href
|
||||||
# owned by no subdevice; one further pass per *candidate* subdevice
|
# owned by no subdevice; one further pass per candidate subdevice
|
||||||
# binds its own canonical view, resolving its own device type from
|
# binds its own canonical view (see subdevices.discover_partitioned),
|
||||||
# its own /information/vs/0 when it reports one and falling back to
|
# gated on whether it actually produced live primary state (an
|
||||||
# the master's registry otherwise. See subdevices.discover_partitioned
|
# unused SmartThings slot answers its seed but never does). A device
|
||||||
# -- it also gates each candidate down to whether it actually
|
# with no candidates behaves exactly like the old single discover()
|
||||||
# produced live primary state (the issue #177 reporter's /device/2,
|
# call.
|
||||||
# an unused SmartThings slot, answers its seed but never does), so
|
|
||||||
# self.subdevices below is narrowed to the ones that passed, not
|
|
||||||
# every candidate _enumerate_subdevices_blocking found. For a device
|
|
||||||
# with no candidates (self.subdevices == []) this is exactly the
|
|
||||||
# single discover() call this method used to make.
|
|
||||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||||
resources,
|
resources,
|
||||||
self.subdevices,
|
self.subdevices,
|
||||||
@@ -715,13 +622,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
skip.subdevice.kind,
|
skip.subdevice.kind,
|
||||||
list(skip.hrefs),
|
list(skip.hrefs),
|
||||||
)
|
)
|
||||||
# Corroborating signal, not a gate (DESIGN-177.md section 4):
|
# Corroborating signal, not a gate: log, don't raise, on a
|
||||||
# /multidevice/vs/0's numofsubdevice is a plain count the issue
|
# disagreement -- only one known board family exposes
|
||||||
# #177 reporter's board reports independently of the liveness gate
|
# numofsubdevice at all, so a mismatch is a triage signal, not proof
|
||||||
# above. Log, don't raise, on a disagreement -- only this one board
|
# either side is wrong.
|
||||||
# family is known to expose the resource at all, so a mismatch is a
|
|
||||||
# "look into this" signal for triage, not proof either side is
|
|
||||||
# wrong.
|
|
||||||
numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice")
|
numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice")
|
||||||
if numofsubdevice is not None:
|
if numofsubdevice is not None:
|
||||||
try:
|
try:
|
||||||
@@ -739,11 +643,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
if device_type_name is not None:
|
if device_type_name is not None:
|
||||||
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
|
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
|
||||||
else:
|
else:
|
||||||
# All three: detection reads each of them (oic device type, then
|
# modelNum alone doesn't identify every type, and device_types
|
||||||
# board token, then consumer-model code), and this line is what a
|
# is often empty even on hardware we don't map yet -- log all
|
||||||
# user pastes into an issue -- modelNum alone doesn't identify a
|
# three so a user can paste this into an issue.
|
||||||
# washer or dryer, and device_types is often empty even when
|
|
||||||
# populated hardware exists for a type we don't map yet.
|
|
||||||
self._log.warning(
|
self._log.warning(
|
||||||
"unknown device type modelNum=%r description=%r device_types=%r; using common caps",
|
"unknown device type modelNum=%r description=%r device_types=%r; using common caps",
|
||||||
model_num,
|
model_num,
|
||||||
@@ -754,20 +656,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.bound = bound
|
self.bound = bound
|
||||||
self._unbound_hrefs = unbound
|
self._unbound_hrefs = unbound
|
||||||
|
|
||||||
# The identity the entry was registered under wins. This poll's own
|
# The entry's stored identity wins; this poll's answer is only
|
||||||
# answer is only adopted when the entry has nothing stored -- a legacy
|
# adopted when nothing is stored (a legacy migration couldn't
|
||||||
# entry whose migration couldn't recover a serial -- and is then
|
# recover it), then written back. Re-keying an entry with existing
|
||||||
# written back so it stops changing. Re-keying a device that already
|
# registry entries orphans them (issue #236).
|
||||||
# has registry entries is what issue #236 is about: the old keys don't
|
|
||||||
# follow, they orphan.
|
|
||||||
polled_serial = resolve_serial(
|
polled_serial = resolve_serial(
|
||||||
info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST]
|
info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST]
|
||||||
)
|
)
|
||||||
serial = self._entry.data.get(CONF_SERIAL) or polled_serial
|
serial = self._entry.data.get(CONF_SERIAL) or polled_serial
|
||||||
if serial != polled_serial:
|
if serial != polled_serial:
|
||||||
# Same IP, different appliance (or a firmware that changed what it
|
# Same IP, different appliance (or firmware that changed what it
|
||||||
# reports). Keeping the stored identity is the safe half of that;
|
# reports) -- keep the registered identity; re-adding is the
|
||||||
# re-adding the device is the user's call.
|
# user's call.
|
||||||
self._log.warning(
|
self._log.warning(
|
||||||
"device at %s reports serial %r but this entry is registered "
|
"device at %s reports serial %r but this entry is registered "
|
||||||
"as %r; keeping the registered identity",
|
"as %r; keeping the registered identity",
|
||||||
@@ -810,12 +710,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
unbound_hrefs: list[str],
|
unbound_hrefs: list[str],
|
||||||
device_name: str,
|
device_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Raise or clear a Repairs issue when capability coverage is incomplete.
|
"""Raise or clear a Repairs issue when capability coverage is
|
||||||
|
incomplete -- unrecognized device type or unbound resources.
|
||||||
Fires once, at discovery time, either because the device type itself
|
Diagnostics (diagnostics.py) is what a user downloads to help; this
|
||||||
wasn't recognized or because some of its resources didn't bind to
|
just tells them there's something to send.
|
||||||
any capability. Diagnostics (diagnostics.py) is what a user actually
|
|
||||||
downloads to help; this just tells them there's something to send.
|
|
||||||
"""
|
"""
|
||||||
issue_id = f"device_gap_{self._entry.entry_id}"
|
issue_id = f"device_gap_{self._entry.entry_id}"
|
||||||
if unknown_type or unbound_hrefs:
|
if unknown_type or unbound_hrefs:
|
||||||
@@ -839,8 +737,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
if not hrefs:
|
if not hrefs:
|
||||||
return
|
return
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
# _poll_once already connects on a real poll; this only fires
|
# _poll_once already connects on a real poll; only fires if the
|
||||||
# if the session was closed out from under us concurrently.
|
# session was closed out from under us concurrently.
|
||||||
await self.hass.async_add_executor_job(self._connect_session)
|
await self.hass.async_add_executor_job(self._connect_session)
|
||||||
sess = self._session
|
sess = self._session
|
||||||
if sess is None:
|
if sess is None:
|
||||||
@@ -863,39 +761,25 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"""True if this poll failure should NOT trigger a reconnect this
|
"""True if this poll failure should NOT trigger a reconnect this
|
||||||
cycle.
|
cycle.
|
||||||
|
|
||||||
A `TimeoutError` (see `_poll_once`) means one block's ACK didn't
|
A `TimeoutError` means one block's ACK was late, not that the
|
||||||
arrive in time — not that the session is dead. A recent OBSERVE
|
session is dead (see `_poll_once`). A recent OBSERVE notify is proof
|
||||||
notify is direct proof the channel is still live, so always defer
|
the channel is live, so always defer then. Otherwise defer until
|
||||||
in that case. Otherwise, defer until `_POLL_TIMEOUT_LIMIT`
|
`_POLL_TIMEOUT_LIMIT` consecutive timeouts pile up. Any other
|
||||||
consecutive timeouts have piled up — a single slow transfer is
|
exception reconnects immediately.
|
||||||
normal on a flaky device; a run of them is a real problem. Any
|
|
||||||
other exception (a `ConnectionError`, an explicitly closed
|
|
||||||
session) is unambiguous and always reconnects immediately.
|
|
||||||
|
|
||||||
Never defers before the first successful discovery (issue #254).
|
Never defers before first discovery (issue #254): deferring returns
|
||||||
Deferring is a *mid-session* judgement call — "keep the entities we
|
an empty dict, which the base coordinator treats as a successful
|
||||||
already have and try again next cycle" — which is only coherent once
|
first refresh -- and since platforms enumerate `bound` once, the
|
||||||
there are entities to keep. Pre-discovery the same exception type
|
entry would load with zero entities and stay that way.
|
||||||
means something else entirely: `_poll_once` calls `_connect_session`,
|
|
||||||
so `connect()`'s own handshake timeout surfaces here as a
|
|
||||||
`TimeoutError` too, and that is a dead connection, not a slow
|
|
||||||
transfer. Deferring it returned an empty dict instead of raising,
|
|
||||||
which `DataUpdateCoordinator` counts as a successful first refresh —
|
|
||||||
and since platforms enumerate `bound` exactly once, the entry loaded
|
|
||||||
with zero entities and stayed that way until a manual reload.
|
|
||||||
"""
|
"""
|
||||||
if not self._discovered:
|
if not self._discovered:
|
||||||
return False
|
return False
|
||||||
if not isinstance(e, TimeoutError):
|
if not isinstance(e, TimeoutError):
|
||||||
return False
|
return False
|
||||||
if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified():
|
if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified():
|
||||||
# Recent push is proof of life — reset the counter too, so
|
# Recent push is proof of life -- reset the counter too, so
|
||||||
# timeouts from an earlier quiet stretch don't carry over and
|
# timeouts from an earlier quiet stretch don't carry over and
|
||||||
# trigger a reconnect once the device goes quiet again. The
|
# trigger a false reconnect once the device goes quiet again.
|
||||||
# counter should mean "consecutive timeouts with no push
|
|
||||||
# activity to vouch for the session," not just "consecutive
|
|
||||||
# timeouts" — otherwise an intermittently-active device could
|
|
||||||
# still accumulate its way into a false reconnect.
|
|
||||||
self._consecutive_poll_timeouts = 0
|
self._consecutive_poll_timeouts = 0
|
||||||
return True
|
return True
|
||||||
self._consecutive_poll_timeouts += 1
|
self._consecutive_poll_timeouts += 1
|
||||||
@@ -936,11 +820,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
)
|
)
|
||||||
return flatten(self.bound, self._cache.snapshot())
|
return flatten(self.bound, self._cache.snapshot())
|
||||||
self._consecutive_poll_timeouts = 0
|
self._consecutive_poll_timeouts = 0
|
||||||
# One reconnect attempt — pause briefly so the device can
|
# A lone reconnect is routine (README's "Known device
|
||||||
# clean up its DTLS session state before we knock again.
|
# behavior"); only warn once they pile up. Pause first so
|
||||||
# A lone reconnect is routine (see the README's "Known
|
# the device can clean up its DTLS state before we knock
|
||||||
# device behavior" section); only warn once they're piling
|
# again.
|
||||||
# up within the trailing window.
|
|
||||||
if self._reconnect_is_frequent():
|
if self._reconnect_is_frequent():
|
||||||
self._log.warning("poll failed, reconnecting: %s", e)
|
self._log.warning("poll failed, reconnecting: %s", e)
|
||||||
else:
|
else:
|
||||||
@@ -952,29 +835,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
self._log.error("poll failed after reconnect: %s", e2)
|
self._log.error("poll failed after reconnect: %s", e2)
|
||||||
snapshot = self._cache.snapshot()
|
snapshot = self._cache.snapshot()
|
||||||
# `self._discovered` is the same precondition
|
# Same precondition as _defer_reconnect_for (issue #254):
|
||||||
# `_defer_reconnect_for` applies (issue #254): returning
|
# degraded-but-successful data only makes sense once
|
||||||
# degraded-but-successful data is only meaningful once
|
# there are bound entities to carry it.
|
||||||
# there are bound entities to carry it. Pre-discovery the
|
|
||||||
# cache happens to always be empty -- every apply() site
|
|
||||||
# is gated on post-discovery state -- so this arm is
|
|
||||||
# unreachable then, but that is a non-local accident
|
|
||||||
# across four call sites, not something to rely on.
|
|
||||||
if self._discovered and snapshot:
|
if self._discovered and snapshot:
|
||||||
self._log.debug("Full error:", exc_info=e2)
|
self._log.debug("Full error:", exc_info=e2)
|
||||||
return flatten(self.bound, snapshot)
|
return flatten(self.bound, snapshot)
|
||||||
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
|
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
|
||||||
else:
|
else:
|
||||||
# The reconnect gave us a brand-new session with zero
|
# A fresh session has zero OBSERVE registrations; if we
|
||||||
# OBSERVE registrations. If we were in observe mode,
|
# were in observe mode that state is now stale. Tear it
|
||||||
# that state is now stale — the refresh task is still
|
# down and resubscribe immediately below instead of
|
||||||
# pinned to the old (closed) session and nothing will
|
# waiting for the poll-mode retry timer, which exists to
|
||||||
# ever re-subscribe on the new one. Tear it down and
|
# throttle devices that never had observe working at all
|
||||||
# try to resubscribe immediately below rather than
|
# -- a reconnect just proved this session is healthy.
|
||||||
# waiting for the poll-mode retry timer — that timer
|
|
||||||
# exists to throttle devices that never had observe
|
|
||||||
# working at all, but a reconnect just proved this
|
|
||||||
# session is healthy, so there's no reason to wait.
|
|
||||||
if self._observe.mode == MODE_OBSERVE:
|
if self._observe.mode == MODE_OBSERVE:
|
||||||
self._log.debug(
|
self._log.debug(
|
||||||
"reconnect while in observe mode; downgrading to "
|
"reconnect while in observe mode; downgrading to "
|
||||||
@@ -984,13 +858,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
just_downgraded_from_observe = True
|
just_downgraded_from_observe = True
|
||||||
|
|
||||||
if not self._discovered:
|
if not self._discovered:
|
||||||
# One-time (issue #177): find out whether this connection has
|
# One-time (issue #177): find sibling subdevices before the
|
||||||
# sibling indoor subdevices before the first discovery pass, and
|
# first discovery pass, folding their seed resources into this
|
||||||
# fold their seed resources into this cycle's snapshot so
|
# cycle's snapshot so discovery sees every subdevice on the
|
||||||
# discovery sees every subdevice's state on the very first poll
|
# first poll rather than waiting a cycle.
|
||||||
# rather than waiting a cycle. Runs under its own session-lock
|
|
||||||
# scope (the poll above already released the lock) since it
|
|
||||||
# shares the same DTLS session.
|
|
||||||
async with self._session_lock:
|
async with self._session_lock:
|
||||||
resources = await self.hass.async_add_executor_job(
|
resources = await self.hass.async_add_executor_job(
|
||||||
self._enumerate_subdevices_blocking, resources
|
self._enumerate_subdevices_blocking, resources
|
||||||
@@ -999,32 +870,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
source = "sweep" if self._discovered else "poll"
|
source = "sweep" if self._discovered else "poll"
|
||||||
first_cycle = not self._discovered
|
first_cycle = not self._discovered
|
||||||
if first_cycle:
|
if first_cycle:
|
||||||
# Discovery runs *before* the apply loop below, not after it, so
|
# Discovery runs before the apply loop so a rejected candidate's
|
||||||
# a rejected candidate's resources never reach the state cache
|
# resources never reach the state cache (issue #177) --
|
||||||
# at all (issue #177). Enumeration has to fetch every candidate's
|
# StateCache has no eviction, so the only way to keep them out
|
||||||
# seed to evaluate the liveness gate, but only the subdevices that
|
# is to not put them in. Safe to reorder: _run_discovery reads
|
||||||
# pass it are ever polled again -- applying the rest would freeze
|
# the passed dict, never the cache.
|
||||||
# ~14 hrefs per rejected slot into the cache on this one cycle
|
|
||||||
# and leave them there forever, indistinguishable from live
|
|
||||||
# state in `last_resources` and in the diagnostics dump built
|
|
||||||
# from it. StateCache has no eviction, so the only way to keep
|
|
||||||
# them out is to not put them in. Safe to reorder: _run_discovery
|
|
||||||
# reads the dict passed to it and never the cache, and
|
|
||||||
# log_sweep_discrepancies below can't fire on a first cycle
|
|
||||||
# (observe mode is only ever attempted after discovery).
|
|
||||||
self._run_discovery(resources)
|
self._run_discovery(resources)
|
||||||
resources = self._live_subdevice_resources(resources)
|
resources = self._live_subdevice_resources(resources)
|
||||||
sweep_mismatch = False
|
sweep_mismatch = False
|
||||||
if self._observe.mode == MODE_OBSERVE:
|
if self._observe.mode == MODE_OBSERVE:
|
||||||
# A sweep/cache mismatch never tears down a still-live OBSERVE
|
# A mismatch never tears down a still-live OBSERVE session (see
|
||||||
# session (see log_sweep_discrepancies) — the sweep below
|
# log_sweep_discrepancies) -- the sweep below re-applies
|
||||||
# re-applies the authoritative state to the cache regardless,
|
# authoritative state regardless. It only triggers extra
|
||||||
# so there's nothing to correct by downgrading. Only a
|
# hot/warm subpolls this cycle so a channel gone silent without
|
||||||
# reconnect (above) proves subscriptions are actually gone.
|
# a reconnect still gets fresher-than-30s data.
|
||||||
# Instead, a mismatch triggers extra hot/warm subpolls this
|
|
||||||
# cycle below, so a channel gone silent without a reconnect
|
|
||||||
# (e.g. lost internet on an otherwise-live local session)
|
|
||||||
# still gets fresher-than-30s data.
|
|
||||||
sweep_mismatch = self._observe.log_sweep_discrepancies(resources)
|
sweep_mismatch = self._observe.log_sweep_discrepancies(resources)
|
||||||
for href, rep in resources.items():
|
for href, rep in resources.items():
|
||||||
self._observe.apply(href, rep, source=source)
|
self._observe.apply(href, rep, source=source)
|
||||||
@@ -1034,15 +893,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
elif self._observe.mode == MODE_POLL:
|
elif self._observe.mode == MODE_POLL:
|
||||||
await self._maybe_retry_observe_mode()
|
await self._maybe_retry_observe_mode()
|
||||||
|
|
||||||
# Schedule sub-polls for hot/warm hrefs between summary polls
|
# Background task, not async_create_task: self-limiting (cancelled
|
||||||
# (no-op in observe-primary mode unless this cycle's sweep found a
|
# and recreated every cycle, see above) and owned entirely by the
|
||||||
# mismatch; _run_subpolls checks the mode/force). A background task,
|
# coordinator, so it shouldn't be tied into HA's startup/shutdown
|
||||||
# not async_create_task: this loop is self-limiting (cancelled and
|
# sequencing -- a subpoll in flight (up to ~27s) would delay both
|
||||||
# recreated every refresh cycle, see the cancel() above) and owned
|
# (issue #207).
|
||||||
# entirely by the coordinator, so it has no business being tracked by
|
|
||||||
# HA's own startup/shutdown sequencing -- async_create_task ties it
|
|
||||||
# in regardless, so a subpoll cycle in flight (up to ~27s,
|
|
||||||
# _SUBPOLL_STEP_S x 9 slots) delays both (issue #207).
|
|
||||||
if self._hot_hrefs or self._warm_hrefs:
|
if self._hot_hrefs or self._warm_hrefs:
|
||||||
self._subpoll_task = self.hass.async_create_background_task(
|
self._subpoll_task = self.hass.async_create_background_task(
|
||||||
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
|
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
|
||||||
@@ -1055,21 +910,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None:
|
async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None:
|
||||||
"""Write a value to the device. Fire-and-forget style.
|
"""Write a value to the device. Fire-and-forget.
|
||||||
|
|
||||||
A description-level validate_fn (currently SwitchDesc only) runs
|
A description-level validate_fn (SwitchDesc only, currently) rejects
|
||||||
here rather than per-platform, so rejecting a write with a
|
a write with a user-facing message ahead of write_fn's silent
|
||||||
user-facing message -- as opposed to write_fn's silent no-op below
|
no-op. The remote-control check runs first, unconditionally, unless
|
||||||
-- is available to every platform for free. The remote-control
|
the user opted out via CONF_BYPASS_REMOTE_CONTROL (issue #54: some
|
||||||
check runs first and applies to every platform unconditionally,
|
devices accept some writes even while reporting remote control off)
|
||||||
ahead of any description-specific validate_fn -- unless the user has
|
or the laundry firmware declares itself writable without Smart
|
||||||
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
|
Control."""
|
||||||
#54: some devices accept certain writes, e.g. a washer's default
|
|
||||||
dosing levels, even while reporting remote control off, so the
|
|
||||||
block's assumption doesn't hold for every model), or the laundry
|
|
||||||
firmware flag isModelSettingWithoutSC declares settings writable
|
|
||||||
without Smart Control (cycle start/pause/stop on /operational/state
|
|
||||||
still require it)."""
|
|
||||||
desc = bound_entity.desc
|
desc = bound_entity.desc
|
||||||
write_fn = getattr(desc, "write_fn", None)
|
write_fn = getattr(desc, "write_fn", None)
|
||||||
if write_fn is None:
|
if write_fn is None:
|
||||||
@@ -1104,79 +953,42 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
return
|
return
|
||||||
path_segs, body = result
|
path_segs, body = result
|
||||||
|
|
||||||
# The write's actual target, not necessarily bound_entity.href. Most
|
# The write's actual target, not necessarily bound_entity.href -- a
|
||||||
# descriptors write to the same resource they're bound to, but a
|
# composite entity (the AC's ClimateDesc) drives writes to sibling
|
||||||
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
|
# resources via path_segs (see airconditioner._climate_write).
|
||||||
# drives writes to several sibling resources via path_segs
|
# Applying the optimistic value to bound_entity.href instead caused
|
||||||
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
|
# the 20-60s lag in issues #17/#53: the wrong resource got the
|
||||||
# write_fn picks per payload (see airconditioner._climate_write).
|
# optimistic merge while the one HA actually displays from never
|
||||||
# Applying the optimistic value and settle guard below to
|
# did.
|
||||||
# bound_entity.href instead of this target protected the wrong
|
|
||||||
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
|
|
||||||
# merge while the resource the climate entity actually displays from
|
|
||||||
# (e.g. /power/0) never got one, so HA kept showing the pre-write
|
|
||||||
# state until the next real read of that resource -- the 20-60s lag
|
|
||||||
# in issues #17/#53, which survived the earlier optimistic-apply fix
|
|
||||||
# (issue #27) because that fix applied to the wrong href too.
|
|
||||||
#
|
#
|
||||||
# write_fn's path_segs are canonical (issue #177) -- a subdevice's
|
# path_segs are canonical (issue #177); translate through this
|
||||||
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or
|
# entity's own subdevice so a subdevice's actual href (e.g.
|
||||||
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical
|
# /mode/vs/1) is targeted instead -- identity transform for MAIN.
|
||||||
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
|
|
||||||
# bound entity's own subdevice so the optimistic apply, the settle
|
|
||||||
# guard and the POST below all target that subdevice's real resource --
|
|
||||||
# to_actual is the identity transform for MAIN, so a device with no
|
|
||||||
# subdevices writes exactly where it always did.
|
|
||||||
write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs))
|
write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs))
|
||||||
path_segs = [s for s in write_href.strip("/").split("/") if s]
|
path_segs = [s for s in write_href.strip("/").split("/") if s]
|
||||||
|
|
||||||
# Apply the write optimistically before starting the settle guard,
|
# Apply optimistically before starting the settle guard -- guard and
|
||||||
# not after -- mark_write_pending gates every source (poll, sweep,
|
# apply share the same gate (mark_write_pending), so reversing the
|
||||||
# observe) through the same apply(), itself included, so flipping
|
# order would drop the very update it exists to protect (issue #27).
|
||||||
# this order would have the guard drop the one update it exists to
|
|
||||||
# protect. Without an optimistic value in the cache for it to hold
|
|
||||||
# onto, the settle window was just delaying the real device
|
|
||||||
# confirmation for a few seconds on every write, which read exactly
|
|
||||||
# like the write being silently reverted (issue #27).
|
|
||||||
#
|
#
|
||||||
# settle_s must outlast the PUT and the async_request_refresh()
|
# settle_s must outlast the PUT plus the confirming refresh, not
|
||||||
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
|
# DEFAULT_SETTLE_S's fixed few seconds: the refresh is a full
|
||||||
# that refresh is a full /device/0 summary poll, which
|
# summary poll that can legitimately take tens of seconds (see
|
||||||
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
|
# _poll_once), and some writes (issue #9's washer course/detergent/
|
||||||
# seconds on these devices (see _poll_once), and some writes settle
|
# softener selection) settle on-device well after that. A short
|
||||||
# on the device itself well after that: issue #9's washer packs
|
# fixed window let a stale confirm poll land unprotected and revert
|
||||||
# cycle/detergent/softener selection into the same /course/vs/0
|
# the optimistic value, read by users as the write "reverting, then
|
||||||
# options[] array, and picking a new value there visibly needs a
|
# re-applying" itself a few seconds later. Releasing the guard early
|
||||||
# few seconds of internal validation/dispenser movement before the
|
# (right after the first confirming refresh) was tried and reverted
|
||||||
# device's own state agrees -- while /washer/vs/0's temperature/
|
# for the same reason, plus races on overlapping writes to the same
|
||||||
# spin fields (plain flags, no device-side settling) confirm
|
# href.
|
||||||
# instantly on the same device. A short fixed window expired while
|
|
||||||
# the confirm poll was still in flight (or before the device had
|
|
||||||
# caught up internally), so that stale read landed unprotected and
|
|
||||||
# reverted the optimistic value, self-correcting again only once a
|
|
||||||
# later poll finally saw the real change -- read by the user as the
|
|
||||||
# write "reverting, then re-applying itself" a few seconds later.
|
|
||||||
#
|
#
|
||||||
# An earlier attempt at this also released the guard early, right
|
# write_fn bodies touching options/items now carry only the changed
|
||||||
# after the confirming refresh completed, to avoid shutting out
|
# token(s) (issue #54), not the whole array -- but apply()'s
|
||||||
# unrelated real updates (another automation, the physical remote)
|
# field-level merge doesn't know that and would wipe every sibling
|
||||||
# for the rest of settle_s. That was reverted: releasing the guard
|
# option/item for the settle window. Pre-merge here the way the
|
||||||
# the moment one round trip finishes doesn't mean the device has
|
# device does, so the optimistic cache entry stays complete; the
|
||||||
# actually caught up (exactly the slow-settling case above), and it
|
# wire `body` stays minimal.
|
||||||
# introduced its own races around overlapping writes to the same
|
|
||||||
# href. Simpler and safer to just hold the guard for the full,
|
|
||||||
# generously-sized window and let it expire on its own.
|
|
||||||
# write_fn bodies that touch x.com.samsung.da.options or
|
|
||||||
# x.com.samsung.da.items carry only the changed token(s)/item now
|
|
||||||
# (issue #54 for options; the AC vendor temperature write for items --
|
|
||||||
# confirmed sufficient on the wire, the device merges the rest itself),
|
|
||||||
# not the whole packed array. observe.apply()'s field-level
|
|
||||||
# {**cached, **rep} merge doesn't know that -- handed the bare
|
|
||||||
# partial value, it would replace the cached field outright and wipe
|
|
||||||
# every sibling option/item for the rest of the settle window.
|
|
||||||
# Pre-merge it here the same way the device does, so the optimistic
|
|
||||||
# cache entry stays complete; the minimal `body` below is still
|
|
||||||
# exactly what goes out over the wire.
|
|
||||||
optimistic_body = body
|
optimistic_body = body
|
||||||
new_options = body.get("x.com.samsung.da.options")
|
new_options = body.get("x.com.samsung.da.options")
|
||||||
if isinstance(new_options, list):
|
if isinstance(new_options, list):
|
||||||
@@ -1185,9 +997,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
**optimistic_body,
|
**optimistic_body,
|
||||||
"x.com.samsung.da.options": merge_options_field(cached_options, new_options),
|
"x.com.samsung.da.options": merge_options_field(cached_options, new_options),
|
||||||
}
|
}
|
||||||
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor
|
# Same fact, items[] shape (see airconditioner._climate_write's
|
||||||
# temperature write, which now carries only {id, desired} -- see that
|
# vendor temperature write).
|
||||||
# module for the write-side half of this).
|
|
||||||
new_items = body.get("x.com.samsung.da.items")
|
new_items = body.get("x.com.samsung.da.items")
|
||||||
if isinstance(new_items, list):
|
if isinstance(new_items, list):
|
||||||
cached_items = (self._cache.get(write_href) or {}).get("x.com.samsung.da.items")
|
cached_items = (self._cache.get(write_href) or {}).get("x.com.samsung.da.items")
|
||||||
@@ -1216,11 +1027,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Debug raw write (issue #54): a power-user escape hatch for the
|
# Debug raw write (issue #54): a power-user escape hatch for the
|
||||||
# options-flow debug panel, letting a user POST an arbitrary partial
|
# options-flow debug panel to POST an arbitrary partial body without a
|
||||||
# body to an arbitrary href to pin down device-specific write behavior
|
# new release. Deliberately bypasses the remote-control block and all
|
||||||
# without waiting on a new release. Deliberately bypasses the
|
# write_fn/validate_fn above -- use with care.
|
||||||
# remote-control block and every write_fn/validate_fn above -- that's
|
|
||||||
# the whole point, so use with care.
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
||||||
@@ -1247,13 +1056,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
return code, new_rep
|
return code, new_rep
|
||||||
|
|
||||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||||
"""Debug-only arbitrary write (issue #54). Bypasses the
|
"""Debug-only arbitrary write (issue #54). Bypasses remote-control
|
||||||
remote-control block and all write_fn/validate_fn logic; sends
|
and write_fn/validate_fn; sends `body` verbatim as a partial-rep
|
||||||
`body` verbatim as a partial-rep PATCH to `href`. Returns
|
PATCH to `href`. Returns (coap_code, new_rep) read back right
|
||||||
(coap_code, new_rep) where new_rep is the href's value read back
|
after."""
|
||||||
right after the write. Used by the options-flow debug panel to
|
|
||||||
help users pin down device-specific write behavior without a new
|
|
||||||
release."""
|
|
||||||
if not isinstance(body, dict) or not body:
|
if not isinstance(body, dict) or not body:
|
||||||
raise ServiceValidationError(
|
raise ServiceValidationError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
@@ -1270,7 +1076,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
code, new_rep = await self.hass.async_add_executor_job(
|
code, new_rep = await self.hass.async_add_executor_job(
|
||||||
self._raw_write_blocking, path_segs, body, norm_href
|
self._raw_write_blocking, path_segs, body, norm_href
|
||||||
)
|
)
|
||||||
# Hasten a full summary poll so entities on other resources catch
|
# Hasten a summary poll so entities on other resources catch up
|
||||||
# up too -- a debug write can affect siblings, not just its href.
|
# too -- a debug write can affect siblings, not just its href.
|
||||||
await self.async_request_refresh()
|
await self.async_request_refresh()
|
||||||
return code, new_rep
|
return code, new_rep
|
||||||
|
|||||||
@@ -35,31 +35,24 @@ async def async_get_config_entry_diagnostics(
|
|||||||
|
|
||||||
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
|
# /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`
|
# 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
|
# is OCF's device-type declaration; /oic/res is OCF's discovery
|
||||||
# discovery endpoint, listing every href/Collection the connection
|
# endpoint, relevant to the "Composite Device" model (issue #177). See
|
||||||
# hosts -- relevant to the "Composite Device" model (issue #177) where
|
|
||||||
# a single physical device exposes more than one logical subdevice. See
|
|
||||||
# registry/identity.py.
|
# registry/identity.py.
|
||||||
identity = coordinator._identity
|
identity = coordinator._identity
|
||||||
|
|
||||||
def _seed_diag(su) -> dict:
|
def _seed_diag(su) -> dict:
|
||||||
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
|
# A flat-mode subdevice (issue #205: no working /<uuid>/device/0
|
||||||
# Collection, so its state comes from individually-polled hrefs
|
# Collection, state comes from individually-polled hrefs instead)
|
||||||
# instead) has no meaningful seed_path; report the flat_hrefs list
|
# has no meaningful seed_path; report flat_hrefs in its place.
|
||||||
# in its place rather than the misleading bare "/" a joined empty
|
|
||||||
# tuple would otherwise produce.
|
|
||||||
return {
|
return {
|
||||||
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
|
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
|
||||||
"flat_hrefs": list(su.flat_hrefs),
|
"flat_hrefs": list(su.flat_hrefs),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _subdevice_diag(su) -> dict:
|
def _subdevice_diag(su) -> dict:
|
||||||
# One pass over coordinator.bound for both fields below (count and
|
# `model` reads modelNum off the already-redacted `resources` rather
|
||||||
# the distinct hrefs), and one redaction of this subdevice's canonical
|
# than redacting /information/vs/0 again -- modelNum never matches
|
||||||
# view -- `model` reads modelNum off the already-redacted `resources`
|
# redact.py's substring rules, so the value is the same either way.
|
||||||
# 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.
|
|
||||||
matching = [b for b in coordinator.bound if b.subdevice == su]
|
matching = [b for b in coordinator.bound if b.subdevice == su]
|
||||||
res = redact_resources(coordinator.canonical_resources(su))
|
res = redact_resources(coordinator.canonical_resources(su))
|
||||||
return {
|
return {
|
||||||
@@ -69,13 +62,10 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"bound_entity_count": len(matching),
|
"bound_entity_count": len(matching),
|
||||||
"hrefs": sorted({b.href for b in matching}),
|
"hrefs": sorted({b.href for b in matching}),
|
||||||
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
|
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
|
||||||
# Keyed by this subdevice's *canonical* hrefs, not the real ones
|
# Keyed by this subdevice's canonical hrefs ('/mode/vs/0'), not
|
||||||
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
|
# the real ones it answers on ('/mode/vs/1', '/<uuid>/mode/vs/0')
|
||||||
# '/<uuid>/mode/vs/0'. That's the form the registry and every
|
# -- the form the registry is written against, so a sibling's
|
||||||
# capability are written against, so a sibling's block can be
|
# block reads exactly like the master's `resources` below.
|
||||||
# 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.
|
|
||||||
"resources": res,
|
"resources": res,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,49 +81,34 @@ async def async_get_config_entry_diagnostics(
|
|||||||
if identity is not None
|
if identity is not None
|
||||||
else None,
|
else None,
|
||||||
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
||||||
# This subdevice's own resources, and only this subdevice's -- what
|
# This subdevice's own resources, and only this subdevice's. On a
|
||||||
# the module docstring and the adding-device-support skill have
|
# composite device (issue #177) `last_resources` is the union
|
||||||
# always described it as ("the parsed /device/0 snapshot"). On a
|
# across every live subdevice keyed by real hrefs, so reporting it
|
||||||
# composite device (issue #177) `last_resources` is the union across
|
# raw here would mix a sibling's /mode/vs/1 with the master's
|
||||||
# every live subdevice keyed by real hrefs, so reporting it raw here
|
# /mode/vs/0 under no attribution. Each sibling reports its own
|
||||||
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
|
# resources in `subdevices` below instead. For a device with no
|
||||||
# under no attribution at all. Each sibling reports its own
|
# subdevices, this is byte-identical to `last_resources`.
|
||||||
# resources in its own `subdevices` entry below instead. For a
|
|
||||||
# device with no subdevices -- almost every device -- this is
|
|
||||||
# byte-identical to `last_resources`.
|
|
||||||
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
|
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
|
||||||
# Sibling indoor subdevices discovered on this connection (issue
|
# Sibling indoor subdevices discovered on this connection (issue
|
||||||
# #177) -- per-subdevice kind/key/seed path plus what actually bound
|
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
|
||||||
# to it, so a report shows whether a composite device's subdevice
|
# from) is deliberately NOT redacted here, unlike elsewhere in
|
||||||
# was found at all and what it resolved to. subdeviceIdList (the
|
# `resources` -- it's an appliance-internal pairing id, not account
|
||||||
# UUID a prefixed subdevice's key comes from) is deliberately NOT
|
# data, and reporting it is what makes this block actionable.
|
||||||
# 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.
|
|
||||||
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
|
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
|
||||||
# Candidates that answered their seed but that discover_partitioned's
|
# Candidates that answered their seed but that discover_partitioned's
|
||||||
# entity-level liveness gate rejected -- an unused SmartThings slot
|
# liveness gate rejected -- an unused SmartThings slot, not a real
|
||||||
# (the issue #177 reporter's /device/2) that still answers a
|
# second subdevice. Reported alongside subdevices above so a report
|
||||||
# same-shaped batch, not a real second subdevice. Reported alongside
|
# shows what was found and why it didn't become an entity.
|
||||||
# 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.
|
|
||||||
"subdevices_skipped": [
|
"subdevices_skipped": [
|
||||||
{
|
{
|
||||||
"kind": skip.subdevice.kind,
|
"kind": skip.subdevice.kind,
|
||||||
"key": skip.subdevice.key,
|
"key": skip.subdevice.key,
|
||||||
**_seed_diag(skip.subdevice),
|
**_seed_diag(skip.subdevice),
|
||||||
"hrefs": list(skip.hrefs),
|
"hrefs": list(skip.hrefs),
|
||||||
# The reps the liveness gate actually judged, canonicalized
|
# The reps the liveness gate actually judged -- the one
|
||||||
# like the materialized subdevices above. These are the one
|
# thing a reader needs to second-guess a skip, and they
|
||||||
# thing a reader needs to second-guess a skip ("is my second
|
# exist nowhere else in this dump: a rejected candidate is
|
||||||
# subdevice really absent, or did the gate get it wrong?"),
|
# never polled again or entered into the state cache.
|
||||||
# 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(
|
"resources": redact_resources(
|
||||||
{
|
{
|
||||||
canon: rep
|
canon: rep
|
||||||
@@ -146,17 +121,12 @@ async def async_get_config_entry_diagnostics(
|
|||||||
],
|
],
|
||||||
# What each enumeration probe returned ({} vs a batch), keyed by the
|
# What each enumeration probe returned ({} vs a batch), keyed by the
|
||||||
# seed href attempted -- lets a report distinguish "checked, nothing
|
# seed href attempted -- lets a report distinguish "checked, nothing
|
||||||
# there" from "never checked", the same posture the speculative
|
# there" from "never checked".
|
||||||
# /device/1 //device/2 probe this replaced used to document directly
|
|
||||||
# in identity.py before it moved to registry/subdevices.py.
|
|
||||||
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
|
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
|
||||||
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
|
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
|
||||||
# Reported on its own rather than inside `resources` because it is
|
# Reported on its own, not inside `resources`, since it's metadata
|
||||||
# metadata about the connection rather than state of any one
|
# about the connection rather than one subdevice's state, and
|
||||||
# subdevice -- and because nothing polls it after discovery, so it
|
# nothing polls it after discovery so it would go stale in there.
|
||||||
# would go stale in there. Its numofsubdevice count is what
|
|
||||||
# independently corroborates the subdevices/subdevices_skipped split
|
|
||||||
# above.
|
|
||||||
"multidevice": redact_resources(coordinator._multidevice),
|
"multidevice": redact_resources(coordinator._multidevice),
|
||||||
"integration_version": integration.version,
|
"integration_version": integration.version,
|
||||||
"smartthings_local_version": stl_version,
|
"smartthings_local_version": stl_version,
|
||||||
|
|||||||
@@ -18,28 +18,23 @@ from .registry.discovery import BoundEntity, _snake_to_title
|
|||||||
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.
|
"""Return False if the entity should not be registered for this device.
|
||||||
|
|
||||||
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
|
Explicit exists_fn takes priority. Otherwise, if the entity has a
|
||||||
require that field to be present in the resource rep so that optional
|
field, require that field to be present in the resource rep so that
|
||||||
fields on shared resources don't create phantom entities.
|
optional fields on shared resources don't create phantom entities.
|
||||||
|
|
||||||
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
|
A stub rep (is_stub_rep) is included anyway so it can be populated by
|
||||||
yet" marker) is included anyway so it can be populated by sub-polls. A
|
sub-polls. A genuinely empty {} rep is included too by this default
|
||||||
genuinely empty {} rep is included too by this default gate -- whether
|
gate: whether empty means "not populated yet" or "permanently
|
||||||
empty means "not populated yet" or "permanently unsupported" needs
|
unsupported" needs per-field domain knowledge this generic gate
|
||||||
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
|
doesn't have (e.g. /alarms/vs/0's {} is fridge.py's documented normal
|
||||||
{} is fridge.py's documented *normal* no-alarm state (see
|
no-alarm state, not an absence signal). Only a capability whose author
|
||||||
_active_alarm_codes), not an absence signal, and it's far from the only
|
has verified a field is genuinely never populated opts into stricter
|
||||||
resource like that. Only a capability whose author has actually verified
|
gating with its own exists_fn (see common.ENERGY_METER, issue #127).
|
||||||
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.
|
|
||||||
|
|
||||||
`bound.href` is already the *actual* href (issue #177 -- see
|
`bound.href` is already the actual href (issue #177); `exists_fn` gets
|
||||||
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
|
`bound`'s own subdevice's canonical view instead of the raw snapshot,
|
||||||
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
|
same rule as everywhere else a whole-resources-dict scan happens --
|
||||||
raw snapshot, same rule as everywhere else a whole-resources-dict scan
|
this is a free function, so it can't use self._resources.
|
||||||
happens (coordinator.canonical_resources) -- this is a free function, not
|
|
||||||
an LocalThingsEntity method, so it can't use self._resources.
|
|
||||||
"""
|
"""
|
||||||
rep = coordinator.last_resources.get(bound.href)
|
rep = coordinator.last_resources.get(bound.href)
|
||||||
if rep is None:
|
if rep is None:
|
||||||
@@ -97,12 +92,10 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
|||||||
"instance_name": _instance_display_name(bound, self._state_key)
|
"instance_name": _instance_display_name(bound, self._state_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
# _attr_name is deliberately left unset: Home Assistant gives an
|
# _attr_name is deliberately left unset: HA gives an explicitly-set
|
||||||
# explicitly-set name precedence over the translation catalog, so
|
# name precedence over the translation catalog, so setting it here
|
||||||
# setting it here would make every entity untranslatable. Every
|
# would make every entity untranslatable. A platform that wants the
|
||||||
# descriptor resolves to a catalog entry (see translation_key below);
|
# bare device name sets _attr_name = None itself (see fan.py).
|
||||||
# a platform that wants the bare device name instead sets
|
|
||||||
# _attr_name = None itself, as fan.py does for the hood's main entity.
|
|
||||||
self._attr_icon = bound.desc.icon
|
self._attr_icon = bound.desc.icon
|
||||||
raw_cat = bound.desc.entity_category
|
raw_cat = bound.desc.entity_category
|
||||||
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
||||||
@@ -112,18 +105,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
|||||||
def translation_key(self) -> str | None:
|
def translation_key(self) -> str | None:
|
||||||
"""The descriptor's catalog key, defaulting to its own `key`.
|
"""The descriptor's catalog key, defaulting to its own `key`.
|
||||||
|
|
||||||
Overrides Entity.translation_key (a property upstream, not a plain
|
Overrides Entity.translation_key so a callable descriptor (e.g.
|
||||||
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
|
laundry.cycle_select's table-id-gated resolver) is re-evaluated
|
||||||
table-id-gated resolver -- is re-evaluated against live coordinator
|
against live coordinator data on every access, not resolved once
|
||||||
data on every access, not resolved once at construction time.
|
at construction time -- a static resolution would risk baking in
|
||||||
|
a permanent None if the first poll handed a sibling an empty stub
|
||||||
Discovery runs on the first /device/0 poll, which the entity
|
rep (see _is_included's docstring) before it populated.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
tk = self._bound.desc.translation_key
|
tk = self._bound.desc.translation_key
|
||||||
if callable(tk):
|
if callable(tk):
|
||||||
@@ -133,12 +120,11 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
|||||||
@property
|
@property
|
||||||
def _resources(self) -> dict:
|
def _resources(self) -> dict:
|
||||||
"""This entity's own subdevice's canonical resources view (issue
|
"""This entity's own subdevice's canonical resources view (issue
|
||||||
#177) -- see coordinator.canonical_resources. Every platform
|
#177) -- see coordinator.canonical_resources. Any platform property
|
||||||
property that needs the *whole* resources dict, as opposed to one
|
needing the whole resources dict, not one href via
|
||||||
href via `coordinator.resource(href)`, must read through this
|
`coordinator.resource(href)`, must read through this instead of
|
||||||
instead of `coordinator.last_resources`, or a sibling subdevice's own
|
`coordinator.last_resources`, or a sibling subdevice's own hrefs
|
||||||
actual hrefs would leak into (or be missing from) this entity's
|
could leak into this entity's view. For MAIN this is exactly
|
||||||
view. For MAIN (every device with no subdevices) this is exactly
|
|
||||||
`coordinator.last_resources`."""
|
`coordinator.last_resources`."""
|
||||||
return self.coordinator.canonical_resources(self._bound.subdevice)
|
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
|
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/
|
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
|
Medium/High (issue #56) are both an ordered set of numeric levels
|
||||||
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
|
(SET_SPEED), confirmed monotonic in capabilities/air_purifier.py's module
|
||||||
air_purifier.py's module docstring, with no named-mode list to preserve
|
docstring, with no named-mode list since this board never self-reports one.
|
||||||
since this board never self-reports one. The TP1X air-purifier family's
|
The TP1X air-purifier family's modes (Smart/Max/Mid/WindFree/Sleep, issue
|
||||||
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
|
#130) and the A-VTWW-TP2-21 family's /wind/strength/vs/0 modes (issue #151)
|
||||||
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
|
are both named behaviors with no linear order (PRESET_MODE) --
|
||||||
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
|
LocalThingsAirPurifierFan handles both hrefs, the only difference being
|
||||||
both hrefs, the only difference being whether the label comes straight
|
whether the label comes from supportedModes or a parallel modesName array
|
||||||
from supportedModes or from a parallel modesName array (see
|
(see _label_for_code)."""
|
||||||
_label_for_code)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -76,19 +75,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
|
|
||||||
Some boards that reuse this capability (built-in microwave vent fans,
|
Some boards that reuse this capability (built-in microwave vent fans,
|
||||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
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
|
resource at all -- fan speed 0 is itself the off state there.
|
||||||
separate power toggle to write. `_speed_zero_is_off` detects that
|
`_speed_zero_is_off` detects that shape and switches every method
|
||||||
shape from the hood resource's own settableMinFanSpeed/
|
below to drive off/on purely through the fanSpeed field.
|
||||||
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.
|
|
||||||
|
|
||||||
This is deliberately not the same question as `_has_separate_power`,
|
Deliberately not the same question as `_has_separate_power`, which
|
||||||
which only proves *some* power resource exists on the device -- on a
|
only proves some power resource exists on the device -- on a combi
|
||||||
combi appliance (e.g. an over-the-range microwave) that resource can
|
appliance that resource can belong to the cavity, not the vent fan,
|
||||||
belong to the cavity, not the vent fan, and toggling it from here
|
and toggling it from here would turn off the whole appliance.
|
||||||
would turn off the whole appliance instead of just the fan.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
@@ -112,10 +106,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
|
|
||||||
def _speed_zero_is_off(self) -> bool:
|
def _speed_zero_is_off(self) -> bool:
|
||||||
"""Whether fan speed '0' is itself this hood's off step, with no
|
"""Whether fan speed '0' is itself this hood's off step, with no
|
||||||
separate power resource to toggle. The board says so directly:
|
separate power resource to toggle -- settableMinFanSpeed '0', or
|
||||||
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
|
'0' inside supportedFanSpeed. False for the standalone hood, whose
|
||||||
standalone hood's codes start at 14 and it carries a real /power
|
codes start at 14 and which carries a real /power resource."""
|
||||||
resource instead, so this is False there."""
|
|
||||||
rep = self._rep(self._bound.href)
|
rep = self._rep(self._bound.href)
|
||||||
return (
|
return (
|
||||||
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
|
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
|
||||||
@@ -140,12 +133,10 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
|||||||
def _active_speed_codes(self) -> list[str]:
|
def _active_speed_codes(self) -> list[str]:
|
||||||
codes = self._all_speed_codes()
|
codes = self._all_speed_codes()
|
||||||
if self._speed_zero_is_off():
|
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]
|
return [code for code in codes if code != _OFF_SPEED_CODE]
|
||||||
# Power is carried by the separate /power resource. fanSpeed
|
# Power is carried by the separate /power resource; fanSpeed
|
||||||
# retains the selected setting while power is off (as the
|
# retains the selected setting while power is off, so every
|
||||||
# lamp's `current` field does), so every advertised code is an
|
# advertised code is an active ordered speed.
|
||||||
# active ordered speed.
|
|
||||||
return codes
|
return codes
|
||||||
|
|
||||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||||
@@ -268,15 +259,12 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
|||||||
def _label_for_code(self, code) -> str:
|
def _label_for_code(self, code) -> str:
|
||||||
"""Lowercased HA preset label for a device mode code.
|
"""Lowercased HA preset label for a device mode code.
|
||||||
|
|
||||||
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
|
The TP1X_DA-AC-AIR board (issue #130) reports named modes directly
|
||||||
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
|
as supportedModes, so the code IS the label. The A-VTWW-TP2-21
|
||||||
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
|
board (issue #151) instead reports numeric wind-strength codes with
|
||||||
wind-strength codes ('87'/'89'/...) with a separate modesName array
|
a separate modesName array giving the real names -- same shape as
|
||||||
(parallel-indexed with supportedModes) giving the actual names --
|
climate.py's _wind_strength_label, and coincidentally the same word
|
||||||
same shape as climate.py's _wind_strength_label, and coincidentally
|
set, so both generations land on identical HA preset values."""
|
||||||
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."""
|
|
||||||
rep = self._mode_rep()
|
rep = self._mode_rep()
|
||||||
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
|
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
|
||||||
names = rep.get(_MODES_NAME_FIELD)
|
names = rep.get(_MODES_NAME_FIELD)
|
||||||
@@ -327,11 +315,8 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
|||||||
|
|
||||||
_AIRFLOW_SPEED_FIELD = "speed"
|
_AIRFLOW_SPEED_FIELD = "speed"
|
||||||
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
|
# 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
|
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Treated
|
||||||
# as plain strings, same as _all_speed_codes above, so
|
# as plain ordered strings, same as the range hood's numeric levels.
|
||||||
# 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"]
|
_AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
|
||||||
|
|
||||||
|
|
||||||
@@ -354,14 +339,11 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
|||||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||||
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
||||||
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
||||||
harmless for the TP1X board because it never reports /power/0 at
|
harmless for the TP1X board because it never reports /power/0.
|
||||||
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
|
This family's dumps carry both hrefs, and common.POWER_GENERIC is
|
||||||
(the power_switch entity) is unconditionally bound to /power/0
|
unconditionally bound to /power/0 when present, so writing to
|
||||||
whenever it's present, so writing here to /power/vs/0 first would
|
/power/vs/0 first would leave power_switch and this fan
|
||||||
leave power_switch and this fan reading/writing two different
|
disagreeing until the next poll."""
|
||||||
resources -- disagreeing until the next poll refreshes the other
|
|
||||||
one (the same optimistic-apply lag coordinator.py's own comments
|
|
||||||
warn about)."""
|
|
||||||
resources = self._resources
|
resources = self._resources
|
||||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||||
return "power", enabled, target
|
return "power", enabled, target
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
|||||||
|
|
||||||
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
||||||
# `description` right before any '/board-info' suffix) -> registry key.
|
# `description` right before any '/board-info' suffix) -> registry key.
|
||||||
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
|
# NOT derived from `modelNum`: washer and dryer share the same 'DA_WM_'
|
||||||
# internal board-family prefix there, and dishwasher's modelNum contains
|
# board-family prefix there, and dishwasher's modelNum contains the
|
||||||
# the substring 'WW', so a modelNum-only rule misroutes both.
|
# substring 'WW', so a modelNum-only rule misroutes both.
|
||||||
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||||
"WW": "washer",
|
"WW": "washer",
|
||||||
"WD": "washer",
|
"WD": "washer",
|
||||||
@@ -79,48 +79,39 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
|||||||
# Board-family token -> registry key, matched against whole tokens of
|
# Board-family token -> registry key, matched against whole tokens of
|
||||||
# `modelNum`/`description` (see `_board_tokens`).
|
# `modelNum`/`description` (see `_board_tokens`).
|
||||||
#
|
#
|
||||||
# Tokenizing instead of substring-matching is what keeps this a table rather
|
# Tokenizing instead of substring-matching keeps this a table rather than a
|
||||||
# than a ladder of hand-written rules. Samsung spells the same board family
|
# ladder of hand-written rules: Samsung spells the same board family with
|
||||||
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
|
# either delimiter ('TP1X_DA-AC-RAC-01001' vs 'TP2X_RAC_20K', both RAC), so
|
||||||
# same RAC family -- so a substring rule has to be written once per spelling
|
# a substring rule would need writing once per spelling, and a token with
|
||||||
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
|
# no trailing delimiter ('ARTIK051_DONGLE_REF') would match neither.
|
||||||
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
|
|
||||||
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
|
|
||||||
# every one of those as a single entry.
|
|
||||||
#
|
#
|
||||||
# Entries must name the *specific* device type, never the board family that
|
# 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
|
# 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
|
# would swallow the dehumidifier and the air purifier. Where two families
|
||||||
# genuinely share a resource surface they share a registry (all the
|
# genuinely share a resource surface they share a registry (the
|
||||||
# air-conditioner spellings below), which is a statement about the hardware,
|
# air-conditioner spellings below), which is a statement about the
|
||||||
# not a shortcut.
|
# hardware, not a shortcut.
|
||||||
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
||||||
"REF": "refrigerator",
|
"REF": "refrigerator",
|
||||||
# Air conditioners. Every one of these is a distinct board family with
|
# Air conditioners: distinct board families sharing one resource
|
||||||
# the same resource surface: room (issues #37, #91), package, Korean
|
# surface -- room, package, Korean (#136), window (#87), 2-in-1
|
||||||
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
|
# floor+wall (#150/#153), system/commercial (#52), cassette (#191), and
|
||||||
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
|
# ARA-WW wall-mount (#115-120).
|
||||||
"RAC": "airconditioner",
|
"RAC": "airconditioner",
|
||||||
"PRAC": "airconditioner",
|
"PRAC": "airconditioner",
|
||||||
"KRAC": "airconditioner",
|
"KRAC": "airconditioner",
|
||||||
"WAC": "airconditioner",
|
"WAC": "airconditioner",
|
||||||
"FAC": "airconditioner",
|
"FAC": "airconditioner",
|
||||||
"CAWW": "airconditioner",
|
"CAWW": "airconditioner",
|
||||||
"CAC": "airconditioner", # issue #191 -- TP1X_DA-AC-CAC-01001_0000
|
"CAC": "airconditioner", # issue #191
|
||||||
"ARA": "airconditioner",
|
"ARA": "airconditioner",
|
||||||
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
|
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
|
||||||
"EHS": "ehs", # Eco Heating System air-to-water heat pump --
|
"EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water
|
||||||
# zone1 space heating/cooling + dhw domestic
|
|
||||||
# hot water, its own /mode/*/vs/0 and
|
|
||||||
# /temperatures/*/vs/0 resource shapes
|
|
||||||
"TVTL": "air_purifier", # issue #56 (ARTIK051)
|
"TVTL": "air_purifier", # issue #56 (ARTIK051)
|
||||||
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
|
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
|
||||||
"AVT": "air_purifier", # issue #190 -- AVT-WW-TP1-23-AXX500, a
|
# issue #190: same lineage as VTWW, but the '-WW-' delimiter falls one
|
||||||
# next-gen BESPOKE Cube Air board; same
|
# letter left ('A-VTWW-' -> 'AVT-WW-'), splitting into a different token.
|
||||||
# lineage as VTWW above but the '-WW-'
|
"AVT": "air_purifier",
|
||||||
# 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)
|
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
|
||||||
"WATERPURIFIER": "water_purifier", # issue #90
|
"WATERPURIFIER": "water_purifier", # issue #90
|
||||||
"ADW": "dishwasher",
|
"ADW": "dishwasher",
|
||||||
@@ -129,13 +120,12 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
|
|||||||
"OVEN": "oven", # issue #55 -- wall oven, no burners
|
"OVEN": "oven", # issue #55 -- wall oven, no burners
|
||||||
"MICROWAVE": "microwave", # issues #66, #121
|
"MICROWAVE": "microwave", # issues #66, #121
|
||||||
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
|
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
|
||||||
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
|
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'): burner state
|
||||||
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
|
# lives in /mode/vs/0's options array. Deliberately the loosest entry
|
||||||
# token, and so the loosest entry in this table -- it is only ever
|
# here -- reached only when nothing more specific matched, since its
|
||||||
# reached by a device that matched nothing more specific, and its
|
# description ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
||||||
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
|
# induction cooktop via COOKTOP above (see for_device_by_model's field
|
||||||
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
|
# ordering).
|
||||||
# for the field ordering that makes that resolve correctly.
|
|
||||||
"CT": "cooktop",
|
"CT": "cooktop",
|
||||||
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
|
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
|
||||||
"DF": "air_dresser", # issue #162
|
"DF": "air_dresser", # issue #162
|
||||||
@@ -161,20 +151,16 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
|
|||||||
def _board_family_key(value: str, cut_at: str) -> str | None:
|
def _board_family_key(value: str, cut_at: str) -> str | None:
|
||||||
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
|
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
|
||||||
|
|
||||||
No known modelNum or description yields two *conflicting* board keys, so
|
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
|
which token is found first doesn't matter within one field -- the
|
||||||
a flat lookup, not a priority list. Adding an entry that could co-occur
|
table is a flat lookup, not a priority list.
|
||||||
with another (a family token, or one short enough to collide by accident)
|
|
||||||
would break that property; see this table's comment.
|
|
||||||
|
|
||||||
One documented exception (issue #196): AILITE water-purifier boards
|
One documented exception (issue #196): AILITE water-purifier boards
|
||||||
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
|
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names
|
||||||
shared cooling-subsystem board, not the refrigerator device type --
|
the shared cooling-subsystem board, not the refrigerator type --
|
||||||
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
|
'WATERPURIFIER' is the actual, more specific type. This one known
|
||||||
or rename either entry (both are correct on their own for the model
|
co-occurrence resolves to 'water_purifier'; TestBoardTokenAmbiguity
|
||||||
strings that exist today), this one known co-occurrence resolves to
|
carries a matching carve-out for this exact pair.
|
||||||
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
|
|
||||||
matching carve-out for this exact pair.
|
|
||||||
"""
|
"""
|
||||||
tokens = _board_tokens(value, cut_at)
|
tokens = _board_tokens(value, cut_at)
|
||||||
if "REF" in tokens and "WATERPURIFIER" in tokens:
|
if "REF" in tokens and "WATERPURIFIER" in tokens:
|
||||||
@@ -191,25 +177,19 @@ def _consumer_model_key(description: str) -> str | None:
|
|||||||
|
|
||||||
Usually that token is the last '_'-delimited segment before any
|
Usually that token is the last '_'-delimited segment before any
|
||||||
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
|
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
|
||||||
But issue #79's dryer pairs two model numbers in one description --
|
But issue #79's dryer pairs two model numbers in one description, so
|
||||||
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token
|
the true consumer token sits one segment before the actual last
|
||||||
('DVE50A8800') sits one segment *before* the actual last segment
|
segment -- scan from the end and take the first segment that resolves.
|
||||||
('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.
|
|
||||||
|
|
||||||
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
|
Splits on '_' only, unlike `_board_tokens` above: widening the split to
|
||||||
prefixes matched against the *start* of a segment, so widening the split
|
'-' would start reading board-family segments as consumer models (the
|
||||||
to '-' as well would start reading board-family segments as consumer
|
dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare 'WW' and
|
||||||
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
|
route to washer).
|
||||||
'WW' segment and route to washer.
|
|
||||||
|
|
||||||
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
|
Only a 2-letter prefix match, so e.g. 'WAC' (Window AC, issue #87) also
|
||||||
board-family token, issue #87) also starts with 'WA' (the top-load-washer
|
matches 'WA' (top-load washer, issue #106) at this granularity --
|
||||||
prefix, issue #106) at this granularity. for_device_by_model() consults
|
for_device_by_model() consults the board-family table first and this
|
||||||
the board-family table first and this function only as a fallback, so
|
only as a fallback, so that ambiguity resolves correctly.
|
||||||
that ambiguity resolves correctly without this function needing to know
|
|
||||||
about unrelated device families.
|
|
||||||
"""
|
"""
|
||||||
segments = (description or "").split("/", 1)[0].split("_")
|
segments = (description or "").split("/", 1)[0].split("_")
|
||||||
for segment in reversed(segments):
|
for segment in reversed(segments):
|
||||||
@@ -220,36 +200,25 @@ def _consumer_model_key(description: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
|
# /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
|
# -> registry key. The device naming its own type, no board-part guessing --
|
||||||
# guessing involved -- so it's consulted before modelNum/description at all.
|
# consulted before modelNum/description.
|
||||||
#
|
#
|
||||||
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
|
# 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
|
# `test_every_oic_type_resolves_to_a_real_registry`) -- this deliberately
|
||||||
# stops well short of the full OCF/SmartThings device-type vocabulary: a
|
# stops short of the full OCF/SmartThings vocabulary, since most of it (lights,
|
||||||
# compiled list of `x.com.st.d.*` types will include plenty of device
|
# locks, cameras, TVs, ...) has no registry here to point at, and
|
||||||
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy
|
# 'oic.d.robotcleaner' names an actual robot vacuum, a different product from
|
||||||
# meters, ...) no Samsung DA appliance dump could ever report and this
|
# the clean/auto-empty *station* `vacuum_station` covers.
|
||||||
# integration has no registry for -- and 'oic.d.robotcleaner' names an
|
|
||||||
# actual robot vacuum, a different product from the clean/auto-empty
|
|
||||||
# *station* `vacuum_station` covers (see that registry's own module
|
|
||||||
# docstring); mapping it there would misroute a genuine robot-vacuum dump
|
|
||||||
# into a registry with no vacuum-body capabilities at all. Add a row only
|
|
||||||
# once there's a real registry key on the right-hand side to point at.
|
|
||||||
#
|
#
|
||||||
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
|
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
|
||||||
# device-type vocabulary (used for categories with no `oic.d.*` equivalent),
|
# device-type vocabulary, for categories with no `oic.d.*` equivalent.
|
||||||
# same prefix convention as the `x.com.samsung.da.*` resource fields
|
|
||||||
# elsewhere in this codebase.
|
|
||||||
#
|
#
|
||||||
# `oic.d.cooktop` is deliberately absent, and is the one measured type left out.
|
# `oic.d.cooktop` is deliberately absent: a TP1X_DA-KS-COOKTOP induction
|
||||||
# A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` and
|
# reports it, but `cooktop` and `induction_cooktop` are unrelated registries
|
||||||
# `induction_cooktop` are two unrelated registries that happen to share the
|
# sharing the English word (see by_type/cooktop.py's docstring) -- the OCF
|
||||||
# English word (see by_type/cooktop.py's docstring: the NA9300K gas family keeps
|
# type doesn't distinguish them, and as the primary signal it would override
|
||||||
# burner state in /mode/vs/0's options array, a completely different OCF
|
# a correct `COOKTOP`/`CT` board token. No unambiguous key to point at, so no
|
||||||
# surface). The OCF type does not distinguish them, so mapping it to either key
|
# row.
|
||||||
# would silently misroute the other -- and as the *primary* signal it would
|
|
||||||
# override a `COOKTOP`/`CT` board token that had it right. Same reasoning as
|
|
||||||
# `oic.d.robotcleaner` above: no unambiguous key to point at, so no row.
|
|
||||||
_OIC_TYPE_TO_KEY: dict[str, str] = {
|
_OIC_TYPE_TO_KEY: dict[str, str] = {
|
||||||
"oic.d.airconditioner": "airconditioner",
|
"oic.d.airconditioner": "airconditioner",
|
||||||
"oic.d.airpurifier": "air_purifier",
|
"oic.d.airpurifier": "air_purifier",
|
||||||
@@ -268,13 +237,10 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
|
|||||||
|
|
||||||
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
|
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
|
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
|
||||||
declaration.
|
declaration. The primary path when a dump carries it, since the device
|
||||||
|
names its own type. Most hardware still doesn't populate `/oic/d`
|
||||||
The primary path when a dump carries it: the device names its own type,
|
usefully, so `for_device_by_model`/`for_device_by_resources` remain
|
||||||
so there's nothing to infer from board part numbers. Most hardware still
|
load-bearing for everything else.
|
||||||
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
|
|
||||||
this only ever helps a minority of dumps, and `for_device_by_model`/
|
|
||||||
`for_device_by_resources` remain load-bearing for everything else.
|
|
||||||
"""
|
"""
|
||||||
for device_type in device_types:
|
for device_type in device_types:
|
||||||
key = _OIC_TYPE_TO_KEY.get(device_type)
|
key = _OIC_TYPE_TO_KEY.get(device_type)
|
||||||
@@ -323,15 +289,15 @@ def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | No
|
|||||||
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
|
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
|
||||||
"""Detect a device family from a distinctive local-resource signature.
|
"""Detect a device family from a distinctive local-resource signature.
|
||||||
|
|
||||||
This runs first as an override path for non-standard devices, not because
|
Runs first as an override path for non-standard devices -- not because
|
||||||
resource signatures are inherently more trustworthy than OIC/model
|
resource signatures are more trustworthy than OIC/model metadata, but
|
||||||
metadata. It also types boards that ship no ``/information/vs/0`` at all,
|
because it also types boards with no ``/information/vs/0`` at all.
|
||||||
leaving `for_device_by_model` nothing to read. Some newer cooktops are the
|
Some newer cooktops were the original case: their mode resource still
|
||||||
original case: their mode resource still identifies them, carrying a
|
identifies them via a DeviceType option and multiple per-burner
|
||||||
DeviceType option and multiple per-burner OperationState options.
|
OperationState options.
|
||||||
|
|
||||||
Require two independent shapes for every signature here, never one, so
|
Every signature here requires two independent shapes, never one, so
|
||||||
putting this ahead of OIC/model metadata cannot let a common resource
|
running this ahead of OIC/model metadata can't let a common resource
|
||||||
misclassify an unrelated family.
|
misclassify an unrelated family.
|
||||||
"""
|
"""
|
||||||
mode = resources.get("/mode/vs/0", {})
|
mode = resources.get("/mode/vs/0", {})
|
||||||
@@ -347,12 +313,10 @@ def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None
|
|||||||
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
|
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
|
||||||
return _REGISTRY_BY_KEY["range_hood"]
|
return _REGISTRY_BY_KEY["range_hood"]
|
||||||
# Oven/range/microwave boards that report no /information/vs/0 at all
|
# Oven/range/microwave boards that report no /information/vs/0 at all
|
||||||
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
|
# (issues #74, #172) can't be matched via modelNum tokens either. Mode
|
||||||
# absent from the dump, not just empty) can't be matched via
|
# vocabulary alongside the oven cavity resource (/oven/vs/0) is a safe
|
||||||
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
|
# two-resource signature; it also corrects Qooker's generic oic.d.oven
|
||||||
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
|
# metadata (PR #225) since resource detection runs before it.
|
||||||
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
|
|
||||||
# PR #225) when resource detection runs before metadata.
|
|
||||||
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
|
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
|
||||||
if not isinstance(supported_modes, (list, tuple)):
|
if not isinstance(supported_modes, (list, tuple)):
|
||||||
supported_modes = ()
|
supported_modes = ()
|
||||||
@@ -379,20 +343,18 @@ def resolve(
|
|||||||
flow's probe and the golden-regression harness all call this, so the
|
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.
|
order can't drift between what ships and what the tests assert.
|
||||||
|
|
||||||
Distinctive resource signatures run first because they describe the live
|
Distinctive resource signatures run first, since they describe the
|
||||||
capability surface a registry must bind. They are deliberately strict in
|
live capability surface a registry must bind; `for_device_by_resources`
|
||||||
`for_device_by_resources`: each requires multiple independent details, so
|
is deliberately strict (multiple independent details required) so this
|
||||||
this can correct misleading metadata (Qooker's generic ``oic.d.oven``)
|
can correct misleading metadata without a common href overriding an
|
||||||
without a common href overriding an unrelated family. When no signature
|
unrelated family. When no signature matches, `/oic/d`'s `rt` wins over
|
||||||
matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see
|
model-string parsing.
|
||||||
registry/identity.py) wins over model-string parsing.
|
|
||||||
|
|
||||||
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
|
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted:
|
||||||
reads like the obvious signal -- the device naming its own type, e.g.
|
only a minority of hardware populates it, every device that does is
|
||||||
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
|
already typed by its modelNum board token, and no device-support issue
|
||||||
device that does is already typed by its modelNum board token, and no
|
has ever needed it. Still reported in diagnostics as a firmware
|
||||||
device-support issue has ever been fixed by adding a mapping for it. It
|
marker.
|
||||||
is still reported in diagnostics as a firmware-generation marker.
|
|
||||||
"""
|
"""
|
||||||
info = resources.get("/information/vs/0", {})
|
info = resources.get("/information/vs/0", {})
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,24 +12,16 @@ reports a CO2 reading the other two families don't.
|
|||||||
|
|
||||||
A second `value` list element on the particulate-matter types (e.g. Dust's
|
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
|
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
|
||||||
board (no `supportedGrades`/similar field, no repeated dump to compare
|
board confirms what its scale means -- left unbound rather than guessed;
|
||||||
against) confirms what its scale means -- left unbound rather than guessed,
|
index 0 is the only slot any family has ever read.
|
||||||
per the adding-device-support skill's "still never invent... from nothing"
|
|
||||||
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
|
|
||||||
shape; index 0 is the only slot any family has ever read.
|
|
||||||
|
|
||||||
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
|
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
|
||||||
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
|
(pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate
|
||||||
ug/m3 particulate readings in a physically consistent order (coarser
|
values: Samsung's own two-tier Korean convention maps only to a PM10/PM2.5
|
||||||
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
|
pair, and this board's three-tier naming doesn't confirm where the extra
|
||||||
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
|
tier or a PM1 reading fits. A wrong guess would silently mislabel every
|
||||||
three-tier naming doesn't confirm where the extra tier or a PM1 reading
|
reading forever, so they're plain `measurement` sensors named after the
|
||||||
actually fits. The adding-device-support skill's read-side rule says
|
device's own field instead, matching air_purifier.AIR_QUALITY's precedent.
|
||||||
leave unit/device_class unset when the dump gives no field that
|
|
||||||
nominates one -- a wrong guess would silently mislabel every reading
|
|
||||||
forever, and the write-side rejection safety net doesn't cover reads.
|
|
||||||
Exposed as plain `measurement` sensors named after the device's own
|
|
||||||
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import time as dt_time
|
from datetime import time as dt_time
|
||||||
@@ -134,13 +126,10 @@ def _dnd_time_write(field):
|
|||||||
return _write
|
return _write
|
||||||
|
|
||||||
|
|
||||||
# Issue #210: no idle-vs-active dump pair exists for this href (only one
|
# Issue #210: only one dump exists (DND never toggled in it), so this write
|
||||||
# dump total, DND never toggled in it), so this write contract is an
|
# contract is an educated guess -- symmetric with the read side's own
|
||||||
# educated guess, not a confirmed one -- symmetric with the read side
|
# 'true'/'false' and 'HH:MM:SS' formats, but still needs a reporter to
|
||||||
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
|
# confirm it on real hardware.
|
||||||
# device itself reports back) rather than invented from nothing, but still
|
|
||||||
# needs a reporter to actually flip it on real hardware and confirm. See
|
|
||||||
# the adding-device-support skill's "Educated guesses are fine" section.
|
|
||||||
DND = Capability(
|
DND = Capability(
|
||||||
href="/dnd/vs/0",
|
href="/dnd/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
|
|||||||
@@ -1,61 +1,25 @@
|
|||||||
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
|
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
|
||||||
(model AX60R5080WD/SE, issue #56).
|
(model AX60R5080WD/SE, issue #56).
|
||||||
|
|
||||||
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
|
Power, kids-lock, remote-control, alarms, and the energy meter are the
|
||||||
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
|
shared common.py capabilities; /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS
|
||||||
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
|
(identical field/write contract).
|
||||||
dishwasher.DIAGNOSIS -- identical field/write contract
|
|
||||||
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
|
|
||||||
|
|
||||||
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
|
/mode/vs/0's options[] packs several '<Prefix>_<value>' flags, the same
|
||||||
'<Prefix>_<value>' flags into one list -- the same packed-list contract
|
packed-list contract as laundry.py's option_value/option_write. Light_On/
|
||||||
laundry.py's option_value/option_write already model for /course/vs/0's
|
Light_Off is a real on/off switch here -- NOT the same polarity as the AC
|
||||||
options[] (reused directly below, just against this family's own href). Per
|
family's own Light_On/Light_Off token on its own /mode/vs/0, which is
|
||||||
issue #56's follow-up (five diagnostics dumps captured with the physical unit
|
inverted (airconditioner._display_light_on). Comode_Off reads 'Off' on
|
||||||
set to Auto/Sleep/Low/Medium/High):
|
every setting (Auto/Sleep/Low/Medium/High), ruling out the original
|
||||||
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
|
"fan speed selector" guess; exposed read-only. OptionCode_* and Blooming_*
|
||||||
real switch, RMW-replacing just that one entry.
|
are unmodeled: confirmed opaque / not app-facing.
|
||||||
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).
|
|
||||||
|
|
||||||
/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up).
|
/airflow/0's `speed` is a real fan-speed control: two independent units,
|
||||||
The first round of five dumps above wasn't conclusive -- it read 0 for both
|
sampled 60-90s apart per setting, confirmed a clean monotonic 0-4 mapping
|
||||||
Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five
|
across Auto/Sleep/Low/Medium/High. AIRFLOW_GENERIC below builds an
|
||||||
were captured within about a minute of each other, faster than this
|
ordered-speed fan off that range. /airflow/vs/0's vendor `speedLevel` is
|
||||||
integration's own ~30s poll cycle could settle each change. A second round,
|
NOT used for the same purpose -- unreliable on both units in the same
|
||||||
captured 60-90s apart per setting on two independent units, confirmed a
|
round (collided Low/Medium on one, stuck at 0 on the other).
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -73,14 +37,11 @@ from ..entities import (
|
|||||||
from .common import epoch_to_utc, 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
|
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
|
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
|
||||||
# #130) report fan modes directly on /mode/vs/0's top-level `modes`/
|
# on /mode/vs/0's top-level modes/supportedModes instead of packing
|
||||||
# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing
|
# everything into options[] like the older ARTIK051_TVTL family. Both
|
||||||
# everything into the options[] array the way the older ARTIK051_TVTL
|
# generations share this href; FAN and MODE below are mutually exclusive
|
||||||
# family above does -- that older family's /mode/vs/0 has no top-level
|
# via presence of supportedModes.
|
||||||
# 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_MODE = "/mode/vs/0"
|
||||||
HREF_AIRFLOW = "/airflow/0"
|
HREF_AIRFLOW = "/airflow/0"
|
||||||
HREF_WIND_STRENGTH = "/wind/strength/vs/0"
|
HREF_WIND_STRENGTH = "/wind/strength/vs/0"
|
||||||
@@ -122,12 +83,9 @@ def _consumable_state(items, name):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# FilterProgress is a 0-100 percentage counting up as the filter wears --
|
# FilterProgress counts UP as the filter wears (100 = "needs changing",
|
||||||
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
|
# confirmed via the SmartThings app) -- named after the raw field rather
|
||||||
# once this reaches 100, so 100 means fully used, not "brand new." Named
|
# than "filter life," which would imply the opposite direction.
|
||||||
# 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.
|
|
||||||
FILTER = Capability(
|
FILTER = Capability(
|
||||||
href="/consumable/vs/0",
|
href="/consumable/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -160,11 +118,9 @@ DEVICE_ACTIVE = Capability(
|
|||||||
|
|
||||||
|
|
||||||
def _power_write(power_href, value):
|
def _power_write(power_href, value):
|
||||||
"""Shared 'power' payload handling for this family's three FanDesc write
|
"""Shared 'power' payload handling for this family's FanDescs -- targets
|
||||||
functions -- targets whichever power href fan.py's _power_payload picked
|
whichever power href fan.py picked (the board may only report
|
||||||
(the board may only report /power/0); a hardcoded vendor href here would
|
/power/0)."""
|
||||||
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":
|
if power_href == "/power/0":
|
||||||
return ["power", "0"], {"value": bool(value)}
|
return ["power", "0"], {"value": bool(value)}
|
||||||
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
||||||
@@ -179,24 +135,14 @@ def _airflow_fan_write(payload, rep, href=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Confirmed via issue #56's second, properly-spaced round of diagnostics
|
# Confirmed monotonic 0-4 speed code (see module docstring) backs a real
|
||||||
# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is
|
# ordered-speed fan, same SET_SPEED shape as the range hood's. `direction`
|
||||||
# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now
|
# stays a diagnostic: every dump reads 'Off' regardless of fan setting.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared
|
# Keyed 'airflow_fan', not 'fan' -- FAN below shares this registry and also
|
||||||
# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built
|
# uses key 'fan'; unique_id is built from key alone, so a shared key would
|
||||||
# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are
|
# collide if a board ever reported both (empirically mutually exclusive,
|
||||||
# only *empirically* mutually exclusive (every dump seen has one board
|
# not architecturally enforced the way same-href caps are).
|
||||||
# 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.
|
|
||||||
AIRFLOW_GENERIC = Capability(
|
AIRFLOW_GENERIC = Capability(
|
||||||
href=HREF_AIRFLOW,
|
href=HREF_AIRFLOW,
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -211,10 +157,8 @@ AIRFLOW_GENERIC = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Left exactly as a read-only fallback -- speedLevel is NOT the same
|
# Read-only fallback: speedLevel is unreliable (see module docstring),
|
||||||
# confirmed-reliable field as /airflow/0's speed above (see module
|
# unlike /airflow/0's speed.
|
||||||
# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
|
|
||||||
# on the other in the same properly-spaced round.
|
|
||||||
AIRFLOW_VS_FALLBACK = Capability(
|
AIRFLOW_VS_FALLBACK = Capability(
|
||||||
href="/airflow/vs/0",
|
href="/airflow/vs/0",
|
||||||
match_fn=lambda rep, resources: "/airflow/0" not in resources,
|
match_fn=lambda rep, resources: "/airflow/0" not in resources,
|
||||||
@@ -239,12 +183,9 @@ AIRFLOW_VS_FALLBACK = Capability(
|
|||||||
|
|
||||||
|
|
||||||
def _light_write(payload, rep, href=None):
|
def _light_write(payload, rep, href=None):
|
||||||
# option_write's single-token write is confirmed on a washer's
|
# option_write's single-token merge is confirmed on a washer's
|
||||||
# /course/vs/0 (issue #54), NOT independently on this family's
|
# /course/vs/0 (issue #54); extrapolated here on the assumption the
|
||||||
# /mode/vs/0 -- extrapolated on the assumption the same vendor field
|
# same vendor field merges the same way on this family's /mode/vs/0.
|
||||||
# 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"], {
|
return ["mode", "vs", "0"], {
|
||||||
"x.com.samsung.da.options": option_write("Light", payload),
|
"x.com.samsung.da.options": option_write("Light", payload),
|
||||||
}
|
}
|
||||||
@@ -286,9 +227,8 @@ def _fan_write(payload, rep, href=None):
|
|||||||
|
|
||||||
|
|
||||||
def _first_fan_mode(rep):
|
def _first_fan_mode(rep):
|
||||||
"""Representative scalar for the fan entity in the flattened state
|
"""Representative scalar for the flattened golden state; the real
|
||||||
(golden/regression), mirroring airconditioner.py's own _first_mode --
|
entity reads live coordinator state instead."""
|
||||||
the real entity computes its state from live coordinator reads."""
|
|
||||||
modes = rep.get("x.com.samsung.da.modes")
|
modes = rep.get("x.com.samsung.da.modes")
|
||||||
if isinstance(modes, (list, tuple)):
|
if isinstance(modes, (list, tuple)):
|
||||||
return modes[0] if modes else None
|
return modes[0] if modes else None
|
||||||
@@ -296,10 +236,8 @@ def _first_fan_mode(rep):
|
|||||||
|
|
||||||
|
|
||||||
# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered
|
# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered
|
||||||
# percentage -- WindFree/Smart/Sleep are named behaviors, not
|
# percentage -- these are named behaviors, not "faster/slower" positions,
|
||||||
# "faster/slower" positions relative to Max/Mid, so fan.py's entity for
|
# so fan.py only exposes PRESET_MODE here.
|
||||||
# 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.
|
|
||||||
FAN = Capability(
|
FAN = Capability(
|
||||||
href=HREF_MODE,
|
href=HREF_MODE,
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -324,19 +262,14 @@ def _wind_strength_fan_write(payload, rep, href=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but
|
# A-VTWW-TP2-21-COMMON (issue #151): named presets like FAN above, but on a
|
||||||
# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
|
# distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
|
||||||
# self-describing supportedModes -- x.com.samsung.da.modesName gives the
|
# self-describing supportedModes -- x.com.samsung.da.modesName gives the
|
||||||
# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's
|
# real names, read live by fan.py rather than a hardcoded map. `modes` is a
|
||||||
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded
|
# bare string here, not a single-element list like HREF_MODE's.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry
|
# key is 'wind_strength_fan', not 'fan' -- same unique_id collision hazard
|
||||||
# and also uses a FanDesc; BoundEntity's unique_id is built from key alone
|
# as AIRFLOW_GENERIC above.
|
||||||
# (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).
|
|
||||||
WIND_STRENGTH_FAN = Capability(
|
WIND_STRENGTH_FAN = Capability(
|
||||||
href=HREF_WIND_STRENGTH,
|
href=HREF_WIND_STRENGTH,
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -350,15 +283,12 @@ WIND_STRENGTH_FAN = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# TP1X_DA-AC-AIR-class additions (issue #130): resources the older
|
||||||
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several
|
# ARTIK051_TVTL family never reported.
|
||||||
# resources the older ARTIK051_TVTL family never did.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood
|
# Screen/indicator panel on/off, distinct from the display_light switch
|
||||||
# light): both report the same {mode, supportedModes: [On, Off]} shape on
|
# above (ambient mood light) -- two independent controls on separate hrefs
|
||||||
# separate hrefs on this dump, so they're two independent physical controls,
|
# with the same {mode, supportedModes: [On, Off]} shape.
|
||||||
# not a duplicate encoding of one.
|
|
||||||
DISPLAY = Capability(
|
DISPLAY = Capability(
|
||||||
href="/display/vs/0",
|
href="/display/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -377,10 +307,8 @@ DISPLAY = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own
|
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's
|
||||||
# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not
|
# AIR_FILTER; the normal/wash/replace option list is reused as-is.
|
||||||
# seen on this one dump, so the option list there is reused as-is rather
|
|
||||||
# than re-deriving it from a single sample.
|
|
||||||
HEPA_FILTER = Capability(
|
HEPA_FILTER = Capability(
|
||||||
href="/filter/hepafilter/vs/0",
|
href="/filter/hepafilter/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -406,10 +334,9 @@ HEPA_FILTER = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Physical panel/cover status -- meaning of the one value seen ('Close') is
|
# Physical panel/cover status ('Close' seen, plausibly the HEPA-filter
|
||||||
# plausible (the HEPA-filter access cover) but unconfirmed, and no
|
# cover) -- unconfirmed, and no supportedStatus list to check against, so a
|
||||||
# supportedStatus list is present to check against -- exposed as a plain
|
# plain diagnostic rather than an asserted binary_sensor.
|
||||||
# diagnostic sensor rather than an asserted binary_sensor polarity.
|
|
||||||
PANEL_STATUS = Capability(
|
PANEL_STATUS = Capability(
|
||||||
href="/panel/vs/0",
|
href="/panel/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -443,22 +370,18 @@ PET_FILTER_ACTIVATION = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sound mode/volume shapes look like laundry.py's SOUND_MODE/SOUND_VOLUME at
|
# Sound mode/volume look like laundry.py's SOUND_MODE/SOUND_VOLUME but this
|
||||||
# a glance, but this board's actual values differ (supportedModes here is
|
# board's actual values differ (supportedModes here is ['mute', 'buzzer'],
|
||||||
# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range
|
# not laundry's voice/tone/mute; volume is 0-3, not laundry's fixed 0-15) --
|
||||||
# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a
|
# separate descriptors reading live supported values instead of reusing
|
||||||
# valid write ('buzzer') or expose the wrong number range, so these are
|
# laundry's hardcoded table.
|
||||||
# separate descriptors reading the live supported values instead of a
|
|
||||||
# hardcoded table.
|
|
||||||
SOUND_MODE = Capability(
|
SOUND_MODE = Capability(
|
||||||
href="/settings/sound/mode/vs/0",
|
href="/settings/sound/mode/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
# Distinct translation_key from laundry.SOUND_MODE's shared
|
# Distinct translation_key from laundry.SOUND_MODE's shared
|
||||||
# 'sound_mode' catalog entry -- that one's state table is
|
# 'sound_mode' catalog ({voice, tone, mute}) -- this board's
|
||||||
# {voice, tone, mute}, but this board's supportedModes is
|
# {mute, buzzer} doesn't overlap it.
|
||||||
# {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled
|
|
||||||
# (falls through to the raw code) since the catalogs don't overlap.
|
|
||||||
SelectDesc(
|
SelectDesc(
|
||||||
key="sound_mode",
|
key="sound_mode",
|
||||||
translation_key="air_purifier_sound_mode",
|
translation_key="air_purifier_sound_mode",
|
||||||
@@ -510,76 +433,41 @@ SOUND_VOLUME = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# AI Purify -- /airlevelcheck/vs/0 (issues #84, #190). Not scheduler
|
||||||
# AI Purify -- /airlevelcheck/vs/0 (issues #84 and #190)
|
# 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.
|
||||||
#
|
#
|
||||||
# Covered as "periodic air-quality sensing scheduler plumbing" until two dumps
|
# Two independent knobs, one entity each rather than folded into one
|
||||||
# of the AVT-WW-TP1-23 board showed it is not plumbing: it drives the feature
|
# select: periodicSensingActivationState (is it running) and autoExeState
|
||||||
# the SmartThings app calls AI Purify, where the unit wakes on a timer, samples
|
# (what it does with a bad reading, Off/Airpurify/Alarm) -- mirrors the
|
||||||
# the air, and optionally acts on the result. Every field is named, none are
|
# appliance's own UI. Folding them lost information: a configured action
|
||||||
# opaque, and two of them are already user-set on the reported units.
|
# 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").
|
||||||
#
|
#
|
||||||
# Three of this registry's four board families report the resource with the
|
# range_hood.AIR_LEVEL_CHECK models the same href's read-only fields
|
||||||
# same field names -- TP1X_DA-AC-AIR (#130), A-VTWW-TP2 (#151) and AVT-WW-TP1
|
# (reused verbatim below) but is deliberately not imported: it exposes
|
||||||
# (#84, #190); only ARTIK051_TVTL (#56) has no such href. Bound unconditionally
|
# periodic_air_sensing as a read-only BinarySensorDesc where this board
|
||||||
# rather than behind a match_fn so any board reporting it is covered; the one
|
# needs it writable, and reusing it would migrate every hood user's entity
|
||||||
# field that genuinely varies is gated per-entity below.
|
# to a different platform.
|
||||||
#
|
#
|
||||||
# The resource carries two independent knobs and they get one entity each,
|
# Every write below was exercised on AVT-WW-TP1-23-AXX500 hardware and
|
||||||
# rather than being folded into a single control:
|
# 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.
|
||||||
#
|
#
|
||||||
# periodicSensingActivationState On/Off -- is AI Purify running
|
# Deferred: startSensingOnce looks like a one-shot "sense now" trigger but
|
||||||
# autoExeState Off/Airpurify/Alarm -- what it does with a
|
# stays unbound until its side effect (not just the echo) is confirmed.
|
||||||
# bad reading
|
|
||||||
#
|
|
||||||
# The appliance itself presents them that way: its own UI has an on/off for AI
|
|
||||||
# Purify separately from the three mode choices. Folding them into one select
|
|
||||||
# was tried first and lost two things -- a configured action became invisible
|
|
||||||
# while the feature was off, and no option could toggle the feature without
|
|
||||||
# also overwriting the action.
|
|
||||||
#
|
|
||||||
# Note the two 'off's mean opposite things and are not interchangeable. The
|
|
||||||
# switch's off stops the unit sampling at all; the select's off is the
|
|
||||||
# advertised autoExeState "Off", where the unit keeps sampling and simply
|
|
||||||
# doesn't act on what it measures -- the app calls that choice "sensing only".
|
|
||||||
#
|
|
||||||
# The select reads its options straight off supportedAutoExeState rather than
|
|
||||||
# a typed-in tuple, the same shape SOUND_MODE below uses for supportedModes: a
|
|
||||||
# board advertising a fourth action gets it accepted on both the options list
|
|
||||||
# and the write path.
|
|
||||||
#
|
|
||||||
# range_hood.AIR_LEVEL_CHECK already models this same href, and its read-only
|
|
||||||
# keys (air_sensing_state / last_air_sensing_time / last_air_sensing_level) are
|
|
||||||
# reused verbatim so both families share one catalog entry. It is deliberately
|
|
||||||
# NOT imported: the hood exposes periodic_air_sensing as a read-only
|
|
||||||
# BinarySensorDesc and this board needs a writable SwitchDesc on that same key,
|
|
||||||
# so reusing the hood's capability would migrate every hood user's entity to a
|
|
||||||
# different platform.
|
|
||||||
#
|
|
||||||
# Verification: every write below was exercised on AVT-WW-TP1-23-AXX500
|
|
||||||
# hardware. This board returns 2.04 for writes it silently discards (see
|
|
||||||
# HEPA_FILTER's filter-reset note), so an echo proves nothing -- each was
|
|
||||||
# judged by the value surviving a reconnect, which forces a new DTLS session,
|
|
||||||
# fresh discovery and a fresh observe of this href, leaving no cached state to
|
|
||||||
# read back. The other two families get the writes on field-shape grounds, the
|
|
||||||
# same basis on which they already share MODE, HEPA_FILTER and the air-quality
|
|
||||||
# sensors.
|
|
||||||
#
|
|
||||||
# Deferred: startSensingOnce (On/Off on all three dumps) looks like a one-shot
|
|
||||||
# "sense now" trigger and would be a ButtonDesc, but nothing here writes it yet
|
|
||||||
# and this board is known to acknowledge writes it discards -- so it stays
|
|
||||||
# unbound until someone can confirm the side effect rather than the echo.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _interval_minutes(seconds):
|
def _interval_minutes(seconds):
|
||||||
"""Device stores the interval in seconds; the entity is in minutes.
|
"""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."""
|
||||||
`is None` rather than a falsy check so a reported 0 is distinguishable
|
|
||||||
from a missing one. Anything else nonzero rounds up rather than to
|
|
||||||
nearest, so a sub-minute value can't render as 0 and fall below the
|
|
||||||
entity's own floor.
|
|
||||||
"""
|
|
||||||
secs = int_or_none(seconds)
|
secs = int_or_none(seconds)
|
||||||
if secs is None:
|
if secs is None:
|
||||||
return None
|
return None
|
||||||
@@ -587,26 +475,15 @@ def _interval_minutes(seconds):
|
|||||||
|
|
||||||
|
|
||||||
def _interval_write(payload, rep, href=None):
|
def _interval_write(payload, rep, href=None):
|
||||||
# Minutes in the UI -> seconds on the wire (scalar string). Modelled as a
|
# Minutes in the UI -> seconds on the wire. Modeled as a free Number,
|
||||||
# free Number rather than the app's three fixed choices (10 min / 30 min /
|
# not the app's three fixed choices, since the resource advertises no
|
||||||
# 1 hour): this resource advertises no supported-values or range field for
|
# constraint for this field (unlike supportedAutoExeState beside it)
|
||||||
# the interval -- supportedAutoExeState sits right beside it, so the board
|
# and accepts finer values than the app offers (60s drove an observed
|
||||||
# does advertise constraints where it has them -- and it accepts values the
|
# ~60s sensing cycle on hardware). One-minute floor matches this
|
||||||
# app never offers. Writing 60 s, six times finer than the app's smallest
|
# board's own reporting resolution (lastSensingTime lands on exact
|
||||||
# choice, drove an observed ~60 s sensing cycle on hardware.
|
# minutes). Zero is refused: unlike a real "no timer" 0 elsewhere in
|
||||||
#
|
# this repo, nothing establishes what 0 does here. Silent no-op via
|
||||||
# One minute is the floor because that's the resolution this board reports
|
# None, same shape as range_hood._lamp_level_write.
|
||||||
# results at: lastSensingTime lands on an exact minute on every sample from
|
|
||||||
# the AVT-WW-TP1 and A-VTWW-TP2 boards (both fixtures, and eleven
|
|
||||||
# consecutive live readings), where the TP1X/AC/hood boards report arbitrary
|
|
||||||
# seconds. A sub-minute interval is therefore unobservable here whether or
|
|
||||||
# not the board honours it. Zero is refused for a separate reason: unlike
|
|
||||||
# oven.cook_time or operational's delay hours, where 0 is a real setting
|
|
||||||
# ("no timer", "no delay"), nothing establishes what a 0 interval does to
|
|
||||||
# this board -- so native_min stops the UI offering it, and this guard
|
|
||||||
# covers the service-call path. Silent no-op via a None return, the same
|
|
||||||
# shape range_hood._lamp_level_write uses for a level the device didn't
|
|
||||||
# advertise.
|
|
||||||
minutes = round(float(payload))
|
minutes = round(float(payload))
|
||||||
if minutes < 1:
|
if minutes < 1:
|
||||||
return None
|
return None
|
||||||
@@ -616,10 +493,9 @@ def _interval_write(payload, rep, href=None):
|
|||||||
|
|
||||||
|
|
||||||
def _periodic_sensing_write(payload, rep, href=None):
|
def _periodic_sensing_write(payload, rep, href=None):
|
||||||
# The master on/off for AI Purify. Leaves autoExeState alone, so the
|
# Master on/off; leaves autoExeState alone so the configured action
|
||||||
# configured action survives the feature being switched off and comes back
|
# survives the feature being toggled off -- the select can't do that,
|
||||||
# with it -- the thing the select cannot do, since every option it writes
|
# since every option write sets an action too.
|
||||||
# sets an action.
|
|
||||||
return ["airlevelcheck", "vs", "0"], {
|
return ["airlevelcheck", "vs", "0"], {
|
||||||
"x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off")
|
"x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off")
|
||||||
}
|
}
|
||||||
@@ -631,14 +507,11 @@ def _skip_status_write(payload, rep, href=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# The daily window during which periodic sensing is skipped, stored as one
|
# Daily skip window, stored as one HHMMHHMM string
|
||||||
# HHMMHHMM string (start+end) on periodicSensingSkipTime. The read side is
|
# (periodicSensingSkipTime). Cross-confirmed on two units (inert
|
||||||
# cross-confirmed on two units: issue #84's sits at the inert '00000000', while
|
# '00000000' vs a real '03002300'). Split into two HA time entities; each
|
||||||
# issue #190's carries a real user-set '03002300' -> 03:00-23:00. Split into
|
# write reads the other half back out of the live rep so the pair
|
||||||
# two HA time entities; each write reads the other half back out of the live
|
# round-trips -- confirmed in both directions on hardware.
|
||||||
# rep so the pair round-trips. Confirmed in both directions on hardware: from
|
|
||||||
# 13:00-23:00, writing start=07:30 then end=22:00 left the device holding
|
|
||||||
# '07302200' -- each write kept the half it wasn't given.
|
|
||||||
def _skip_time_read(part):
|
def _skip_time_read(part):
|
||||||
def _read(value):
|
def _read(value):
|
||||||
raw = str(value or "")
|
raw = str(value or "")
|
||||||
@@ -654,11 +527,9 @@ def _skip_time_read(part):
|
|||||||
|
|
||||||
|
|
||||||
def _skip_half(raw, part):
|
def _skip_half(raw, part):
|
||||||
"""The half this write isn't setting, normalized. Padding alone would carry
|
"""The half this write isn't setting, normalized. An unparseable half
|
||||||
a malformed value straight back to the device -- writing start over a junk
|
becomes '0000' rather than carrying a malformed value back to the
|
||||||
skip time would send '0730' + junk. The read side already refuses a half it
|
device."""
|
||||||
can't parse, so an unparseable one becomes '0000' here and the pair
|
|
||||||
round-trips honestly in the same cases."""
|
|
||||||
chunk = (str(raw or "") + "00000000")[:8]
|
chunk = (str(raw or "") + "00000000")[:8]
|
||||||
other = chunk[4:8] if part == "start" else chunk[0:4]
|
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"
|
return other if _skip_time_read("end" if part == "start" else "start")(chunk) else "0000"
|
||||||
@@ -687,10 +558,8 @@ AIR_LEVEL_CHECK = Capability(
|
|||||||
value_fn=lambda v: str(v).lower() == "on",
|
value_fn=lambda v: str(v).lower() == "on",
|
||||||
write_fn=_periodic_sensing_write,
|
write_fn=_periodic_sensing_write,
|
||||||
),
|
),
|
||||||
# Options come off supportedAutoExeState, not a table here -- the
|
# Options come off supportedAutoExeState rather than a typed table,
|
||||||
# catalog carries the labels for the three values seen so far, and an
|
# so an unrecognized fourth value still reaches the user.
|
||||||
# unrecognized fourth still reaches the user (select.py falls back to
|
|
||||||
# the device's own token when the catalog doesn't know it).
|
|
||||||
SelectDesc(
|
SelectDesc(
|
||||||
key="sensing_mode",
|
key="sensing_mode",
|
||||||
field="x.com.samsung.da.autoExeState",
|
field="x.com.samsung.da.autoExeState",
|
||||||
@@ -703,10 +572,9 @@ AIR_LEVEL_CHECK = Capability(
|
|||||||
{"x.com.samsung.da.autoExeState": p},
|
{"x.com.samsung.da.autoExeState": p},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# The one field that varies across the three families reporting this
|
# The one field that varies across families: TP1X_DA-AC-AIR (#130)
|
||||||
# resource: the TP1X_DA-AC-AIR dump (#130) omits it while both
|
# omits it, so that board runs sensing on a fixed, unexposed
|
||||||
# AVT-WW-TP1 dumps and the A-VTWW-TP2 dump carry it, so that board runs
|
# interval.
|
||||||
# the sensing engine on a fixed interval it doesn't expose.
|
|
||||||
NumberDesc(
|
NumberDesc(
|
||||||
key="sensing_interval",
|
key="sensing_interval",
|
||||||
field="x.com.samsung.da.periodicSensingInterval",
|
field="x.com.samsung.da.periodicSensingInterval",
|
||||||
@@ -758,9 +626,8 @@ AIR_LEVEL_CHECK = Capability(
|
|||||||
entity_category="diagnostic",
|
entity_category="diagnostic",
|
||||||
value_fn=epoch_to_utc,
|
value_fn=epoch_to_utc,
|
||||||
),
|
),
|
||||||
# 'Kr1' on both dumps -- a national air-quality grade whose scale is
|
# 'Kr1' on both dumps -- a region-prefixed, undocumented grade;
|
||||||
# region-prefixed and undocumented here, so it stays a raw diagnostic
|
# stays a raw diagnostic rather than an asserted enum.
|
||||||
# rather than being mapped to an asserted enum.
|
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key="last_air_sensing_level",
|
key="last_air_sensing_level",
|
||||||
field="x.com.samsung.da.lastSensingLevel",
|
field="x.com.samsung.da.lastSensingLevel",
|
||||||
@@ -770,18 +637,12 @@ AIR_LEVEL_CHECK = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
|
# /humidity/0 and /humidity/vs/0 are empty on both dumps -- covered here
|
||||||
# been verified against -- covered here (not globally, per ignored.py's
|
# (not globally) since they collide with fridge/AC schemas elsewhere, same
|
||||||
# module docstring) since those hrefs collide with fridge/AC schemas
|
# reasoning as airconditioner.py's _AC_IGNORED. The next six hrefs (issue
|
||||||
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
|
# #130) are the exact same DA-AC- board resources as _AC_IGNORED,
|
||||||
#
|
# duplicated here rather than promoted to the global list (a possible
|
||||||
# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same
|
# follow-up DRY cleanup).
|
||||||
# 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.
|
|
||||||
COVERAGE = [
|
COVERAGE = [
|
||||||
Capability(href="/humidity/0"),
|
Capability(href="/humidity/0"),
|
||||||
Capability(href="/humidity/vs/0"),
|
Capability(href="/humidity/vs/0"),
|
||||||
@@ -790,14 +651,11 @@ COVERAGE = [
|
|||||||
Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag
|
Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag
|
||||||
Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here)
|
Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here)
|
||||||
Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob
|
Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob
|
||||||
# Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/
|
# Do-not-disturb/auto-sleep schedule -- every field reads its inert
|
||||||
# useTimeSetting/functionState) -- every field reads its inert default
|
# default on the only dump seen. Needs a multi-field schedule editor,
|
||||||
# on the only dump seen (times both '00:00:00', useTimeSetting/
|
# same as fridge.py's /defrost/reservation/vs/0.
|
||||||
# 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"),
|
Capability(href="/dnd/autosleep/vs/0"),
|
||||||
# Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's
|
# Empty on the A-VTWW-TP2-21 dump (issue #151) -- this board's
|
||||||
# convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN
|
# convenient-mode equivalent lives in WIND_STRENGTH_FAN instead.
|
||||||
# above instead.
|
|
||||||
Capability(href="/mode/convenient/vs/0"),
|
Capability(href="/mode/convenient/vs/0"),
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,12 +49,10 @@ def wh_to_kwh(v):
|
|||||||
|
|
||||||
|
|
||||||
def parse_iso_utc(raw):
|
def parse_iso_utc(raw):
|
||||||
"""ISO datetime defaulting to UTC when the string carries no timezone
|
"""ISO datetime defaulting to UTC when the string carries no timezone of
|
||||||
of its own (this integration's convention for other bare ISO datetime
|
its own. A few boards ship a 'Z'/offset suffix already (fromisoformat
|
||||||
fields -- see washer.py's drum-clean-log comment). A few boards do
|
parses that natively since Python 3.11) -- only fill in UTC when parsing
|
||||||
ship a 'Z'/offset suffix (fromisoformat parses that natively since
|
left the result naive."""
|
||||||
Python 3.11) -- only fill in UTC when parsing left the result naive,
|
|
||||||
rather than unconditionally overwriting whatever offset was parsed."""
|
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -65,11 +63,8 @@ def parse_iso_utc(raw):
|
|||||||
|
|
||||||
|
|
||||||
def epoch_to_utc(value):
|
def epoch_to_utc(value):
|
||||||
"""Unix epoch seconds -> aware UTC datetime, for the boards that report a
|
"""Unix epoch seconds -> aware UTC datetime, for boards that report a
|
||||||
bare epoch rather than the ISO string parse_iso_utc handles. Lived in
|
bare epoch rather than the ISO string parse_iso_utc handles."""
|
||||||
range_hood.py as `_timestamp` until air_purifier.py needed the same reading
|
|
||||||
for /airlevelcheck/vs/0's lastSensingTime -- promoted here rather than
|
|
||||||
cross-imported, matching how filter_usage_percent was shared."""
|
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(float(value), tz=UTC)
|
return datetime.fromtimestamp(float(value), tz=UTC)
|
||||||
except (TypeError, ValueError, OSError):
|
except (TypeError, ValueError, OSError):
|
||||||
@@ -78,9 +73,8 @@ def epoch_to_utc(value):
|
|||||||
|
|
||||||
def filter_usage_percent(rep):
|
def filter_usage_percent(rep):
|
||||||
"""Filter usage as a percentage of rated capacity. Several families
|
"""Filter usage as a percentage of rated capacity. Several families
|
||||||
(AC, air purifier) report `filterUsage` as a raw count in
|
report `filterUsage` as a raw count in `filterCapacityUnit` (e.g. 100 of
|
||||||
`filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain
|
a 500-hour capacity), so a plain value with a '%' unit would be wrong.
|
||||||
value with a '%' unit would be wrong -- normalize to used/capacity.
|
|
||||||
Returns None when capacity is missing/zero."""
|
Returns None when capacity is missing/zero."""
|
||||||
used = _num(rep.get("x.com.samsung.da.filterUsage"))
|
used = _num(rep.get("x.com.samsung.da.filterUsage"))
|
||||||
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
|
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
|
||||||
@@ -92,8 +86,8 @@ def filter_usage_percent(rep):
|
|||||||
def normalize_temp_unit(raw, default="°F"):
|
def normalize_temp_unit(raw, default="°F"):
|
||||||
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
|
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
|
||||||
`default` for any other/missing value. Shared by fridge.py and oven.py,
|
`default` for any other/missing value. Shared by fridge.py and oven.py,
|
||||||
both of which read a per-device unit off a `/temperature*` resource
|
which both read a per-device unit off a `/temperature*` resource
|
||||||
instead of assuming one (see fridge.py's module docstring, issue #7)."""
|
instead of assuming one (issue #7)."""
|
||||||
raw = (raw or "").strip().upper()
|
raw = (raw or "").strip().upper()
|
||||||
if raw.startswith("C"):
|
if raw.startswith("C"):
|
||||||
return "°C"
|
return "°C"
|
||||||
@@ -108,25 +102,17 @@ def _ml_to_l(v):
|
|||||||
|
|
||||||
|
|
||||||
def _active_alarm_codes(items):
|
def _active_alarm_codes(items):
|
||||||
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted,
|
"""Join active alarm codes; skip retained rows Samsung leaves as
|
||||||
and any code ending in '_OFF'.
|
Deleted, and any code ending in '_OFF'.
|
||||||
|
|
||||||
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
|
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
|
||||||
condition clears (see WD7000B diagnostics). Surface only live alarms so
|
condition clears. Samsung also pre-populates this array with one row
|
||||||
HA doesn't stick on a stale ErrorCode.
|
per alarm *type* the board supports, each carrying its own
|
||||||
|
'<Name>_OFF' placeholder when that alarm isn't firing -- confirmed
|
||||||
Samsung pre-populates this array with one row per alarm *type* the board
|
across independent families. A firing alarm instead reports a plain,
|
||||||
supports, each carrying its own '<Name>_OFF' placeholder code when that
|
unsuffixed code (FilterAlarm, DoorA_Opened, ...); issue #166 shows both
|
||||||
alarm isn't firing -- confirmed across independent device families
|
in one dump. Generalizes what range hood used to special-case as just
|
||||||
(ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF,
|
the literal 'ErrorCode_OFF' string.
|
||||||
AC_V_0002_OFF all appear in fixtures with no corresponding active
|
|
||||||
condition). An alarm that's actually firing instead reports a plain,
|
|
||||||
unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's
|
|
||||||
AC dump has both a FilterAlarm_OFF placeholder and shows what a live
|
|
||||||
filter alert looks like: code 'FilterAlarm' (no suffix), state
|
|
||||||
'Created'. Range-hood previously special-cased only the literal
|
|
||||||
'ErrorCode_OFF' string in its own stricter helper; this generalizes
|
|
||||||
the same rule to the whole '_OFF' suffix convention.
|
|
||||||
"""
|
"""
|
||||||
if not items or not isinstance(items, list):
|
if not items or not isinstance(items, list):
|
||||||
return "none"
|
return "none"
|
||||||
@@ -145,13 +131,11 @@ def merge_options_field(cached, new_tokens):
|
|||||||
x.com.samsung.da.options[]-style array the same way the device itself
|
x.com.samsung.da.options[]-style array the same way the device itself
|
||||||
merges them: match by prefix, replace if present, append if not.
|
merges them: match by prefix, replace if present, append if not.
|
||||||
|
|
||||||
Confirmed on real hardware (issue #54) that a write only needs to carry
|
Confirmed on hardware (issue #54) that a write only needs to carry the
|
||||||
the changed token(s), not the whole array -- see laundry.option_write /
|
changed token(s), not the whole array -- see laundry.option_write /
|
||||||
oven._option_write for the write side. This is the read side of that
|
oven._option_write for the write side. coordinator.async_send_command
|
||||||
same fact: coordinator.async_send_command uses it to keep the
|
uses this read-side counterpart to keep the optimistic cache entry
|
||||||
optimistic cache entry for the written href complete (every sibling
|
complete during the write-settle window."""
|
||||||
option still present) during the write-settle window, since the wire
|
|
||||||
body it applies straight to the cache no longer carries them."""
|
|
||||||
merged = list(cached or [])
|
merged = list(cached or [])
|
||||||
for token in new_tokens or ():
|
for token in new_tokens or ():
|
||||||
if not isinstance(token, str) or "_" not in token:
|
if not isinstance(token, str) or "_" not in token:
|
||||||
@@ -169,18 +153,15 @@ def merge_options_field(cached, new_tokens):
|
|||||||
|
|
||||||
def merge_items_field(cached, new_items):
|
def merge_items_field(cached, new_items):
|
||||||
"""Merge a partial x.com.samsung.da.items[]-style write (matched by
|
"""Merge a partial x.com.samsung.da.items[]-style write (matched by
|
||||||
x.com.samsung.da.id) into a cached items array -- the read-side
|
x.com.samsung.da.id) into a cached items array -- the items[]
|
||||||
counterpart of merge_options_field above, for the items[] shape instead
|
counterpart of merge_options_field above.
|
||||||
of the packed options[] shape.
|
|
||||||
|
|
||||||
Confirmed on hardware that a write only needs to carry the array item
|
Confirmed on hardware that a write only needs to carry the item with
|
||||||
with the changed id plus the field(s) being changed; the device merges
|
the changed id plus the field(s) being changed (see
|
||||||
the rest itself (same fact as the options[] case, different array --
|
airconditioner._climate_write's vendor temperature write). Fields
|
||||||
see airconditioner._climate_write's vendor temperature write). Fields
|
|
||||||
within the matched item are merged, not replaced outright, so a
|
within the matched item are merged, not replaced outright, so a
|
||||||
setpoint-only write doesn't wipe current/minimum/maximum/unit from the
|
setpoint-only write doesn't wipe current/minimum/maximum/unit from the
|
||||||
optimistic cache entry for the settle window. An id with no match in
|
optimistic cache entry. An id with no match in `cached` is appended."""
|
||||||
`cached` is appended."""
|
|
||||||
merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])]
|
merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])]
|
||||||
for new_item in new_items or ():
|
for new_item in new_items or ():
|
||||||
if not isinstance(new_item, dict):
|
if not isinstance(new_item, dict):
|
||||||
@@ -196,22 +177,19 @@ def merge_items_field(cached, new_items):
|
|||||||
|
|
||||||
|
|
||||||
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
|
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
|
||||||
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
|
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for
|
||||||
# life of a given board, so reading them from the /device/0 seed (no dedicated
|
# the life of a board, so reading it from the /device/0 seed is enough.
|
||||||
# poll_tier) is enough.
|
|
||||||
_SETINFO_HREF = "/wm/setinfo/vs/0"
|
_SETINFO_HREF = "/wm/setinfo/vs/0"
|
||||||
_POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff"
|
_POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff"
|
||||||
_WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC"
|
_WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC"
|
||||||
|
|
||||||
|
|
||||||
def model_allows_power_on_off(resources: dict) -> bool:
|
def model_allows_power_on_off(resources: dict) -> bool:
|
||||||
"""True unless firmware explicitly declares remote power on/off unsupported.
|
"""True unless firmware explicitly declares remote power on/off
|
||||||
|
unsupported. isModelSettingPowerOnOff is "false" on many laundry
|
||||||
`/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry
|
boards: /power/0 and /power/vs/0 still report state, but CoAP writes
|
||||||
boards (washers/dryers): `/power/0` and `/power/vs/0` still report state,
|
are ignored. Absent setinfo (non-laundry families) keeps the writable
|
||||||
but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps
|
switch."""
|
||||||
the writable switch -- current behavior.
|
|
||||||
"""
|
|
||||||
setinfo = resources.get(_SETINFO_HREF)
|
setinfo = resources.get(_SETINFO_HREF)
|
||||||
if setinfo is None:
|
if setinfo is None:
|
||||||
return True
|
return True
|
||||||
@@ -222,13 +200,11 @@ def model_allows_power_on_off(resources: dict) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def model_setting_without_sc(resources: dict) -> bool:
|
def model_setting_without_sc(resources: dict) -> bool:
|
||||||
"""True when firmware declares settings writable without Smart Control.
|
"""True when firmware declares settings writable without Smart
|
||||||
|
Control. isModelSettingWithoutSC is "true" on washers/dryers that
|
||||||
`/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers
|
accept temperature/spin/cycle-option writes while remote control is
|
||||||
that accept temperature/spin/cycle-option writes while remote control is
|
off; cycle start/pause/stop still need Smart Control on those
|
||||||
off. Cycle start/pause/stop still need Smart Control on those boards --
|
boards."""
|
||||||
the flag name is settings-specific, not a blanket remote-control bypass.
|
|
||||||
"""
|
|
||||||
setinfo = resources.get(_SETINFO_HREF) or {}
|
setinfo = resources.get(_SETINFO_HREF) or {}
|
||||||
return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true"
|
return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true"
|
||||||
|
|
||||||
@@ -243,11 +219,10 @@ def _power_sensor_exists(rep, resources):
|
|||||||
|
|
||||||
def sensor_item_value(items, sensor_type, index=0):
|
def sensor_item_value(items, sensor_type, index=0):
|
||||||
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
|
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
|
||||||
item is `{type, value: [...]}`; `index` picks which slot of a possibly
|
item is `{type, value: [...]}`; `index` picks which slot to read
|
||||||
multi-value reading to read (index 0 is the raw measurement on every
|
(index 0 is the raw measurement on every family seen so far). Shared
|
||||||
family seen so far). Shared by range_hood.AIR_QUALITY,
|
by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and
|
||||||
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
|
air_monitor.SENSORS, which all read the same resource shape."""
|
||||||
same resource shape against the same {type, sensor_type} keys."""
|
|
||||||
for item in items or ():
|
for item in items or ():
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
@@ -262,26 +237,18 @@ def sensor_item_value(items, sensor_type, index=0):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
|
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
|
||||||
#
|
# control: each exists as both a standard OCF resource (/power/0,
|
||||||
# These three controls exist as both a standard OCF resource (/power/0,
|
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
|
||||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource
|
# resource (/power/vs/0, x.com.samsung.da.power), since Samsung advertises
|
||||||
# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its
|
# both while its firmware migrates onto the OCF standard model. Prefer the
|
||||||
# firmware migrates onto the OCF standard model. Prefer the OCF-standard href
|
# OCF-standard href when present; the '-vs' href binds only when it's
|
||||||
# when the device exposes it; the '-vs' href (a string-encoded duplicate for
|
# absent, via match_fn. Older firmware has only the '-vs' resource. See
|
||||||
# these three) binds only when the generic href is absent, via match_fn. Older
|
# the adding-device-support skill's "OCF-standard vs vendor" section.
|
||||||
# firmware has only the '-vs' resource, so the pair is behaviour-identical to a
|
# Every device registry lists both caps of each pair.
|
||||||
# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs
|
|
||||||
# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket
|
|
||||||
# choice. Every device registry lists both caps of each pair.
|
|
||||||
|
|
||||||
POWER_GENERIC = Capability(
|
POWER_GENERIC = Capability(
|
||||||
href="/power/0",
|
href="/power/0",
|
||||||
# Neither href of this pair carried a poll_tier before (issue #56's
|
|
||||||
# follow-up), so power state only ever refreshed on the once-per-30s
|
|
||||||
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
|
|
||||||
# hrefs get -- the same "signal drives real-time state, but sat in the
|
|
||||||
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
|
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
# Writable when firmware allows remote power; otherwise a read-only
|
# Writable when firmware allows remote power; otherwise a read-only
|
||||||
@@ -331,16 +298,14 @@ POWER_VS_FALLBACK = Capability(
|
|||||||
KIDS_LOCK_GENERIC = Capability(
|
KIDS_LOCK_GENERIC = Capability(
|
||||||
href="/kidslock/0",
|
href="/kidslock/0",
|
||||||
entities=(
|
entities=(
|
||||||
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
|
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183): HA's
|
||||||
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
|
# switch platform never honored SwitchDesc's device_class='lock'
|
||||||
# by HA (its switch platform only accepts 'outlet'/'switch'),
|
# ('outlet'/'switch' only), leaving a plain switch whose 'On' meant
|
||||||
# leaving a plain switch whose 'On' state meant different things
|
# different things on different boards. As a BinarySensorDesc with
|
||||||
# on different boards. As a BinarySensorDesc with `device_class='lock'`,
|
# device_class='lock', both surfaces read with the same polarity
|
||||||
# both kids-lock surfaces read with the same polarity: 'On' means
|
# ('On' = open/unlocked, per HA convention); value_fn here inverts
|
||||||
# open/unlocked, per HA's lock device_class. The inversion in
|
# the wire value to match (value=False on /kidslock/0 means kids
|
||||||
# value_fn here (and in the fallback below) keeps the on-the-wire
|
# lock is NOT active).
|
||||||
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
|
|
||||||
# both mean kids lock NOT active) consistent with that polarity.
|
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
|
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
|
||||||
),
|
),
|
||||||
@@ -351,16 +316,11 @@ KIDS_LOCK_VS_FALLBACK = Capability(
|
|||||||
href="/kidslock/vs/0",
|
href="/kidslock/vs/0",
|
||||||
match_fn=lambda rep, resources: "/kidslock/0" not in resources,
|
match_fn=lambda rep, resources: "/kidslock/0" not in resources,
|
||||||
entities=(
|
entities=(
|
||||||
# Read-only, not a SwitchDesc (issues #181/#183): the write side of
|
# Read-only, not a SwitchDesc (issues #181/#183): the old write
|
||||||
# this capability wrote 'Enable', a value no dump in the fixture
|
# side wrote 'Enable', a value no dump ever reports back (every one
|
||||||
# corpus has ever reported back -- every one reports either 'Ready'
|
# is 'Ready' or 'Run'), and #181's reporter confirmed writing the
|
||||||
# or 'Run', so it was never a confirmed contract. #181's reporter
|
# correct value ('Run') still 4.05s -- genuinely read-only on this
|
||||||
# confirmed this directly: writing the *correct* value ('Run')
|
# hardware. Polarity matches KIDS_LOCK_GENERIC ('On' = unlocked).
|
||||||
# still 4.05s, and the SmartThings app itself has no control for
|
|
||||||
# it either -- the resource is genuinely read-only on this
|
|
||||||
# hardware, not just wrong-valued. Polarity matches
|
|
||||||
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
|
|
||||||
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
|
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key="child_lock",
|
key="child_lock",
|
||||||
field="x.com.samsung.da.kidsLock",
|
field="x.com.samsung.da.kidsLock",
|
||||||
@@ -373,15 +333,11 @@ KIDS_LOCK_VS_FALLBACK = Capability(
|
|||||||
|
|
||||||
def remote_control_enabled(resources: dict) -> bool:
|
def remote_control_enabled(resources: dict) -> bool:
|
||||||
"""Single source of truth for the /remotectrl on/off signal, mirroring
|
"""Single source of truth for the /remotectrl on/off signal, mirroring
|
||||||
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence
|
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field precedence. Used both
|
||||||
below. Used both to render the read-only Smart Control binary_sensor
|
to render the read-only Smart Control binary_sensor and, from
|
||||||
(via those two descriptors) and, from coordinator.async_send_command,
|
coordinator.async_send_command, to block writes when remote control is
|
||||||
to block writes outright when remote control is off. Both hrefs are
|
off. True (assume enabled) when neither href is present -- most device
|
||||||
poll_tier='warm' below so that gate reads recent state (subscribed
|
types don't report this capability at all."""
|
||||||
when observe is live, subpolled every ~6s otherwise) rather than a
|
|
||||||
once-per-30s cold summary poll. True (assume enabled) when neither
|
|
||||||
href is present -- most device types don't report this capability
|
|
||||||
at all."""
|
|
||||||
generic = resources.get("/remotectrl/0")
|
generic = resources.get("/remotectrl/0")
|
||||||
if generic is not None:
|
if generic is not None:
|
||||||
return bool(generic.get("value"))
|
return bool(generic.get("value"))
|
||||||
@@ -446,26 +402,21 @@ ALARMS = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and
|
# instantaneousPower is a dead field on DA_WM_-class laundry dumps and
|
||||||
# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500',
|
# dishwashers: the literal sentinel '-500', unchanged across off/idle/
|
||||||
# unchanged across off/idle/running. clamp_power floors it to a misleading
|
# running. clamp_power would floor it to a misleading "0 W". Gate
|
||||||
# "0 W" that reads as a real idle measurement. Gate power_watts out when the
|
# power_watts out when the sentinel is seen, but only then, so a device
|
||||||
# sentinel is seen -- but only then, so a device reporting a real value (e.g. a
|
# reporting a real value (e.g. a fridge's 93 W) still shows it (issue #6).
|
||||||
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
|
|
||||||
# least one washer model; the exists_fn makes that explicit rather than relying
|
|
||||||
# on the generic field-presence gate.
|
|
||||||
_DEAD_INSTANTANEOUS_POWER = "-500"
|
_DEAD_INSTANTANEOUS_POWER = "-500"
|
||||||
|
|
||||||
ENERGY_METER = Capability(
|
ENERGY_METER = Capability(
|
||||||
href="/energy/consumption/vs/0",
|
href="/energy/consumption/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
|
# is_stub_rep(rep) keeps the stub carve-out (see
|
||||||
# an explicit exists_fn otherwise bypasses it, which would drop the
|
# entity._is_included): an explicit exists_fn otherwise bypasses
|
||||||
# entity when /device/0 returns a not-yet-fetched stub. A genuinely
|
# it and would drop the entity when /device/0 returns a
|
||||||
# empty {} rep is NOT a stub -- it's the device's confirmed (if empty)
|
# not-yet-fetched stub. A genuinely empty {} rep is NOT a stub, so
|
||||||
# answer, so it falls through to the normal field/sentinel checks like
|
# it still falls through to the normal field/sentinel checks.
|
||||||
# any populated rep. On a populated rep, hide power only for the dead
|
|
||||||
# sentinel or an absent field.
|
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key="power_watts",
|
key="power_watts",
|
||||||
field="x.com.samsung.da.instantaneousPower",
|
field="x.com.samsung.da.instantaneousPower",
|
||||||
@@ -493,11 +444,7 @@ ENERGY_METER = Capability(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
# cumulativeConsumption is a second, independently-varying running
|
# cumulativeConsumption is a second, independently-varying running
|
||||||
# total alongside cumulativePower -- some fridges (issue #26) report
|
# total some fridges (issue #26) report alongside cumulativePower.
|
||||||
# both. Self-gates off where only cumulativePower is present. The
|
|
||||||
# `is_stub_rep(rep) or` keeps the same stub carve-out as power_watts/
|
|
||||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
|
||||||
# entity if setup happens to land on a not-yet-fetched stub.
|
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key="power_energy_kwh",
|
key="power_energy_kwh",
|
||||||
field="x.com.samsung.da.cumulativeConsumption",
|
field="x.com.samsung.da.cumulativeConsumption",
|
||||||
@@ -510,8 +457,8 @@ ENERGY_METER = Capability(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
||||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
# baseline -- present on some models (issue #21/#27), absent on
|
||||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
# others (issue #20/#26).
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key="energy_saved_kwh",
|
key="energy_saved_kwh",
|
||||||
field="x.com.samsung.da.cumulativeSavedPower",
|
field="x.com.samsung.da.cumulativeSavedPower",
|
||||||
@@ -523,9 +470,8 @@ ENERGY_METER = Capability(
|
|||||||
is_stub_rep(rep) or "x.com.samsung.da.cumulativeSavedPower" in rep
|
is_stub_rep(rep) or "x.com.samsung.da.cumulativeSavedPower" in rep
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# Monthly billing-cycle totals -- the completed prior month and the
|
# Monthly billing-cycle totals -- completed prior month and
|
||||||
# in-progress current month. Not ever-increasing (each resets at
|
# in-progress current month. Not ever-increasing, so no state_class.
|
||||||
# month boundary), so no state_class.
|
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key="energy_last_month_kwh",
|
key="energy_last_month_kwh",
|
||||||
field="x.com.samsung.da.monthlyConsumption",
|
field="x.com.samsung.da.monthlyConsumption",
|
||||||
@@ -586,26 +532,21 @@ WATER_FILTER = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
|
# AI energy-saving level -- '0' is off, supportedAiLevel lists the
|
||||||
# additional level(s) the device offers ('1' meaning just "on" on most
|
# additional level(s) offered ('1' meaning just "on" on most hardware,
|
||||||
# hardware, but multi-level boards have been reported). Verified cross-family:
|
# multi-level on some). Verified cross-family: fridge (issue #21) and
|
||||||
# fridge (issue #21) and washer (issue #40) both expose this href.
|
# washer (issue #40). Most hardware's supportedAiLevel is a single-entry
|
||||||
#
|
# list, so a select there would offer only one real choice against an
|
||||||
# supportedAiLevel is a single-entry list on most captured hardware, where a
|
# implicit "off" -- shown as a switch instead; '0' is never in
|
||||||
# select would offer only one real choice against an implicit "off" -- shown
|
# supportedAiLevel but is the observed off value, so the select
|
||||||
# as a switch instead. '0' itself is never in supportedAiLevel but has been
|
# synthesizes it back in as an explicit option. No translation_key:
|
||||||
# observed live as the off value of aiLevel, so the select synthesizes it
|
# aiLevel's values are plain digit strings, and select.py already renders
|
||||||
# back in as an explicit option rather than leaving no way to turn off.
|
# an untranslated numeric string as-is.
|
||||||
#
|
|
||||||
# No translation_key: aiLevel's values are plain digit strings, and
|
|
||||||
# select.py's _display() already renders an untranslated numeric string
|
|
||||||
# as-is -- there's nothing a catalog entry adds that's worth maintaining
|
|
||||||
# against an unknown, growing number of future levels.
|
|
||||||
|
|
||||||
|
|
||||||
def _ai_energy_supported_levels(rep):
|
def _ai_energy_supported_levels(rep):
|
||||||
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
|
"""supportedAiLevel as a list -- a stray scalar must not be
|
||||||
be len()-checked as if it were a list."""
|
len()-checked as if it were one."""
|
||||||
sl = rep.get("supportedAiLevel")
|
sl = rep.get("supportedAiLevel")
|
||||||
return list(sl) if isinstance(sl, (list, tuple)) else []
|
return list(sl) if isinstance(sl, (list, tuple)) else []
|
||||||
|
|
||||||
@@ -630,21 +571,15 @@ AI_ENERGY_LEVEL = Capability(
|
|||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
# No is_stub_rep carve-out on either side, unlike most exists_fn
|
# No is_stub_rep carve-out on either side, unlike most exists_fn
|
||||||
# gates in this file -- entity creation only ever runs once, against
|
# gates in this file: entity creation runs once against whichever
|
||||||
# whichever snapshot happens to be current the moment platforms are
|
# snapshot is current at platform setup, while flatten() re-checks
|
||||||
# set up (see entity._is_included / __init__.py's
|
# exists_fn every poll against live data. Both descriptors share
|
||||||
# async_config_entry_first_refresh-before-forward-entry-setups
|
# key='ai_energy_level' -- a stub carve-out could let one win at
|
||||||
# ordering), while flatten() re-evaluates exists_fn every poll
|
# setup and the other win once real data lands, feeding the
|
||||||
# against live data. Both descriptors share key='ai_energy_level',
|
# instantiated entity a value shaped for the other platform.
|
||||||
# so if a stub carve-out let one of them win at setup time while the
|
# Requiring populated data on both sides keeps the two decisions in
|
||||||
# other wins once real data lands, flatten() would feed the
|
# permanent agreement, at the cost of the entity not appearing
|
||||||
# instantiated entity a value shaped for the other platform (e.g. a
|
# until a reload if the first poll stubs this cold-tier href.
|
||||||
# bool into a Select). Requiring real, populated data on both sides
|
|
||||||
# keeps the entity-creation decision and the live-value decision in
|
|
||||||
# permanent agreement -- the cost is this entity doesn't appear
|
|
||||||
# until a reload if the device's very first poll stubs this
|
|
||||||
# cold-tier href, the same reload already required to fix which
|
|
||||||
# platform got picked in that case.
|
|
||||||
SwitchDesc(
|
SwitchDesc(
|
||||||
key="ai_energy_level",
|
key="ai_energy_level",
|
||||||
field="aiLevel",
|
field="aiLevel",
|
||||||
@@ -720,37 +655,27 @@ SELF_CHECK = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cross-family bundles, unpacked into every by_type registry's _build([...])
|
# Cross-family bundles, unpacked into every by_type registry's _build([...])
|
||||||
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
|
# call the same way ignored.IGNORED is. discover() only binds a capability
|
||||||
# discover() only binds a capability whose href is actually present in a
|
# whose href is actually present in a given device's dump, so listing one
|
||||||
# given device's resource dump, so listing one here for a family that
|
# here for a family that doesn't expose the href is a no-op, not a phantom
|
||||||
# doesn't expose the href is a no-op, not a phantom entity -- see the
|
# entity -- see the adding-device-support skill's coverage-discipline
|
||||||
# adding-device-support skill's coverage-discipline section.
|
# section.
|
||||||
#
|
#
|
||||||
# UNIVERSAL holds every capability with no known family that both (a) has
|
# UNIVERSAL holds every capability with no known family that both has the
|
||||||
# the href and (b) needs to model it some other way -- broadening one of
|
# href and needs to model it some other way.
|
||||||
# these to a new family is a safe, harmless guess (issue #40's AI energy
|
|
||||||
# level: 2 of 6 families confirmed, blanket-added everywhere else).
|
|
||||||
#
|
#
|
||||||
# POWER is kept separate -- airconditioner is the one family that opts out
|
# POWER is kept separate: airconditioner opts out of it entirely, since
|
||||||
# of it entirely. Canonical reason (see by_type/airconditioner.py and its
|
# its climate entity already owns /power/0 and /power/vs/0 via bare
|
||||||
# test for pointers back here, not restatements): AC's climate entity
|
# no-entity Capability objects (airconditioner.COVERAGE), and a second
|
||||||
# already owns /power/0 and /power/vs/0 via bare, no-entity Capability
|
# real cap on the same href would make _build() raise (see
|
||||||
# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
# by_type/airconditioner.py). Kids-lock/remote-control have no such
|
||||||
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
|
# conflict, so they stay in UNIVERSAL.
|
||||||
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
|
|
||||||
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
|
|
||||||
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
|
|
||||||
# in UNIVERSAL.
|
|
||||||
#
|
#
|
||||||
# Airconditioner also partially opts out of UNIVERSAL itself, not just
|
# Airconditioner also partially opts out of UNIVERSAL itself: issue #193
|
||||||
# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by
|
# needs ENERGY_METER's cumulativePower scale to differ by board
|
||||||
# board generation, so by_type/airconditioner.py excludes just that one
|
# generation, so by_type/airconditioner.py excludes just that one member
|
||||||
# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`)
|
# and substitutes its own ENERGY_METER_GENERIC/ENERGY_METER_LEGACY.
|
||||||
# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in
|
|
||||||
# its place -- every other registry still unpacks UNIVERSAL wholesale.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
UNIVERSAL = (
|
UNIVERSAL = (
|
||||||
ALARMS,
|
ALARMS,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from .common import int_or_none
|
|||||||
|
|
||||||
def _first_mode(rep):
|
def _first_mode(rep):
|
||||||
"""Representative scalar for the operating-mode select. `modes` is a
|
"""Representative scalar for the operating-mode select. `modes` is a
|
||||||
single-element list on every dump seen so far, mirroring
|
single-element list on every dump seen, mirroring
|
||||||
airconditioner._first_mode's handling of the same field shape."""
|
airconditioner._first_mode."""
|
||||||
modes = rep.get("x.com.samsung.da.modes")
|
modes = rep.get("x.com.samsung.da.modes")
|
||||||
if isinstance(modes, (list, tuple)):
|
if isinstance(modes, (list, tuple)):
|
||||||
return modes[0] if modes else None
|
return modes[0] if modes else None
|
||||||
@@ -40,14 +40,11 @@ MODE = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Target humidity is this device's primary control (the issue-#88 dump's
|
# Target humidity is this device's primary control (issue #88's equivalent
|
||||||
# equivalent of a thermostat setpoint). No min/max range field is present in
|
# of a thermostat setpoint). No min/max range field is present in any dump
|
||||||
# any dump seen so far -- native_min/native_max are deliberately left unset
|
# seen, so native_min/native_max are left unset, falling back to HA's own
|
||||||
# so the number entity falls back to HA's own 0-100 default, the natural
|
# 0-100 default rather than a bound guessed from one unit's spec sheet.
|
||||||
# bound for a percentage field, rather than a bound guessed from one unit's
|
# Step comes live from the device's own `increment` field.
|
||||||
# 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.
|
|
||||||
HUMIDITY = Capability(
|
HUMIDITY = Capability(
|
||||||
href="/humidity/vs/0",
|
href="/humidity/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -77,15 +74,13 @@ HUMIDITY = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Water-tank ambient light (issues #271/#231, TP1X_DA_AC_DHM_01001_0000):
|
# Water-tank ambient light (issues #271/#231): on/off, color, and
|
||||||
# on/off, color, and brightness are three independent controls on this one
|
# brightness are three independent controls on this one resource.
|
||||||
# resource. `waterfullAlarmStatus` differs between the two dumps that
|
# `waterfullAlarmStatus` differs between the two dumps that reported this
|
||||||
# reported this href (On vs. Off) so it's a real live flag, not a constant --
|
# href, so it's a real live flag, but its exact meaning (tank full vs. the
|
||||||
# but its exact meaning (tank actually full vs. the chime feature merely
|
# chime feature merely enabled) isn't confirmed, and /alarms/vs/0 already
|
||||||
# enabled) isn't confirmed by either dump alone, and /alarms/vs/0's
|
# surfaces a live WaterTankFull condition -- exposed read-only as a plain
|
||||||
# alarm_code already surfaces a live WaterTankFull condition when one fires
|
# diagnostic rather than guessed at as a binary_sensor.
|
||||||
# (see common._active_alarm_codes), so this is exposed read-only as a plain
|
|
||||||
# diagnostic value rather than guessed at as a binary_sensor.
|
|
||||||
WATERTANK_LIGHTING = Capability(
|
WATERTANK_LIGHTING = Capability(
|
||||||
href="/watertank/lighting/vs/0",
|
href="/watertank/lighting/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -131,13 +126,10 @@ WATERTANK_LIGHTING = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable
|
||||||
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state
|
# state, following the same rule as airconditioner._AC_IGNORED (same
|
||||||
# or no documented write contract, following the same 'don't guess' rule as
|
# DA_AC_ board family). Not in the global ignored.IGNORED since some hrefs
|
||||||
# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in
|
# collide with other families' schemas.
|
||||||
# the global ignored.IGNORED since some of these hrefs collide with other
|
|
||||||
# families' schemas.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_DHM_IGNORED = [
|
_DHM_IGNORED = [
|
||||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
|
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
|
||||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||||
|
|||||||
@@ -41,26 +41,16 @@ DISHWASHER_SETTINGS = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# /course/vs/0 -- cycle selection (shared laundry.cycle_select) plus the
|
||||||
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
|
# dishwasher-only StormWashZone / AutoDoorRelease toggles riding in the same
|
||||||
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
|
# options array (shared laundry.bool_option_switch). Course display names
|
||||||
# same options array (shared laundry.bool_option_switch, same options[]
|
# live in translations under entity.select.dishwasher_cycle.
|
||||||
# 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).
|
|
||||||
#
|
#
|
||||||
# '83'/'86' were transposed in that catalog until issue #226: both the
|
# '83'/'86' were transposed in that catalog until issue #226: the original
|
||||||
# original DW9000F-class fixture this table was built from and the issue
|
# fixture's own live editCourseList puts them back to back, exactly the
|
||||||
# #226 reporter's board report the identical DeviceType_0812 (a real
|
# kind of adjacent pair a manual screenshot transcription slips on. The
|
||||||
# per-board-generation id also seen on unrelated washer/dryer fixtures, so
|
# reporter's live confirmation (selecting 'Normal' ran the physical Express
|
||||||
# this is one shared course table, not a Table_02/Table_03-style generation
|
# 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
|
||||||
# split), and the original fixture's own live editCourseList
|
|
||||||
# ('EditCourseList_0E07908683848D808E8F') puts '86' and '83' back to back at
|
|
||||||
# positions 4-5 -- exactly the kind of adjacent pair a manual screenshot
|
|
||||||
# transcription slips on. The reporter's live confirmation (selecting
|
|
||||||
# 'Normal' ran the physical Express 60 program and vice versa) settles
|
|
||||||
# which way: '86' is Express 60, '83' is Normal.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
CYCLE_OPTIONS = Capability(
|
CYCLE_OPTIONS = Capability(
|
||||||
href="/course/vs/0",
|
href="/course/vs/0",
|
||||||
|
|||||||
@@ -38,27 +38,19 @@ DRYER_SETTINGS = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
|
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
|
||||||
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
|
# laundry.cycle_select. Course display names live in translations under
|
||||||
# an RMW on the options array). Course display names live in translations
|
# entity.select.dryer_cycle (Table_03, DV5000-class). Codes '01' Normal and
|
||||||
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
|
# '06' Time dry were confirmed on a DVE50A8600V/A3 by selecting each cycle
|
||||||
# 2026-05-29). Codes '01' Normal and '06' Time dry were confirmed on a
|
# on the appliance and reading back the raw code (issue #80); '51' Eco
|
||||||
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
|
# Cotton, '53' AI Dry+, and '4e' Self Dry the same way on a DV90DG6845LHU5
|
||||||
# appliance and reading back the raw code from the entity's state (issue
|
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
|
||||||
# #80). Codes '51' Eco Cotton, '53' AI Dry+, and '4e' Self Dry were
|
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
|
||||||
# confirmed the same way on a DV90DG6845LHU5 (issue #244). The
|
|
||||||
# /st/dryercourse/vs/0 resource re-encodes the same selected course and is
|
|
||||||
# ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 is ignored
|
|
||||||
# for washers.
|
|
||||||
#
|
#
|
||||||
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
||||||
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
||||||
# options[] array -- see laundry.drum_clean_cycles_remaining/
|
# options[] array -- see laundry.drum_clean_cycles_remaining/
|
||||||
# drum_clean_last_cleaned's docstrings for the field contract, including
|
# drum_clean_last_cleaned. No separate heat-exchanger-clean tracking was
|
||||||
# the dryer-specific '|'-joined multi-entry DrumCleanLog_ shape. No
|
# found on either dump #258 supplied, so if the app surfaces that reminder,
|
||||||
# separate heat-exchanger-clean tracking was found on either dump #258
|
|
||||||
# supplied (DV90BB7445GES7, DV91T6440LE/SA) -- no HeatExchanger*-prefixed
|
|
||||||
# token, nor any other options[] entry that looks like a second maintenance
|
|
||||||
# counter -- so if the Samsung app surfaces that reminder for these units,
|
|
||||||
# it isn't computed from anything this integration can read locally.
|
# it isn't computed from anything this integration can read locally.
|
||||||
DRYER_COURSE = Capability(
|
DRYER_COURSE = Capability(
|
||||||
href="/course/vs/0",
|
href="/course/vs/0",
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
|
|||||||
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
|
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.
|
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
|
||||||
|
|
||||||
zone1 has no HA platform with matching semantics (it's a leaving-water-
|
zone1 has no HA platform with matching semantics (a leaving-water-
|
||||||
temperature setpoint, not a thermostat with HVAC modes airconditioner.py's
|
temperature setpoint, not a thermostat with HVAC modes), so it stays
|
||||||
climate.py would fit), so it stays switch/select/number/sensor -- same shape
|
switch/select/number/sensor -- same shape as dehumidifier.py's power/mode/
|
||||||
as dehumidifier.py's power/mode/humidity split. dhw is a real HA
|
humidity split. dhw is a real HA water_heater.py entity (see DHW below),
|
||||||
water_heater.py -- see DHW below and water_heater.py's module docstring --
|
|
||||||
following the same primary-resource-plus-sibling-reads pattern as
|
following the same primary-resource-plus-sibling-reads pattern as
|
||||||
airconditioner.py's CLIMATE/climate.py.
|
airconditioner.py's CLIMATE/climate.py.
|
||||||
|
|
||||||
@@ -35,8 +34,7 @@ def _num(v):
|
|||||||
|
|
||||||
def _first_mode(rep):
|
def _first_mode(rep):
|
||||||
"""Representative scalar for a mode select -- `modes` is a single-element
|
"""Representative scalar for a mode select -- `modes` is a single-element
|
||||||
list on every dump seen so far, mirroring airconditioner._first_mode /
|
list on every dump seen, mirroring airconditioner._first_mode."""
|
||||||
dehumidifier._first_mode's handling of the same field shape."""
|
|
||||||
modes = rep.get("x.com.samsung.da.modes")
|
modes = rep.get("x.com.samsung.da.modes")
|
||||||
if isinstance(modes, (list, tuple)):
|
if isinstance(modes, (list, tuple)):
|
||||||
return modes[0] if modes else None
|
return modes[0] if modes else None
|
||||||
@@ -48,14 +46,9 @@ def _temp_unit(rep):
|
|||||||
|
|
||||||
|
|
||||||
def _bounds(rep, default_min, default_max):
|
def _bounds(rep, default_min, default_max):
|
||||||
"""The resource's own (minimum, maximum) pair, or the defaults.
|
"""The resource's own (minimum, maximum) pair, or the defaults. Both
|
||||||
|
ends together or neither -- a board reporting only one would otherwise
|
||||||
Both ends together or neither -- a board reporting only one would
|
pair a real bound with an invented default, silently wrong."""
|
||||||
otherwise pair a real device bound with an invented default, which
|
|
||||||
looks plausible and is silently wrong. Same rule as
|
|
||||||
climate._range()/water_heater._range(), and the same reason
|
|
||||||
oven._setpoint_bounds resolves its pair in one place.
|
|
||||||
"""
|
|
||||||
lo = _num(rep.get("x.com.samsung.da.minimum"))
|
lo = _num(rep.get("x.com.samsung.da.minimum"))
|
||||||
hi = _num(rep.get("x.com.samsung.da.maximum"))
|
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)
|
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
|
||||||
@@ -102,8 +95,8 @@ ZONE_MODE = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
|
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
|
||||||
# room setpoint, not a literal water temperature -- Samsung EHS zone control
|
# room setpoint, not a literal water temperature -- EHS zone control is
|
||||||
# is leaving-water-temperature-based, same convention as the dhw loop below.
|
# leaving-water-temperature-based, same convention as the dhw loop below.
|
||||||
ZONE_TEMPERATURE = Capability(
|
ZONE_TEMPERATURE = Capability(
|
||||||
href="/temperatures/indoor/vs/0",
|
href="/temperatures/indoor/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -134,12 +127,10 @@ ZONE_TEMPERATURE = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Canonical dhw resource hrefs. water_heater.py binds the primary HREF_DHW_MODE
|
# Canonical dhw resource hrefs. water_heater.py binds HREF_DHW_MODE via DHW
|
||||||
# via DHW below and reads the sibling power/temperature hrefs off the
|
# below and reads the sibling power/temperature hrefs off the coordinator
|
||||||
# coordinator snapshot -- same primary-plus-siblings shape as
|
# snapshot -- same primary-plus-siblings shape as airconditioner.py's
|
||||||
# airconditioner.py's HREF_MODE/CLIMATE_CONSUMED_HREFS. Declared once here
|
# HREF_MODE/CLIMATE_CONSUMED_HREFS.
|
||||||
# and imported by water_heater.py, so a new sibling read can't drift out of
|
|
||||||
# sync with its DHW_CONSUMED_HREFS coverage entry below.
|
|
||||||
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
|
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
|
||||||
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
|
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
|
||||||
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
|
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
|
||||||
@@ -150,8 +141,7 @@ DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
|
|||||||
def _dhw_write(payload, rep, href=None):
|
def _dhw_write(payload, rep, href=None):
|
||||||
"""Map a (kind, value) command from the water_heater platform to the
|
"""Map a (kind, value) command from the water_heater platform to the
|
||||||
(path_segs, body) for that one sub-write -- same contract as
|
(path_segs, body) for that one sub-write -- same contract as
|
||||||
airconditioner._climate_write, just across the dhw loop's three
|
airconditioner._climate_write, across the dhw loop's three resources."""
|
||||||
resources instead of the AC's power/mode/temperature/wind set."""
|
|
||||||
kind, value = payload
|
kind, value = payload
|
||||||
if kind == "power":
|
if kind == "power":
|
||||||
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
||||||
@@ -174,19 +164,15 @@ DHW = Capability(
|
|||||||
|
|
||||||
# Power and temperature are read by the composite DHW entity above, not
|
# Power and temperature are read by the composite DHW entity above, not
|
||||||
# given their own entities -- coverage-only caps so discover() reports no
|
# given their own entities -- coverage-only caps so discover() reports no
|
||||||
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS for the same pattern).
|
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS).
|
||||||
DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS]
|
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.
|
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
|
||||||
# HA core's smartthings water_heater does wire this same Samsung capability
|
# HA core's smartthings integration wires this same Samsung capability up
|
||||||
# (CUSTOM_OUTING_MODE) up to WaterHeaterEntityFeature.AWAY_MODE, and the DHW
|
# to WaterHeaterEntityFeature.AWAY_MODE, so the divergence is worth
|
||||||
# operation-mode map above is taken from that integration -- so the
|
# stating: /option/outgoing/vs/0 is device-wide (one `away` flag covering
|
||||||
# divergence is worth stating. /option/outgoing/vs/0 is device-wide: one
|
# zone1 too, with no dhw-scoped sibling href). Hanging it off the DHW card
|
||||||
# `away` flag covering the whole unit, zone1 included (it has no dhw-scoped
|
# would present a device-wide setting as hot-water-only.
|
||||||
# sibling href, unlike every other resource in this loop). Hanging it off
|
|
||||||
# the DHW card would present a device-wide setting as if it only affected
|
|
||||||
# hot water. It stays a switch until a board turns up with a per-loop away
|
|
||||||
# resource to bind instead.
|
|
||||||
AWAY_MODE = Capability(
|
AWAY_MODE = Capability(
|
||||||
href="/option/outgoing/vs/0",
|
href="/option/outgoing/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -205,13 +191,9 @@ AWAY_MODE = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# EHS-scoped coverage: opaque vendor plumbing or resources with no
|
||||||
# EHS-scoped coverage: opaque vendor plumbing (hex-encoded factory/cycle/
|
# confirmed write contract on this dump. Not in the global ignored.IGNORED
|
||||||
# schedule blobs) or resources with no confirmed write contract on this
|
# since these are EHS-only shapes needing their own verification elsewhere.
|
||||||
# dump, following the same 'don't guess' rule as dehumidifier._DHM_IGNORED.
|
|
||||||
# Not in the global ignored.IGNORED since these are EHS-only shapes that
|
|
||||||
# would need their own verification on other device families.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_EHS_IGNORED = [
|
_EHS_IGNORED = [
|
||||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
|
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
|
||||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||||
|
|||||||
@@ -3,17 +3,15 @@
|
|||||||
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
|
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
|
||||||
|
|
||||||
Temperature unit is read live from each resource, not assumed: the RF9000B
|
Temperature unit is read live from each resource, not assumed: the RF9000B
|
||||||
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
|
dump reports Fahrenheit, but a TP1X_REF_21K dump (issue #7) reports the same
|
||||||
but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the
|
fields in Celsius for the exact same resources -- the device tells you which
|
||||||
exact same resources — the device tells you which one it is, it's just never
|
one it is. See `_temp_unit`/`_temp_item_unit` below.
|
||||||
been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are
|
|
||||||
NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path
|
|
||||||
from href at write time.
|
|
||||||
|
|
||||||
Multi-instance note: the two door resources (/door/cooler/0 and
|
Multi-instance note: the two door resources (/door/cooler/0,
|
||||||
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
|
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0,
|
||||||
/icemaker/two/vs/0) use named path segments, so they are modelled via
|
/icemaker/two/vs/0) use named path segments, so they are modeled via
|
||||||
pattern capabilities that auto-derive distinct entity keys from href segments.
|
pattern capabilities that auto-derive distinct entity keys from href
|
||||||
|
segments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -30,10 +28,8 @@ from ..entities import (
|
|||||||
from .common import normalize_temp_unit
|
from .common import normalize_temp_unit
|
||||||
|
|
||||||
# Display names for the beverage zone, flex zone, ice type, and
|
# Display names for the beverage zone, flex zone, ice type, and
|
||||||
# ice-making-status enums below live in translations/en.json,
|
# ice-making-status enums below live in translations/en.json, keyed by the
|
||||||
# keyed by the lowercased raw device value — select.py and SensorDesc.options
|
# lowercased raw device value.
|
||||||
# normalize to lowercase for HA's translation lookup and map back to this
|
|
||||||
# original casing before writing to the device.
|
|
||||||
|
|
||||||
|
|
||||||
def _int(v):
|
def _int(v):
|
||||||
@@ -44,14 +40,13 @@ def _int(v):
|
|||||||
|
|
||||||
|
|
||||||
def _temp_unit(rep):
|
def _temp_unit(rep):
|
||||||
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to
|
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults
|
||||||
°F (this module's original assumption) if the device omits the field."""
|
to °F if the device omits the field."""
|
||||||
return normalize_temp_unit(rep.get("units"))
|
return normalize_temp_unit(rep.get("units"))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Temperature (generic -- covers /temperature/current/* and
|
||||||
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
|
# /temperature/desired/*)
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
TEMP_CURRENT_GENERIC = Capability(
|
TEMP_CURRENT_GENERIC = Capability(
|
||||||
href=None,
|
href=None,
|
||||||
@@ -74,14 +69,10 @@ TEMP_CURRENT_GENERIC = Capability(
|
|||||||
|
|
||||||
|
|
||||||
def _temp_setpoint_write(p, rep, href=None, resources=None):
|
def _temp_setpoint_write(p, rep, href=None, resources=None):
|
||||||
"""Write temperature — prefer vendor /temperatures/vs/0 when available,
|
"""Prefer vendor /temperatures/vs/0 when present, else the direct OCF
|
||||||
fall back to direct OCF /temperature/desired/ write otherwise.
|
/temperature/desired/ write -- on some models only the vendor path
|
||||||
|
commits. Item IDs follow the Samsung convention: "0" = Freezer,
|
||||||
Samsung fridges expose both OCF-standard /temperature/desired/* and vendor
|
"1" = Fridge/Cooler."""
|
||||||
/temperatures/vs/0. On some models only the vendor path commits the change;
|
|
||||||
on others both work. Using the vendor path when present is always correct.
|
|
||||||
Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler.
|
|
||||||
"""
|
|
||||||
if not href:
|
if not href:
|
||||||
return None
|
return None
|
||||||
if resources and "/temperatures/vs/0" in resources:
|
if resources and "/temperatures/vs/0" in resources:
|
||||||
@@ -127,18 +118,12 @@ TEMP_SETPOINT = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Discrete cooler setpoint (issue #186): single-door "cooler only" fridges
|
||||||
# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only")
|
# report no /temperature/current|desired/* pair, only this vendor resource
|
||||||
# fridges report no /temperature/current|desired/* pair at all (this href
|
# bundling the live desired value with the specific values the unit
|
||||||
# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/'
|
# accepts. supportedList (e.g. ['1','2','3','4','7']) is not a contiguous
|
||||||
# or '/temperature/desired/' prefixes), only this one vendor resource that
|
# range, so this is a select reading its own live options rather than a
|
||||||
# bundles the live desired value together with the *specific* values the
|
# NumberDesc with min/max/step.
|
||||||
# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue
|
|
||||||
# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid
|
|
||||||
# setpoints on this model -- so a NumberDesc with a min/max/step would let a
|
|
||||||
# user pick an unsupported value; modeled as a select reading its own live
|
|
||||||
# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _definite_cooler_write(p, rep, href=None):
|
def _definite_cooler_write(p, rep, href=None):
|
||||||
@@ -171,11 +156,8 @@ def _definite_freezer_write(p, rep, href=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Freezer half of the same discrete-setpoint pattern (issue #229): a
|
# Freezer half of the same discrete-setpoint pattern (issue #229) -- same
|
||||||
# fridge/freezer combo reporting no /temperature/current|desired/freezer
|
# shape as DEFINITE_TEMPERATURE_COOLER, negative supportedList values.
|
||||||
# pair, only this bundled vendor resource -- identical shape to
|
|
||||||
# DEFINITE_TEMPERATURE_COOLER above (down to the field names), just negative
|
|
||||||
# supportedList values (e.g. ['-23','-21','-19','-17','-15']).
|
|
||||||
DEFINITE_TEMPERATURE_FREEZER = Capability(
|
DEFINITE_TEMPERATURE_FREEZER = Capability(
|
||||||
href="/temperature/definite/freezer/vs/0",
|
href="/temperature/definite/freezer/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -191,10 +173,6 @@ DEFINITE_TEMPERATURE_FREEZER = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Icemaker nighttime quiet mode
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
ICEMAKER_NIGHTTIME = Capability(
|
ICEMAKER_NIGHTTIME = Capability(
|
||||||
href="/icemaker/nighttime/vs/0",
|
href="/icemaker/nighttime/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -213,18 +191,13 @@ ICEMAKER_NIGHTTIME = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Icemaker (generic -- covers /icemaker/one/vs/0, /icemaker/two/vs/0).
|
||||||
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
|
# /icemaker/status/vs/0 is an exact-href cap and binds first;
|
||||||
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
|
|
||||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
||||||
#
|
|
||||||
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||||
# "ICE_BITES") -- read via name_field, reaching the translated name as the
|
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these
|
||||||
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
|
# two makers can both be enabled at once (issue #27), so they stay
|
||||||
# ice makers are independent on/off toggles that can both be enabled at once
|
# separate entities rather than one ice-type select.
|
||||||
# (issue #27), so they stay separate entities rather than a single ice-type
|
|
||||||
# select, but users still want them labeled with the device's own names.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _icemaker_write(field):
|
def _icemaker_write(field):
|
||||||
@@ -273,10 +246,6 @@ ICEMAKER_GENERIC = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Door alert tone
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
DOOR_ALERT = Capability(
|
DOOR_ALERT = Capability(
|
||||||
href="/settings/sound/alert/door/vs/0",
|
href="/settings/sound/alert/door/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -296,10 +265,6 @@ DOOR_ALERT = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Status/lock — auto door opener and fridge sound
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _status_lock_write(field):
|
def _status_lock_write(field):
|
||||||
return lambda p, rep, href=None: (
|
return lambda p, rep, href=None: (
|
||||||
@@ -331,18 +296,11 @@ STATUS_LOCK = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Defrost delay / active-defrost status
|
|
||||||
#
|
|
||||||
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
||||||
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
|
# defrost. /defrost/block/vs/0 is unrelated: despite the "block" naming,
|
||||||
# status: despite its "block" naming (originally assumed to mean "defrost
|
# live dumps confirm DEFROST_BLOCK_ON means the defrost cycle is actively
|
||||||
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the
|
# running right now (seen with defrost_delay off) -- "block" refers to the
|
||||||
# defrost cycle is *actively running* right now, seen with defrost_delay
|
# evaporator/coil block being defrosted, not a prevention state.
|
||||||
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
|
|
||||||
# not a blocking/prevention state. Exposed as a read-only diagnostic
|
|
||||||
# binary sensor.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
DEFROST_DELAY = Capability(
|
DEFROST_DELAY = Capability(
|
||||||
href="/defrost/delay/vs/0",
|
href="/defrost/delay/vs/0",
|
||||||
@@ -362,10 +320,9 @@ DEFROST_DELAY = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K
|
# OCF-native boolean mirror of DEFROST_DELAY -- only the vendor resource
|
||||||
# firmware publishes the same state on both hrefs, but only the vendor resource
|
# above has a confirmed write contract, so bind this without another
|
||||||
# above has a confirmed write contract. Bind the native mirror without another
|
# entity to record it as an intentional duplicate.
|
||||||
# entity so discovery records it as an intentional duplicate.
|
|
||||||
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
|
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
|
||||||
href="/defrost/delay/0",
|
href="/defrost/delay/0",
|
||||||
)
|
)
|
||||||
@@ -384,10 +341,6 @@ DEFROST_BLOCK_STATUS = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Refrigeration modes (rapid cooling)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _refrigeration_write(field_name):
|
def _refrigeration_write(field_name):
|
||||||
def _write(p, rep, href=None):
|
def _write(p, rep, href=None):
|
||||||
@@ -421,10 +374,6 @@ REFRIGERATION = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Autofill
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _autofill_write(p, rep, href=None):
|
def _autofill_write(p, rep, href=None):
|
||||||
if p not in ("On", "Off"):
|
if p not in ("On", "Off"):
|
||||||
@@ -447,10 +396,6 @@ AUTOFILL = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Welcome lighting (proximity-triggered cabinet light)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
WELCOME_LIGHTING = Capability(
|
WELCOME_LIGHTING = Capability(
|
||||||
href="/proximity/vs/0",
|
href="/proximity/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -469,14 +414,11 @@ WELCOME_LIGHTING = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Enhanced cabinet light nighttime schedule: night.starttime is an ISO
|
||||||
# Enhanced cabinet light — nighttime lighting schedule
|
# datetime (only the time portion matters), night.duration.minute is the
|
||||||
#
|
# window length. End time is derived so both time entities write back to
|
||||||
# night.starttime is an ISO datetime; only the time portion is meaningful.
|
# the same resource without stepping on each other: writing start
|
||||||
# night.duration.minute encodes the window length. End time is derived so
|
# preserves duration; writing end recalculates it.
|
||||||
# both time entities write back to the same resource without stepping on each
|
|
||||||
# other: writing start preserves duration; writing end recalculates duration.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100")
|
_NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100")
|
||||||
|
|
||||||
@@ -600,10 +542,6 @@ CABINET_LIGHT_ENHANCED = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cabinet light
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _cabinet_light_write(p, rep, href=None):
|
def _cabinet_light_write(p, rep, href=None):
|
||||||
if p not in ("On", "Off"):
|
if p not in ("On", "Off"):
|
||||||
@@ -638,10 +576,6 @@ CABINET_LIGHT = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sabbath mode
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _sabbath_write(p, rep, href=None):
|
def _sabbath_write(p, rep, href=None):
|
||||||
if p not in ("On", "Off"):
|
if p not in ("On", "Off"):
|
||||||
@@ -664,10 +598,6 @@ SABBATH = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Beverage zone
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _bzone_write(p, rep, href=None):
|
def _bzone_write(p, rep, href=None):
|
||||||
return ["specialzone", "one", "vs", "0"], {"roomDesiredMode": p}
|
return ["specialzone", "one", "vs", "0"], {"roomDesiredMode": p}
|
||||||
@@ -689,16 +619,11 @@ BEVERAGE_ZONE = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pantry / Cool Select Zone -- a convertible compartment toggled between
|
# Pantry / Cool Select Zone -- a convertible compartment toggled between
|
||||||
# wine/deli/drinks temperature presets (issue #20). Same shape as
|
# wine/deli/drinks presets (issue #20). Same shape as BEVERAGE_ZONE but a
|
||||||
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes
|
# distinct field set (x.com.samsung.da.mode/supportedOptions vs
|
||||||
# list) but a distinct resource/field set -- x.com.samsung.da.mode /
|
# roomDesiredMode/roomSupportedModes). Only a "one" instance seen; not
|
||||||
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than
|
# generalized to a pattern cap until a second instance turns up.
|
||||||
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
|
|
||||||
# instance has been seen; not generalized to a pattern cap until a second
|
|
||||||
# instance turns up.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _pantry_write(p, rep, href=None):
|
def _pantry_write(p, rep, href=None):
|
||||||
@@ -721,18 +646,13 @@ PANTRY_ZONE = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Flex zone (convertible drawer -- /mode/vs/0 on RF9000-class fridges):
|
||||||
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges)
|
# x.com.samsung.da.modes holds several orthogonal flags in one list; the
|
||||||
#
|
# flex-zone entry is whichever item also appears in supportedOptions (the
|
||||||
# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the
|
# other flags, WATERFILTER_*/DEFROST_BLOCK_*/CVN_*_ZONE, never do). The
|
||||||
# flex-zone entry is whichever item is also a member of supportedOptions --
|
# prefix on that item varies by family (CV_TTYPE_RF9000A_ vs CV_FDR_ on
|
||||||
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker)
|
# Bespoke, issues #27/#26), so match by list membership instead of a
|
||||||
# never appear there. The prefix on that item varies by fridge family
|
# hardcoded prefix. Write replaces only that item.
|
||||||
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
|
|
||||||
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
|
|
||||||
# stuck on None), so match by list membership instead of a hardcoded prefix.
|
|
||||||
# Write replaces only that item; other flags are preserved.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _flex_zone_supported(rep):
|
def _flex_zone_supported(rep):
|
||||||
@@ -740,10 +660,9 @@ def _flex_zone_supported(rep):
|
|||||||
|
|
||||||
|
|
||||||
def _flex_zone_current(rep):
|
def _flex_zone_current(rep):
|
||||||
# Every dump seen has at most one modes/supportedOptions overlap, so
|
# Every dump seen has at most one modes/supportedOptions overlap; a
|
||||||
# "first match" and "strip all matches" (in the write below) agree. If a
|
# future device reporting two would read the first and the write below
|
||||||
# future device ever reports two, this reads the first and the write
|
# would drop both.
|
||||||
# would drop both -- revisit if that turns up.
|
|
||||||
modes = rep.get("x.com.samsung.da.modes") or []
|
modes = rep.get("x.com.samsung.da.modes") or []
|
||||||
supported = _flex_zone_supported(rep)
|
supported = _flex_zone_supported(rep)
|
||||||
return next((m for m in modes if m in supported), None)
|
return next((m for m in modes if m in supported), None)
|
||||||
@@ -767,15 +686,11 @@ FLEX_ZONE = Capability(
|
|||||||
entity_category="config",
|
entity_category="config",
|
||||||
options_field="x.com.samsung.da.supportedOptions",
|
options_field="x.com.samsung.da.supportedOptions",
|
||||||
# A nonempty supportedOptions alone isn't sufficient: the
|
# A nonempty supportedOptions alone isn't sufficient: the
|
||||||
# kimchi-refrigerator family (issue #26) also populates
|
# kimchi-refrigerator family (issue #26) also populates both
|
||||||
# /mode/vs/0's modes/supportedOptions with real data, but
|
# fields, but its tokens carry a "_[n]:[n]" suffix on
|
||||||
# its tokens carry a "_[n]:[n]" parameter suffix on
|
# supportedOptions that modes never repeats, so nothing ever
|
||||||
# supportedOptions that modes never repeats, so no item
|
# overlaps there. Require an actual resolvable value so this
|
||||||
# ever overlaps -- the RF9000/Bespoke-class overlap this
|
# stays absent on that family instead of stuck on "unknown".
|
||||||
# capability was built for never happens there. Require an
|
|
||||||
# actual resolvable value instead of just a populated
|
|
||||||
# list, so this stays absent on that family rather than
|
|
||||||
# showing a select permanently stuck on "unknown".
|
|
||||||
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
|
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
|
||||||
rep_fn=_flex_zone_current,
|
rep_fn=_flex_zone_current,
|
||||||
write_fn=_flex_zone_write,
|
write_fn=_flex_zone_write,
|
||||||
@@ -783,18 +698,13 @@ FLEX_ZONE = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Generic door pattern capability (href=None -- use as pattern_cap only)
|
||||||
# Generic door pattern capability (href=None — use as pattern_cap only)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _door_open_state(rep):
|
def _door_open_state(rep):
|
||||||
"""Most /door/* resources report bare `openState`, but the
|
"""Most /door/* resources report bare `openState`, but the
|
||||||
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83)
|
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77,
|
||||||
reports the vendor-prefixed `x.com.samsung.da.openState` instead. This
|
#83) reports `x.com.samsung.da.openState` instead -- check both."""
|
||||||
capability still binds either way (href_prefix match doesn't care about
|
|
||||||
field names), but a plain `field=` lookup against the wrong key means
|
|
||||||
the entity exists and is permanently unavailable -- check both."""
|
|
||||||
v = rep.get("openState")
|
v = rep.get("openState")
|
||||||
if v is None:
|
if v is None:
|
||||||
v = rep.get("x.com.samsung.da.openState")
|
v = rep.get("x.com.samsung.da.openState")
|
||||||
@@ -816,45 +726,27 @@ DOOR_GENERIC = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment
|
||||||
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi
|
# units, issue #26): top/middle/bottom each report their own storage mode
|
||||||
# units, issue #26) -- top/middle/bottom each report their own storage mode
|
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, modeled as a
|
||||||
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in
|
# pattern capability the same way DOOR_GENERIC is. Only the top
|
||||||
# an identical shape; modeled as a pattern capability the same way
|
# compartment's door is reported separately (kimchidoors); middle/bottom
|
||||||
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
|
# apparently have no contact switch, hence the narrower KIMCHI_DOOR_GENERIC
|
||||||
# key and {instance_name} from the href's top/middle/bottom segment. Only
|
# below rather than assuming it's universal.
|
||||||
# the top compartment's door has been seen reported separately (kimchidoors);
|
|
||||||
# middle/bottom apparently have no contact switch of their own, so that's
|
|
||||||
# its own narrower pattern cap rather than assumed universal.
|
|
||||||
#
|
#
|
||||||
# The same state is also mirrored -- packed into single tokens like
|
# The same state is also packed into single tokens (e.g.
|
||||||
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
|
# "KIMCHIT_KIMCHI_STORAGE_NORMAL") on /mode/vs/0, the resource FLEX_ZONE
|
||||||
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
|
# reads for RF9000-class fridges -- this binds to /status/kimchi/<slot>/
|
||||||
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/
|
# vs/0's plain, self-describing currentMode/supportMode instead.
|
||||||
# supportMode fields are unpacked and self-describing, so that's what this
|
|
||||||
# binds to instead.
|
|
||||||
#
|
#
|
||||||
# Write path is unconfirmed (no live write against a real unit) -- same
|
# Write path is unconfirmed on real hardware; same "write the field back to
|
||||||
# "write the same field back to the entity's own href" convention as
|
# the entity's own href" convention as PANTRY_ZONE/BEVERAGE_ZONE.
|
||||||
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
|
|
||||||
#
|
#
|
||||||
# translations/en.json's kimchi_zone_mode state labels were translated
|
# translations/en.json's kimchi_zone_mode labels were translated directly
|
||||||
# directly from the reporter's own (Korean-language) SmartThings app
|
# from the reporter's own Korean SmartThings app screenshots (not guessed),
|
||||||
# screenshots, not guessed from the codes or from their English paraphrase.
|
# and cross-checked against supportMode order to confirm the on-screen
|
||||||
# Cross-checking the screenshots against supportMode confirms the on-screen
|
# option order matches the array order throughout -- so COLD/WARM
|
||||||
# option order matches the array order everywhere it's verifiable: the top
|
# consistently means Strong/Weak everywhere that suffix appears.
|
||||||
# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
|
|
||||||
# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
|
|
||||||
# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
|
|
||||||
# ripening list, and 4-entry custom-storage list each line up 1:1 with their
|
|
||||||
# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
|
|
||||||
# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
|
|
||||||
# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
|
|
||||||
# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
|
|
||||||
# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
|
|
||||||
# and BUY (구입, "purchased") are also confirmed exact matches, not
|
|
||||||
# abbreviation guesses.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _kimchi_mode_write(p, rep, href=None):
|
def _kimchi_mode_write(p, rep, href=None):
|
||||||
@@ -896,9 +788,8 @@ KIMCHI_ZONE = Capability(
|
|||||||
icon="mdi:timer-sand",
|
icon="mdi:timer-sand",
|
||||||
translation_key="kimchi_ripening_remaining",
|
translation_key="kimchi_ripening_remaining",
|
||||||
entity_category="diagnostic",
|
entity_category="diagnostic",
|
||||||
# No dump has this nonzero (ripeStatus is always "Off" so
|
# No dump has this nonzero (ripeStatus is always "Off" so far)
|
||||||
# far) -- device-reported unit unconfirmed, so this stays
|
# -- unit unconfirmed, so this stays a bare number.
|
||||||
# a bare number rather than asserting minutes or hours.
|
|
||||||
value_fn=_int,
|
value_fn=_int,
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
@@ -921,12 +812,11 @@ KIMCHI_DOOR_GENERIC = Capability(
|
|||||||
poll_tier="hot",
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
# Not deduped against DOORS_FALLBACK below: on the one reporter
|
# Not deduped against DOORS_FALLBACK below: on the one reporter
|
||||||
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
|
# this binds alongside, /doors/vs/0's aggregate carries a single
|
||||||
# /doors/vs/0 aggregate carries a single generic item (id "4", no
|
# generic item (id "4", no /door/<instance> siblings) that doesn't
|
||||||
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
|
# share this compartment's "top" numbering -- a distinct
|
||||||
# that doesn't share this compartment's "top" instance numbering --
|
# main-cabinet door, not this drawer's contact switch reported
|
||||||
# a distinct main-cabinet door, not this kimchi drawer's own contact
|
# twice.
|
||||||
# switch reported twice.
|
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key="open",
|
key="open",
|
||||||
rep_fn=_door_open_state,
|
rep_fn=_door_open_state,
|
||||||
@@ -937,19 +827,12 @@ KIMCHI_DOOR_GENERIC = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Aggregate-resource fallbacks: /doors/vs/0, /temperatures/vs/0, and
|
||||||
# Aggregate-resource fallbacks
|
# /icemaker/status/vs/0 each duplicate information the per-instance hrefs
|
||||||
#
|
# above expose more precisely, on hardware that has them -- not every
|
||||||
# /doors/vs/0, /temperatures/vs/0, and /icemaker/status/vs/0 each duplicate
|
# fridge does. Each fallback's match_fn checks for the richer sibling
|
||||||
# information exposed more precisely by per-instance hrefs (DOOR_GENERIC,
|
# hrefs and only binds when they're absent, so it's a no-op wherever the
|
||||||
# TEMP_CURRENT_GENERIC/TEMP_SETPOINT_GENERIC, ICEMAKER_GENERIC) on hardware
|
# richer hrefs exist and a real (coarser) source where they don't.
|
||||||
# that has them. Not every fridge does — a simpler model may only ever
|
|
||||||
# advertise the aggregate resource. Each fallback's match_fn checks the
|
|
||||||
# full resource set for the richer sibling hrefs and only binds when
|
|
||||||
# they're absent, so it's a no-op (not a gap — see discovery.py) wherever
|
|
||||||
# the richer hrefs exist, and a real (if coarser) source of the same data
|
|
||||||
# where they don't.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _any_door_generic(resources):
|
def _any_door_generic(resources):
|
||||||
@@ -1045,23 +928,19 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
|
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
|
||||||
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit
|
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer
|
||||||
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so
|
# per-unit hrefs; its write contract isn't advertised, so bind it as a
|
||||||
# keep the proven per-unit/vendor controls and bind this as a duplicate only.
|
# duplicate only.
|
||||||
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
|
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
|
||||||
href="/icemaker/status/0",
|
href="/icemaker/status/0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out
|
# OCF-native /refrigeration/0 (issue #7): its three fields duplicate two
|
||||||
# in this section: its three fields duplicate two *different* richer
|
# different richer hrefs (REFRIGERATION's rapidFridge/rapidFreezing,
|
||||||
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and
|
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so
|
||||||
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a
|
# gating is per-entity (exists_fn) rather than one capability-level
|
||||||
# single capability-level match_fn can't express it. Gated per-entity
|
# match_fn. No write path confirmed, so these stay read-only.
|
||||||
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
|
|
||||||
# REFRIGERATION's href is present; defrost_active only when
|
|
||||||
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
|
|
||||||
# these are read-only, unlike REFRIGERATION's switches.
|
|
||||||
REFRIGERATION_FALLBACK = Capability(
|
REFRIGERATION_FALLBACK = Capability(
|
||||||
href="/refrigeration/0",
|
href="/refrigeration/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ here would silently do nothing on that path. Enumerate each known href
|
|||||||
instead; it's a short, stable list.
|
instead; it's a short, stable list.
|
||||||
|
|
||||||
This list is maintainer-curated only; there is no per-installation
|
This list is maintainer-curated only; there is no per-installation
|
||||||
override. Grow it as real /device/0 dumps surface more universal noise —
|
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
|
never on a guess. If a href's relevance is unclear, leave it unbound so it
|
||||||
it unbound so it surfaces as a gap for a human to look at.
|
surfaces as a gap for a human to look at.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
@@ -61,12 +61,9 @@ IGNORED: list[Capability] = [
|
|||||||
# Redundant with capabilities already declared elsewhere.
|
# Redundant with capabilities already declared elsewhere.
|
||||||
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
||||||
Capability(href="/speakersound/vs/0"),
|
Capability(href="/speakersound/vs/0"),
|
||||||
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
|
# No entities of its own -- editCourseList is read directly out of the
|
||||||
# editCourseList is read directly out of the resource snapshot by
|
# resource snapshot by dishwasher.CYCLE_OPTIONS/washer.WASHER_COURSE's
|
||||||
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
|
# cycle selects to build the device's supported course list.
|
||||||
# (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="/wm/editcourse/vs/0"),
|
||||||
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
||||||
# listening) — only meaningful with Bixby enabled, which this
|
# listening) — only meaningful with Bixby enabled, which this
|
||||||
@@ -96,17 +93,13 @@ IGNORED: list[Capability] = [
|
|||||||
# Temperature-unit display preference, redundant with HA's own units.
|
# 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
|
# Read-only re-encoding of the course already exposed by
|
||||||
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
|
# washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed
|
||||||
# is literally "Table_02_Course_<same hex code>").
|
# "Table_02_Course_").
|
||||||
Capability(href="/st/washercourse/vs/0"),
|
Capability(href="/st/washercourse/vs/0"),
|
||||||
# Dryer counterpart of the above: re-encoding of the course already
|
# Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0.
|
||||||
# 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"),
|
Capability(href="/st/dryercourse/vs/0"),
|
||||||
# AirDresser counterpart of the above (issue #157): read only for its
|
# AirDresser counterpart (issue #157): read only for its courseTable id
|
||||||
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no
|
# (air_dresser.AIR_DRESSER_COURSE's table_href), no entity of its own.
|
||||||
# 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"),
|
Capability(href="/st/airdressercourse/vs/0"),
|
||||||
# Empty on every washer dump seen so far.
|
# Empty on every washer dump seen so far.
|
||||||
Capability(href="/wm/welcomemsg/vs/0"),
|
Capability(href="/wm/welcomemsg/vs/0"),
|
||||||
@@ -114,29 +107,21 @@ IGNORED: list[Capability] = [
|
|||||||
# state without a multi-slot editor; revisit if that becomes valuable.
|
# 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
|
# OCF-native energy resource is empty ({}) on washer hardware seen so
|
||||||
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
|
# far -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
||||||
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
# real source.
|
||||||
# real source for this control.
|
|
||||||
Capability(href="/energy/consumption/0"),
|
Capability(href="/energy/consumption/0"),
|
||||||
# Empty ({}) on every washer dump seen so far -- nothing to expose.
|
# 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
|
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
|
||||||
# dependency this integration doesn't support locally.
|
# dependency this integration doesn't support locally.
|
||||||
Capability(href="/drlc/0"),
|
Capability(href="/drlc/0"),
|
||||||
# OCF-native duplicate of /operational/state/vs/0, which is already
|
# OCF-native duplicate of /operational/state/vs/0, already modeled by
|
||||||
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
|
# operational.OPERATIONAL_STATE (richer, write-capable, used by washer,
|
||||||
# capability with start/pause/stop buttons and a delay-start control)
|
# dishwasher, dryer, oven). This generic href is read-only overlapping
|
||||||
# used by washer, dishwasher, dryer, and oven. This generic href only
|
# data with no verified write contract worth building around.
|
||||||
# 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"),
|
Capability(href="/operational/state/0"),
|
||||||
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
|
# Cooktop guided-cooking/recipe status (issue #86): every field
|
||||||
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
|
# empty/zero on the only dump seen (device idle). Same "don't guess"
|
||||||
# a textData.menu string -- every field empty/zero on the only dump
|
# treatment as the microwave family's /recipe/cook/vs/0.
|
||||||
# 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="/cooktop/recipe/status/vs/0"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -178,33 +178,24 @@ BUZZER_SOUND = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cycle selection over /course/vs/0.
|
# Cycle selection over /course/vs/0.
|
||||||
#
|
#
|
||||||
# The selected course and every other user-tunable option ride in the
|
# 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.
|
# x.com.samsung.da.options array as `<Prefix>_<value>` tokens. Confirmed on
|
||||||
# Confirmed on real hardware (issue #54): a write only needs to carry the one
|
# real hardware (issue #54): a write only needs to carry the one changed
|
||||||
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
|
# token -- the device matches by prefix, evicts the stale token, and merges
|
||||||
# the device matches by prefix, evicts the stale token, and merges the result
|
# the result itself (see option_write). The set of selectable courses is
|
||||||
# into the array itself. No read-modify-write of the whole array needed (see
|
# read live from editCourseList on /wm/editcourse/vs/0 (cycle_options), not
|
||||||
# option_write). The set of *selectable* courses is not hardcoded -- it's read
|
# hardcoded. Course codes are uppercase hex; display names live in
|
||||||
# live from
|
# translations under entity.select.<translation_key>.state.<id lowercased>.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
||||||
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
|
# editCourseList itself (issue #1) -- cycle_options() falls back to
|
||||||
# the same list from /course/vs/0's own supportedOptions in that case; see
|
# deriving the list from /course/vs/0's own supportedOptions in that case;
|
||||||
# _course_codes_from_supported_options for the byte-level evidence.
|
# see _course_codes_from_supported_options.
|
||||||
#
|
#
|
||||||
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
|
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family
|
||||||
# expose the same /course/vs/0 options contract.
|
# boards expose the same /course/vs/0 options contract.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def hex_pairs(codes):
|
def hex_pairs(codes):
|
||||||
@@ -237,13 +228,11 @@ def option_value(options, prefix):
|
|||||||
|
|
||||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||||
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
|
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
|
||||||
# #258); both families use identical DrumCleanProposal_/WashingTimes_/
|
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
|
||||||
# DrumCleanLog_ tokens. DrumCleanProposal_<N> is the wash/dry-cycle interval
|
# DrumCleanProposal_<N> is the cycle interval between recommended cleans;
|
||||||
# between recommended cleans; WashingTimes_<N> is the count since the last
|
# WashingTimes_<N> is the count since the last one -- their difference is
|
||||||
# one -- their difference is exactly the "N cycles until due" figure the
|
# the "N cycles until due" figure the app shows (verified: 40 - 3 == 37,
|
||||||
# Samsung app shows (verified on a washer: DrumCleanProposal_40 -
|
# matching a live app screenshot).
|
||||||
# WashingTimes_3 == 37, matching a live app screenshot's "Potreba cistenia
|
|
||||||
# po 37 cykloch").
|
|
||||||
def drum_clean_cycles_remaining(rep):
|
def drum_clean_cycles_remaining(rep):
|
||||||
opts = rep.get("x.com.samsung.da.options") or []
|
opts = rep.get("x.com.samsung.da.options") or []
|
||||||
proposal = option_value(opts, "DrumCleanProposal")
|
proposal = option_value(opts, "DrumCleanProposal")
|
||||||
@@ -257,14 +246,10 @@ def drum_clean_cycles_remaining(rep):
|
|||||||
|
|
||||||
|
|
||||||
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
|
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
|
||||||
# datetime (the last clean, verified against the same app screenshot's "10
|
# datetime (the last clean); a dryer (issue #258) instead reports a
|
||||||
# days ago"); a dryer (issue #258's Dillton-reported dump) instead reports a
|
# '|'-joined history of every past clean in increasing order. Splitting on
|
||||||
# '|'-joined history of every past clean, ten deep on that dump, in
|
# '|' and taking the last element handles both shapes identically. No
|
||||||
# strictly increasing order. Splitting on '|' and taking the last element
|
# timezone accompanies either shape, so it's treated as UTC.
|
||||||
# handles both shapes identically -- a no-'|' value is unaffected. No
|
|
||||||
# explicit timezone field accompanies either shape, so it's treated as UTC,
|
|
||||||
# matching this integration's convention for other bare ISO datetime fields
|
|
||||||
# (see fridge.py's night-light schedule comment).
|
|
||||||
def drum_clean_last_cleaned(rep):
|
def drum_clean_last_cleaned(rep):
|
||||||
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
|
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -278,41 +263,27 @@ def drum_clean_last_cleaned(rep):
|
|||||||
|
|
||||||
def _course_codes_from_supported_options(course_rep):
|
def _course_codes_from_supported_options(course_rep):
|
||||||
"""Fallback for an empty/missing editCourseList: derive the selectable
|
"""Fallback for an empty/missing editCourseList: derive the selectable
|
||||||
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
|
course list from /course/vs/0's own supportedOptions instead (issue #1:
|
||||||
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
|
some boards populate /wm/editcourse/vs/0 but never fill in
|
||||||
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
|
editCourseList itself).
|
||||||
|
|
||||||
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
||||||
record per selectable course, self-indexed rather than positional --
|
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 first byte of every record is that course's own hex code.
|
||||||
the firmware's own internal order, not editCourseList's. Confirmed
|
Confirmed against six independent real-world dumps: every one divides
|
||||||
against six independent real-world washer/dryer/dishwasher dumps: every
|
evenly into `header + N * K bytes` with fully unique first bytes across
|
||||||
one divides evenly into `header + N * K bytes` with fully unique first
|
all N records, at the record's true byte width.
|
||||||
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.)
|
|
||||||
|
|
||||||
Two guards, deliberately conservative rather than guessing further: the
|
Two conservative guards rather than guessing further: the derived codes
|
||||||
derived codes must (a) all be distinct -- a real course table, not
|
must all be distinct, and must include whatever course is currently
|
||||||
noise -- and (b) include whatever course is currently selected
|
selected. If no split satisfies both, this returns [].
|
||||||
(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.
|
|
||||||
|
|
||||||
Among splits that satisfy both, the *smallest* passing K wins, rather
|
Among splits that satisfy both, the smallest passing K wins -- more
|
||||||
than requiring a single unambiguous one -- more than one K reliably
|
than one K reliably passes on real data, and smallest-K-wins matches
|
||||||
does pass on real data (e.g. the shipped dishwasher fixture: true
|
the confirmed answer on all six dumps checked, though it's a heuristic
|
||||||
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
|
rather than a proof. Not guarded further: course tables are typically
|
||||||
7 -- position 0 always lands on the same real course code regardless
|
large enough that colliding by chance on both checks is unlikely, and
|
||||||
of K, which is enough on its own to satisfy the current-course guard
|
no device seen so far needs it.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
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
|
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
||||||
@@ -340,7 +311,7 @@ def _course_codes_from_supported_options(course_rep):
|
|||||||
|
|
||||||
def option_write(prefix, new_value):
|
def option_write(prefix, new_value):
|
||||||
"""A one-token x.com.samsung.da.options write -- see the module comment
|
"""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."""
|
above for why this doesn't read/rewrite the whole array."""
|
||||||
return [f"{prefix}_{new_value}"]
|
return [f"{prefix}_{new_value}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -360,35 +331,24 @@ def _table_id(resources, table_href):
|
|||||||
def cycle_select(*, translation_key, icon, table_href=None):
|
def cycle_select(*, translation_key, icon, table_href=None):
|
||||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||||
|
|
||||||
The option list, current value, and write path are all shared across
|
The option list, current value, and write path are shared across
|
||||||
washer/dryer/dishwasher; only the translation is family- (and, for
|
washer/dryer/dishwasher; only the translation is family/board-specific.
|
||||||
washer/dryer, board-) specific.
|
|
||||||
|
|
||||||
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
|
table_href (washer/dryer only) suffixes translation_key with the
|
||||||
suffixes translation_key with the device's own course-table id, read
|
device's own course-table id, read from /st/washercourse/vs/0 or
|
||||||
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
|
/st/dryercourse/vs/0's courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||||
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
'washer_cycle_table_02'). This matters because course codes are NOT
|
||||||
'washer_cycle_table_02'). An absent or unrecognized table id gets the
|
guaranteed consistent across board generations sharing the same
|
||||||
name-only ``cycle`` translation key while the raw course code remains
|
/course/vs/0 contract: washer_cycle_table_02 was confirmed against
|
||||||
visible and writable.
|
Table_02 devices, but FlexWash's older board reports Table_00, where
|
||||||
|
the same hex code could mean a different course. An absent or
|
||||||
This matters because course codes are NOT guaranteed consistent across
|
unrecognized table id falls back to the name-only ``cycle`` key
|
||||||
board generations sharing the same /course/vs/0 contract: every code in
|
instead of borrowing a label from another board generation --
|
||||||
washer_cycle_table_02 was confirmed against Table_02-reporting devices
|
translating a new table is a translations-only change.
|
||||||
(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.
|
|
||||||
|
|
||||||
Left at its default for dishwasher, which has no equivalent table-id
|
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
|
resource and no evidence its codes vary by table the way washer/
|
||||||
table the way washer/dryer's do -- there's nothing to build a
|
dryer's do.
|
||||||
table-specific key from.
|
|
||||||
"""
|
"""
|
||||||
key = translation_key
|
key = translation_key
|
||||||
if table_href is not None:
|
if table_href is not None:
|
||||||
@@ -411,14 +371,11 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plain boolean toggles over /course/vs/0's options[] array: a
|
# Plain boolean toggles over /course/vs/0's options[] array: a
|
||||||
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
|
# '<prefix>_On'/'<prefix>_Off' token, merged the same way as the 'Course'
|
||||||
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
|
# token above. Shared by washer (bubble soak, pre-wash, intensive -- issue
|
||||||
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
|
# #22) and dishwasher (storm wash, auto release dry), just with different
|
||||||
# both families ride this exact contract, just with different prefixes and
|
# prefixes and presence/validation needs on top.
|
||||||
# different presence/validation needs on top.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def bool_option_write(prefix):
|
def bool_option_write(prefix):
|
||||||
@@ -450,12 +407,11 @@ def bool_option_switch(
|
|||||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||||
|
|
||||||
gate_on_presence self-gates the entity off on models that never report
|
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
|
the token (washer's bubble soak/pre-wash/intensive); leave False for a
|
||||||
for a toggle every device in the family reports (dishwasher's storm
|
toggle every device in the family reports (dishwasher's storm wash).
|
||||||
wash). validate_fn is passed straight through to SwitchDesc for callers
|
validate_fn passes straight through to SwitchDesc for callers that need
|
||||||
that need to reject a write against live device state (e.g. washer's
|
to reject a write against live state -- this factory has no opinion on
|
||||||
per-course availability check) -- this factory has no opinion on it and
|
it.
|
||||||
building one, if needed, is the caller's job.
|
|
||||||
"""
|
"""
|
||||||
return SwitchDesc(
|
return SwitchDesc(
|
||||||
key=key,
|
key=key,
|
||||||
@@ -468,14 +424,11 @@ def bool_option_switch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
|
# /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
|
# (e.g. door open, no water), x.com.samsung.da.currentStatus on every dump
|
||||||
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
|
# that populates it. An earlier dryer descriptor read
|
||||||
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
|
# x.com.samsung.da.jobBeginingStatus instead, which no dump ever carried,
|
||||||
# ever carried that field, so the dryer sensor was always blank -- fixed by
|
# so the dryer sensor was always blank -- fixed by sharing this one reader.
|
||||||
# sharing this one reader.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
JOB_BEGINNING_STATUS = Capability(
|
JOB_BEGINNING_STATUS = Capability(
|
||||||
href="/wm/jobbeginingstatus/vs/0",
|
href="/wm/jobbeginingstatus/vs/0",
|
||||||
|
|||||||
@@ -8,33 +8,25 @@ oven.py in by_type/microwave.py rather than duplicated. What's genuinely
|
|||||||
different from an oven, and defined fresh here:
|
different from an oven, and defined fresh here:
|
||||||
|
|
||||||
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
|
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
|
||||||
KeepWarm never appear on an oven's /mode/vs/0, and this family spells
|
KeepWarm never appear on an oven's /mode/vs/0, and some shared-sounding
|
||||||
some shared-sounding modes differently than oven.py's own constants
|
modes are spelled differently (e.g. 'AirFryer', not oven.py's
|
||||||
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
|
'AirFry') -- a distinct SelectDesc and mode list, not oven.OVEN_MODE.
|
||||||
mode list, not oven.OVEN_MODE.
|
|
||||||
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
|
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
|
||||||
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
|
(issue #121) reports 40-200°C / step 5, not oven.py's 30-270°C range.
|
||||||
30-270 C range (verified against a different, bake-oven-class board).
|
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W)
|
||||||
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
|
that plain ovens don't report -- exposed as its own sensor.
|
||||||
on the MicroWave mode's powerListData) that plain ovens don't report --
|
* Lamp: this family's option-array token is bare 'Lamp' (issue #137), not
|
||||||
exposed as its own sensor.
|
oven.py's 'UpperLamp', and genuinely absent on the combi dump (issue
|
||||||
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
|
#121), so it's exists_fn-gated rather than assumed universal. 'On' has
|
||||||
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
|
never been observed as a value; the only confirmed non-Off token is
|
||||||
the combi dump (issue #121), so it's gated with exists_fn rather than
|
'High' (issue #152) -- the switch treats any non-Off/non-None value as
|
||||||
assumed universal like oven.py's lamp switch. Issue #137's dump only
|
"on" for reads and writes back 'High'/'Off'.
|
||||||
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'.
|
|
||||||
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
|
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
|
||||||
option-array tokens (issue #181), both with On and Off observed live
|
option-array tokens (issue #181), gated with exists_fn like Lamp since
|
||||||
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
|
|
||||||
the MW7300B combi dump has neither.
|
the MW7300B combi dump has neither.
|
||||||
|
|
||||||
Note: cooking-mode writes are unproven here, same caveat as oven.py's
|
Cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE
|
||||||
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
|
-- exposed as a SelectDesc for fidelity, first real-world write is the test.
|
||||||
is also the test.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
@@ -46,13 +38,10 @@ from .laundry import option_value, option_write
|
|||||||
# Constants
|
# Constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Union of every mode seen across the two known dumps: issue #121's combi
|
# Union of every mode seen across the two known dumps (issues #121, #137).
|
||||||
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
|
# No dump has shown every mode below on one device -- the select surfaces
|
||||||
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
|
# whatever a given board's own supportedModes reports; an entry here a
|
||||||
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
|
# device never sends just never gets picked.
|
||||||
# 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.
|
|
||||||
_MICROWAVE_MODES = (
|
_MICROWAVE_MODES = (
|
||||||
"NoOperation",
|
"NoOperation",
|
||||||
"MicroWave",
|
"MicroWave",
|
||||||
@@ -67,22 +56,18 @@ _MICROWAVE_MODES = (
|
|||||||
"KeepWarm",
|
"KeepWarm",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
|
# Convection/MicroWaveConvection modeSpec on issue #121's dump: 40-200°C,
|
||||||
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
|
# step 5. No Fahrenheit dump exists for this family, unlike oven.py's own
|
||||||
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
|
# independently-verified F bounds, so this module only exposes the
|
||||||
# against issue #44's range dump), there's nothing to verify a microwave's
|
# setpoint control when the live unit is Celsius (see _microwave_temp_unit).
|
||||||
# Fahrenheit bounds against, so this module only exposes the setpoint
|
|
||||||
# control when the live unit is Celsius (see _microwave_temp_unit below).
|
|
||||||
SETPOINT_MIN_C = 40
|
SETPOINT_MIN_C = 40
|
||||||
SETPOINT_MAX_C = 200
|
SETPOINT_MAX_C = 200
|
||||||
SETPOINT_STEP_C = 5
|
SETPOINT_STEP_C = 5
|
||||||
|
|
||||||
|
|
||||||
def _microwave_temp_unit(rep):
|
def _microwave_temp_unit(rep):
|
||||||
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
|
"""Same shape as oven.py's _oven_temp_unit. Both known dumps report
|
||||||
carries a per-item x.com.samsung.da.unit field. Both known dumps for
|
'Celsius'; kept live rather than hardcoded (issue #7)."""
|
||||||
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 []
|
items = rep.get("x.com.samsung.da.items") or []
|
||||||
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
||||||
return normalize_temp_unit(unit, default="°C")
|
return normalize_temp_unit(unit, default="°C")
|
||||||
@@ -90,8 +75,7 @@ def _microwave_temp_unit(rep):
|
|||||||
|
|
||||||
def _setpoint_write(p, rep, href=None):
|
def _setpoint_write(p, rep, href=None):
|
||||||
"""RMW write to /temperatures/vs/0 items array -- unproven for this
|
"""RMW write to /temperatures/vs/0 items array -- unproven for this
|
||||||
family (no live write confirmed against a real unit), same "exposed for
|
family, same "exposed for fidelity" caveat as the mode select."""
|
||||||
fidelity" caveat as the mode select."""
|
|
||||||
try:
|
try:
|
||||||
temp = float(p)
|
temp = float(p)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -118,12 +102,11 @@ def _power_level_watts(v):
|
|||||||
|
|
||||||
|
|
||||||
def _cooking_mode_options(resources):
|
def _cooking_mode_options(resources):
|
||||||
"""Live mode list from the device's own /mode/vs/0 supportedModes when
|
"""Live mode list from the device's own supportedModes when reported
|
||||||
it reports one (both known dumps do); the union-of-all-dumps
|
(both known dumps do); the union-of-all-dumps _MICROWAVE_MODES guess
|
||||||
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
|
otherwise. Same live-first, static-fallback pattern as
|
||||||
pattern as oven._oven_mode_options -- a fixed list here would offer
|
oven._oven_mode_options -- a fixed list would offer modes a unit
|
||||||
users modes their own unit doesn't have (issue #152's ME7500D reports
|
doesn't have (issue #152 reports only 4 of _MICROWAVE_MODES' 11)."""
|
||||||
only 4 of _MICROWAVE_MODES' 11)."""
|
|
||||||
rep = resources.get("/mode/vs/0") or {}
|
rep = resources.get("/mode/vs/0") or {}
|
||||||
live = rep.get("x.com.samsung.da.supportedModes")
|
live = rep.get("x.com.samsung.da.supportedModes")
|
||||||
return list(live) if live else list(_MICROWAVE_MODES)
|
return list(live) if live else list(_MICROWAVE_MODES)
|
||||||
@@ -163,9 +146,8 @@ def _lamp_write(p, rep, href=None):
|
|||||||
return None
|
return None
|
||||||
if not rep.get("x.com.samsung.da.options"):
|
if not rep.get("x.com.samsung.da.options"):
|
||||||
return None
|
return None
|
||||||
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
|
# 'High'/'Off' are the two confirmed tokens (see module docstring);
|
||||||
# (issues #137/#152) -- 'On' has never been observed and the device
|
# 'On' has never been observed and likely isn't recognized.
|
||||||
# likely doesn't recognize it (see module docstring).
|
|
||||||
token = "High" if p == "On" else "Off"
|
token = "High" if p == "On" else "Off"
|
||||||
return ["mode", "vs", "0"], {
|
return ["mode", "vs", "0"], {
|
||||||
"x.com.samsung.da.options": option_write("Lamp", token),
|
"x.com.samsung.da.options": option_write("Lamp", token),
|
||||||
@@ -271,11 +253,9 @@ MICROWAVE_MODE = Capability(
|
|||||||
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
|
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
|
||||||
write_fn=_lamp_write,
|
write_fn=_lamp_write,
|
||||||
),
|
),
|
||||||
# issue #181: Filter Reminder / End Signal Reminder toggles,
|
# issue #181: Filter Reminder / End Signal Reminder toggles, only on
|
||||||
# confirmed present (both On and Off observed across dumps -- see
|
# boards carrying the FilterRemind_*/RemindBeep_* tokens; gated off
|
||||||
# issue #152's ME7500D fixtures) but only on boards that carry the
|
# elsewhere (the MW7300B combi dump has neither).
|
||||||
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
|
|
||||||
# MW7300B combi dump has neither) rather than assumed universal.
|
|
||||||
SwitchDesc(
|
SwitchDesc(
|
||||||
key="filter_remind",
|
key="filter_remind",
|
||||||
field="x.com.samsung.da.options",
|
field="x.com.samsung.da.options",
|
||||||
|
|||||||
@@ -98,11 +98,9 @@ def _finish_time(rep):
|
|||||||
if not total_s:
|
if not total_s:
|
||||||
return None
|
return None
|
||||||
# Round to whole minutes -- remainingTime itself only has minute
|
# Round to whole minutes -- remainingTime itself only has minute
|
||||||
# resolution, but datetime.now() always carries fresh seconds/
|
# resolution, but datetime.now()'s fresh seconds/microseconds would
|
||||||
# microseconds, so an unrounded result changes on nearly every poll
|
# otherwise change the result on nearly every poll, flooding the
|
||||||
# even when the device-reported remaining time hasn't. That floods
|
# recorder with values that look identical once the UI rounds them.
|
||||||
# the recorder history/logbook with values that look identical once
|
|
||||||
# the UI rounds them down for display.
|
|
||||||
finish = datetime.now(UTC) + timedelta(seconds=total_s)
|
finish = datetime.now(UTC) + timedelta(seconds=total_s)
|
||||||
return finish.replace(second=0, microsecond=0)
|
return finish.replace(second=0, microsecond=0)
|
||||||
|
|
||||||
@@ -147,15 +145,11 @@ OPERATIONAL_STATE = Capability(
|
|||||||
translation_key="machine_state",
|
translation_key="machine_state",
|
||||||
value_fn=_to_ocf,
|
value_fn=_to_ocf,
|
||||||
),
|
),
|
||||||
# cycle_active is a bool derived from machine_state; used by the
|
# cycle_active is a bool derived from machine_state, gated on
|
||||||
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
# progress too since firmware keeps state='Run' after progress
|
||||||
# Harmless for non-oven appliances — just an extra bool in state.
|
# reaches 'Finish' (a stuck 'Running' indication otherwise). Named
|
||||||
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
|
# 'Running' in the catalog, not 'Cycle active' -- this href is
|
||||||
# so we also gate on progress to avoid a stuck 'Running' indication.
|
# shared with oven, and 'cycle' is laundry-specific vocabulary.
|
||||||
# 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(
|
BinarySensorDesc(
|
||||||
key="cycle_active",
|
key="cycle_active",
|
||||||
device_class="running",
|
device_class="running",
|
||||||
@@ -183,9 +177,8 @@ OPERATIONAL_STATE = Capability(
|
|||||||
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# Only show finish time when machine is actively running. Samsung
|
# Only show finish time while actively running -- firmware leaves a
|
||||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
|
||||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
|
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
"""Capabilities for the oven family (Samsung NV7000BS-class).
|
"""Capabilities for the oven family (Samsung NV7000BS-class).
|
||||||
|
|
||||||
Resources verified against the live device via DTLS-CoAP.
|
Resources verified against the live device via DTLS-CoAP. See
|
||||||
See `local-tools/comparisons/oven-tree.md` for the full field reference.
|
`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:
|
Cycle start is not implemented: local-OCF cycle start isn't reproducible on
|
||||||
* Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
|
this firmware. Mode writes are also unreliable -- the oven rolls them back
|
||||||
— works even with Remote Control off.
|
once a cycle is active, so OVEN_MODE's SelectDesc is effectively read-only
|
||||||
|
in practice.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
@@ -45,26 +38,18 @@ SETPOINT_MIN_C = 30
|
|||||||
SETPOINT_MAX_C = 270
|
SETPOINT_MAX_C = 270
|
||||||
SETPOINT_STEP_C = 5
|
SETPOINT_STEP_C = 5
|
||||||
|
|
||||||
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
|
# Verified against issue #44's range dump: Bake mode's modeSpec reports
|
||||||
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
|
# tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept separate rather than
|
||||||
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
|
# converted from the Celsius bounds above, which are themselves unverified.
|
||||||
# constant set rather than converted from the Celsius bounds above, which
|
|
||||||
# are themselves unverified (no live dump; see module docstring).
|
|
||||||
SETPOINT_MIN_F = 175
|
SETPOINT_MIN_F = 175
|
||||||
SETPOINT_MAX_F = 550
|
SETPOINT_MAX_F = 550
|
||||||
SETPOINT_STEP_F = 5
|
SETPOINT_STEP_F = 5
|
||||||
|
|
||||||
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
|
# Mode options seen on NV7000BS-class. No dump exists so this list is
|
||||||
# from Samsung documentation and firmware observations. The firmware will
|
# inferred from Samsung documentation and firmware observations; the
|
||||||
# reject unknown modes; missing entries here are a coverage gap, not a bug.
|
# 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
|
||||||
# This is a 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.
|
||||||
# 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.
|
|
||||||
_OVEN_MODES = (
|
_OVEN_MODES = (
|
||||||
"NoOperation",
|
"NoOperation",
|
||||||
"Bake",
|
"Bake",
|
||||||
@@ -138,22 +123,18 @@ def _option_value(options, prefix):
|
|||||||
|
|
||||||
|
|
||||||
def _has_option(prefix):
|
def _has_option(prefix):
|
||||||
"""exists_fn for an options-array switch: bind only when the device's own
|
"""exists_fn for an options-array switch: bind only when the device's
|
||||||
options[] actually carries a `<prefix>_<value>` token.
|
own options[] actually carries a `<prefix>_<value>` token.
|
||||||
|
|
||||||
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
|
fast_preheat/natural_steam were shipped unconditionally (no exists_fn)
|
||||||
an unverified guess (see module docstring) -- issue #183's dump (model
|
as an unverified guess -- issue #183's dump reports neither token in
|
||||||
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
|
its options[] at all, so both switches were phantom controls that
|
||||||
options[] at all, so both switches were phantom controls: always read as
|
"don't appear to do anything."
|
||||||
off, and toggling them wrote a token the firmware never recognized in
|
|
||||||
the first place, hence "does not appear to do anything."
|
|
||||||
|
|
||||||
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
|
`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
|
identical exists_fn: a stub /device/0 seed rep has no options[] at all,
|
||||||
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
|
and without this a genuinely-present token would never get a first
|
||||||
without this an entity whose token is genuinely present would never get
|
chance to bind.
|
||||||
a first chance to bind, since exists_fn runs before that first real
|
|
||||||
fetch lands.
|
|
||||||
"""
|
"""
|
||||||
return lambda rep, resources: (
|
return lambda rep, resources: (
|
||||||
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
|
||||||
@@ -163,13 +144,10 @@ def _has_option(prefix):
|
|||||||
def _option_write(prefix, new_value):
|
def _option_write(prefix, new_value):
|
||||||
"""A one-token x.com.samsung.da.options write, mirroring
|
"""A one-token x.com.samsung.da.options write, mirroring
|
||||||
laundry.option_write. NOT independently confirmed on an oven -- issue
|
laundry.option_write. NOT independently confirmed on an oven -- issue
|
||||||
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
|
#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
|
this extrapolates the same contract here. If some oven replaces the
|
||||||
/mode/vs/0, on the assumption the firmware handles the array the same
|
field outright instead of merging, this would drop every other option
|
||||||
way there. If that assumption is wrong for some oven, a device that
|
on the next write -- revisit if a real device report surfaces 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}"]
|
return [f"{prefix}_{new_value}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -318,13 +296,11 @@ OVEN_CAVITY = Capability(
|
|||||||
|
|
||||||
|
|
||||||
def _oven_temp_unit(rep):
|
def _oven_temp_unit(rep):
|
||||||
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
|
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this aggregate
|
||||||
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
`/temperatures/vs/0` items[] resource carries a per-item `unit` field
|
||||||
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
that was previously hardcoded away (issue #7). Keeps the verified '°C'
|
||||||
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
default when the field is absent, but reads it live -- issue #44's
|
||||||
Keeps the verified '°C' default when the field is absent (the original
|
range dump is the first to report 'Fahrenheit' here."""
|
||||||
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 []
|
items = rep.get("x.com.samsung.da.items") or []
|
||||||
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
unit = items[0].get("x.com.samsung.da.unit") if items else None
|
||||||
return normalize_temp_unit(unit, default="°C")
|
return normalize_temp_unit(unit, default="°C")
|
||||||
@@ -404,17 +380,12 @@ OVEN_CONNECTED = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Static cavity capability metadata (count/type/supported features) -- no
|
# Static cavity capability metadata -- no per-cavity data varies at runtime
|
||||||
# per-cavity data varies at runtime on any dump seen so far (issue #44's
|
# on any dump seen so far. Bound with no entities purely for coverage.
|
||||||
# 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")
|
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
|
||||||
|
|
||||||
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
|
# Quick-recipe display blob (combi microwave, issue #121) -- every field
|
||||||
# string (language/menu/servingSize/option) with every field blank on the
|
# blank on the only dump seen, no documented write contract.
|
||||||
# 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")
|
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
|
||||||
|
|
||||||
OVEN_MODE = Capability(
|
OVEN_MODE = Capability(
|
||||||
@@ -462,9 +433,8 @@ OVEN_MODE = Capability(
|
|||||||
write_fn=_option_switch_write("NaturalSteam"),
|
write_fn=_option_switch_write("NaturalSteam"),
|
||||||
),
|
),
|
||||||
# 120-hour energy-saving standby (issue #183): confirmed present in
|
# 120-hour energy-saving standby (issue #183): confirmed present in
|
||||||
# this unit's options[] (EnergySaving_On) and directly requested --
|
# this unit's options[] -- unlike fast_preheat/natural_steam above,
|
||||||
# unlike fast_preheat/natural_steam above, this token is real on this
|
# this token is real on this hardware, just previously unbound.
|
||||||
# hardware, just previously unbound entirely.
|
|
||||||
SwitchDesc(
|
SwitchDesc(
|
||||||
key="energy_saving",
|
key="energy_saving",
|
||||||
field="x.com.samsung.da.options",
|
field="x.com.samsung.da.options",
|
||||||
|
|||||||
@@ -1,36 +1,32 @@
|
|||||||
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
|
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
|
||||||
model TP1X_DA-KS-RANGE-0102X).
|
model TP1X_DA-KS-RANGE-0102X).
|
||||||
|
|
||||||
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
|
Not to be confused with registry/capabilities/cooktop.py, which covers an
|
||||||
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
|
unrelated standalone-cooktop product (NA9300K-class) that encodes burner
|
||||||
burner state as strings inside /mode/vs/0's options array instead of the
|
state as strings inside /mode/vs/0's options array instead of the
|
||||||
structured /cooktop/status/vs/0 resource this module reads -- two different
|
structured /cooktop/status/vs/0 resource this module reads -- two
|
||||||
OCF surfaces that happen to share the English word "cooktop".
|
different OCF surfaces that happen to share the English word "cooktop".
|
||||||
|
|
||||||
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
|
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
|
names (no `x.com.samsung.da.` prefix).
|
||||||
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
|
|
||||||
|
|
||||||
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
|
`/cooktop/status/vs/0` carries every burner's live state in one
|
||||||
array (indexed by `burnerNumber`, not by a separate href per burner like
|
`burnerList` array (indexed by `burnerNumber`), so per-burner entities are
|
||||||
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
|
hardcoded up to MAX_BURNERS and gated by exists_fn against whichever
|
||||||
and gated by exists_fn against whichever indices the device actually
|
indices the device actually reports -- an index absent from burnerList
|
||||||
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
|
just never binds.
|
||||||
an index absent from burnerList just never binds.
|
|
||||||
|
|
||||||
Write surfaces here are unproven (no live device to verify against, same
|
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-
|
caveat as oven.py's RMW writes) -- power level uses the same
|
||||||
write pattern already proven safe elsewhere in this codebase (oven setpoint,
|
read-modify-write pattern already proven safe elsewhere in this codebase.
|
||||||
icemaker toggles).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .common import normalize_temp_unit
|
from .common import normalize_temp_unit
|
||||||
|
|
||||||
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
|
# Observed as high as 4; user-reported hardware with 5 burners exists.
|
||||||
# burners exists. Kept a little above both since exists_fn gates unused
|
# Kept a little above both since exists_fn gates unused slots out.
|
||||||
# slots out -- see module docstring.
|
|
||||||
MAX_BURNERS = 6
|
MAX_BURNERS = 6
|
||||||
|
|
||||||
|
|
||||||
@@ -140,11 +136,10 @@ COOKTOP_STATUS = Capability(
|
|||||||
poll_tier="hot",
|
poll_tier="hot",
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
|
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
|
||||||
# The cooktop section's own on/off (issue #86) -- distinct from
|
# 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
|
# common.POWER's whole-appliance switch. Read-only: no live device
|
||||||
# family (the range combo) additionally carry for the whole
|
# to confirm remotely turning it on wouldn't leave a burner active
|
||||||
# appliance. Read-only: no live device to confirm remotely turning
|
# unattended.
|
||||||
# a cooktop on wouldn't leave a burner active unattended.
|
|
||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key="cooktop_power",
|
key="cooktop_power",
|
||||||
field="power",
|
field="power",
|
||||||
@@ -153,8 +148,7 @@ COOKTOP_STATUS = Capability(
|
|||||||
value_fn=lambda v: str(v).lower() == "on",
|
value_fn=lambda v: str(v).lower() == "on",
|
||||||
),
|
),
|
||||||
# Safe to write -- a lock toggle, not a heat control -- via a
|
# Safe to write -- a lock toggle, not a heat control -- via a
|
||||||
# direct single-field PUT (no RMW needed; unlike burnerList this
|
# direct single-field PUT, no RMW needed.
|
||||||
# is a lone scalar, not an array of siblings to preserve).
|
|
||||||
SwitchDesc(
|
SwitchDesc(
|
||||||
key="cooktop_child_lock",
|
key="cooktop_child_lock",
|
||||||
field="childLock",
|
field="childLock",
|
||||||
@@ -169,16 +163,14 @@ COOKTOP_STATUS = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Static burner-count/power-level-list metadata, read directly by
|
# Static burner-count/power-level-list metadata, read directly by
|
||||||
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
|
# COOKTOP_STATUS's power-level select (options=_power_level_options)
|
||||||
# than exposed through its own entity -- same "informs another capability,
|
# rather than exposed through its own entity.
|
||||||
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
|
|
||||||
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
|
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
|
||||||
|
|
||||||
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
|
# 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
|
# duration; state on/off is whether the feature itself is enabled -- not a
|
||||||
# feature itself is enabled -- not a live "surface is hot right now" alert
|
# live "surface is hot right now" alert (that's COOKTOP_STATUS's per-burner
|
||||||
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
|
# hot_surface). No write contract verified, so read-only for now.
|
||||||
# verified, so read-only for now.
|
|
||||||
COOKTOP_SAFETY = Capability(
|
COOKTOP_SAFETY = Capability(
|
||||||
href="/cooktop/settings/status/vs/0",
|
href="/cooktop/settings/status/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -194,9 +186,8 @@ COOKTOP_SAFETY = Capability(
|
|||||||
|
|
||||||
# Bluetooth meat probe (issue #86). All-idle sentinel values when
|
# Bluetooth meat probe (issue #86). All-idle sentinel values when
|
||||||
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
|
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
|
||||||
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of
|
# gating, matching cooktop.PAIRED_HOOD_STATUS's precedent of showing a
|
||||||
# showing a disconnected accessory's fields plainly rather than hiding the
|
# disconnected accessory's fields plainly rather than hiding the capability.
|
||||||
# whole capability.
|
|
||||||
PROBE_STATUS = Capability(
|
PROBE_STATUS = Capability(
|
||||||
href="/bluetooth/probe/status/vs/0",
|
href="/bluetooth/probe/status/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -232,14 +223,12 @@ PROBE_STATUS = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
|
# Some range boards (issue #74) report no /cooktop/status/vs/0 burner
|
||||||
# burner array at all -- their local API only exposes this coarse
|
# array at all -- their local API only exposes this coarse monitoring
|
||||||
# monitoring resource for the cooktop half, with no per-burner detail.
|
# resource, with no per-burner detail. Meaning of `cooktopMonitoring`
|
||||||
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
|
# (bare "0" on the only dump seen) and `warmingCenterState`'s full value
|
||||||
# `warmingCenterState`'s full value set aren't confirmed, so both are
|
# set aren't confirmed, so both are plain sensors rather than a guessed
|
||||||
# exposed as plain sensors rather than guessed at as a switch/select --
|
# switch/select.
|
||||||
# `supportedHoodLampStateList` has no corresponding live-state field on
|
|
||||||
# this resource, so nothing to bind it to yet.
|
|
||||||
COOKTOP_MONITORING = Capability(
|
COOKTOP_MONITORING = Capability(
|
||||||
href="/cooktopmonitoring/vs/0",
|
href="/cooktopmonitoring/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
|
|||||||
@@ -182,18 +182,13 @@ HOOD_FILTER = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# After Run (issue #147): the hood keeps the fan running at low speed for a
|
# After Run (issue #147): the hood keeps the fan running at low speed after
|
||||||
# while after it's switched off, to clear residual cooking smoke -- a
|
# it's switched off, to clear residual cooking smoke -- a feature a user
|
||||||
# feature a user actively watches and cancels, not passive diagnostics, so
|
# actively watches and cancels, so none of the three entities below carry
|
||||||
# none of the three entities below carry entity_category. No
|
# entity_category. No supported-values list is advertised for
|
||||||
# supported-values list is advertised for activationState, so it's modeled
|
# activationState, so it's read-only monitoring rather than an invented
|
||||||
# read-only (monitoring, not an invented "enable" write) per the 'don't
|
# "enable" write; runningCancel's only observed value is the command name
|
||||||
# guess' rule; runningCancel's only observed value is the command name
|
# itself ('Cancel'), the same shape as operational.STOP_BUTTON.
|
||||||
# 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 = Capability(
|
AFTER_RUN = Capability(
|
||||||
href="/afterrun/vs/0",
|
href="/afterrun/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
|
|||||||
@@ -29,62 +29,33 @@ from .laundry import (
|
|||||||
option_write,
|
option_write,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Course_XX hex code labels (translations/en.json,
|
||||||
# Course_XX hex codes. 23 of the codes named in translations/en.json
|
# washer_cycle_table_02.state.<id>) come from several devices, cross-checked
|
||||||
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
|
# rather than guessed: 23 codes from a live WW90DG6U25LEU4's editCourseList,
|
||||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
# matched positionally against a user's app screenshots and the printed
|
||||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
# manual (issue #2); 5 more (Wash+Dry, Air Wash, Cotton Dry, Synthetics Dry,
|
||||||
# positionally against a Slovak-UI user's screenshots of their app's course
|
# a second distinct '1F' Intense Cold) from a WD90T654DBN/S1 combo's own
|
||||||
# list (same order, same count -- see issue #2) and cross-checked against
|
# editCourseList and screenshots (issue #22, a combo's own course set, not
|
||||||
# the printed user manual's course table (confirming e.g. '8F' as 'Intense
|
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
|
||||||
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a
|
# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
|
||||||
# cycle the manual marks "applicable models only" and that does not appear
|
# code after selecting each cycle on the appliance (issue #80). Two code
|
||||||
# in this device's editCourseList -- nor does 'AI Wash', also "applicable
|
# pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels)
|
||||||
# models only"). FixedCourseList_1C29 (the two courses always pinned in the
|
# legitimately share a label across different course tables -- not typos.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
|
# No static fallback list is kept here: other models have different actual
|
||||||
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
|
# course sets, so hardcoding one device's list would show/hide the wrong
|
||||||
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
|
# options elsewhere. laundry.cycle_options() reads only the live
|
||||||
# combo's editCourseList and were named from that user's app screenshot
|
# x.com.samsung.da.editCourseList; a device that doesn't populate it gets no
|
||||||
# (issue #22). Combo units carry their own course set, so these codes
|
# cycle select at all (see cycle_select's exists_fn). x.com.samsung.da.
|
||||||
# don't imply anything about '1F' on a plain washer.
|
# options' MostUsed_* entry was considered as a fallback source (its first
|
||||||
#
|
# byte matches the selected Course_XX on both dumps), but the remaining
|
||||||
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a
|
# bytes don't decode to any confirmed course code, so it isn't used.
|
||||||
# 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.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# /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
|
# 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
|
# 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
|
# 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.
|
# given device, since dryer and washer are separate by_type registries.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
WASHER_SETTINGS = Capability(
|
WASHER_SETTINGS = Capability(
|
||||||
href="/washer/vs/0",
|
href="/washer/vs/0",
|
||||||
@@ -123,9 +94,8 @@ WASHER_SETTINGS = Capability(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
# Washer/dryer combo units carry a dryLevel field on the wash
|
# Washer/dryer combo units carry a dryLevel field on the wash
|
||||||
# resource itself (no separate dryer device/course) -- see issue
|
# resource itself (issue #22). Self-gates off on plain washers,
|
||||||
# #22. Self-gates off on plain washers, which never report
|
# which never report supportedDryLevel.
|
||||||
# supportedDryLevel.
|
|
||||||
SelectDesc(
|
SelectDesc(
|
||||||
key="dry_level",
|
key="dry_level",
|
||||||
field="x.com.samsung.da.dryLevel",
|
field="x.com.samsung.da.dryLevel",
|
||||||
@@ -142,12 +112,9 @@ WASHER_SETTINGS = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
|
# /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
|
# drum-clean and dispenser-dosing entities below are washer-specific reads
|
||||||
# the same options array.
|
# off the same options array.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
|
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
|
||||||
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
|
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
|
||||||
@@ -157,30 +124,17 @@ WASHER_SETTINGS = Capability(
|
|||||||
|
|
||||||
# Detergent/softener auto-dispense dosing, from the same options[] array
|
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||||
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
||||||
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
|
# '<Prefix>Level2Ctrl_<code>' is a second dial (water hardness for
|
||||||
# detergent, concentration for softener -- matching the SmartThings app's
|
# detergent, concentration for softener), matching the app's two-field
|
||||||
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
|
# dispenser screens. 'Supported<Prefix>Ctrl_<hexpairs>' lists the valid raw
|
||||||
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
|
# codes, same hex-pair shape as EditCourseList. '<Prefix>Alarm_<On/Off>' is
|
||||||
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
|
# a low-reservoir warning flag.
|
||||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
|
||||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
|
||||||
#
|
#
|
||||||
# Label mapping (entity.select.{detergent,softener}_quantity /
|
# Label mapping (translations/en.json's {detergent,softener}_quantity /
|
||||||
# detergent_water_hardness / softener_concentration in translations/en.json) is an
|
# detergent_water_hardness / softener_concentration) is an assumed reading
|
||||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
# of the single issue #9 dump + screenshots, cross-checked against the
|
||||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
# selected value on both dispensers, not independently verified per code --
|
||||||
# on-screen equivalent -- the app's Quantité picker only offers
|
# revisit if a second device's dump contradicts it.
|
||||||
# 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.
|
|
||||||
def _supported_level_options(resources, prefix):
|
def _supported_level_options(resources, prefix):
|
||||||
rep = resources.get("/course/vs/0") or {}
|
rep = resources.get("/course/vs/0") or {}
|
||||||
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
|
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
|
||||||
@@ -192,15 +146,13 @@ def _level_options(prefix):
|
|||||||
|
|
||||||
|
|
||||||
def _dosing_level(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>`
|
||||||
The device reports the selected level as `<prefix>_<code>` with the code
|
un-padded (e.g. '3'), but the select's own options come from
|
||||||
un-padded (e.g. '3'), but the valid codes -- which are also this select's
|
`Supported<prefix>_<hexpairs>` as zero-padded hex pairs (e.g. '03').
|
||||||
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
|
Left as '3', the value sits outside the select's own option list and
|
||||||
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
|
HA renders it 'unknown' (issue #9) -- resolve it to the matching
|
||||||
outside the select's own option list, so HA renders it 'unknown' (issue #9).
|
zero-padded code instead."""
|
||||||
Resolve it to the supported code with the same integer value so
|
|
||||||
current_option matches an option (and its translation)."""
|
|
||||||
|
|
||||||
def fn(rep):
|
def fn(rep):
|
||||||
opts = rep.get("x.com.samsung.da.options")
|
opts = rep.get("x.com.samsung.da.options")
|
||||||
@@ -227,9 +179,8 @@ def _level_write(prefix):
|
|||||||
def write(p, rep, href=None):
|
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
|
return None
|
||||||
# `p` is the zero-padded supported code the UI selected (e.g. '03');
|
# `p` is the zero-padded supported code (e.g. '03'); the device
|
||||||
# the device stores the level un-padded (e.g. '3'), matching how it
|
# stores it un-padded (e.g. '3'), matching how it's reported.
|
||||||
# reports it, so write it back in that native shape.
|
|
||||||
try:
|
try:
|
||||||
native = format(int(p, 16), "X")
|
native = format(int(p, 16), "X")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -248,37 +199,28 @@ def _dosing_low(prefix):
|
|||||||
|
|
||||||
|
|
||||||
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
|
# 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
|
# array (issue #22 follow-up). Each rides as a plain '<Prefix>_On'/'_Off'
|
||||||
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
|
# token, confirmed against a dump taken with Bubble Soak switched on in the
|
||||||
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
|
# app -- the same shape as AiOption/KidsLockBypass in this array.
|
||||||
# already used by AiOption and KidsLockBypass in this same array, so
|
|
||||||
# PreWashSetting/IntensiveSetting are assumed to follow suit.
|
|
||||||
#
|
#
|
||||||
# Each also has a differently-named hex-pair availability field that lines up
|
# Each also has a hex-pair availability field positional with
|
||||||
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
|
# editCourseList (BubbleSoakSet, PreWashAvailableSet,
|
||||||
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
|
# IntensiveAvailableSet): on the reporter's dump 'F0' at a course's
|
||||||
# 24), all three read 'F0' at that position and the toggle was writable --
|
# position matched the app enabling the control there, '00' matched it
|
||||||
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
|
# grayed out. exists_fn only runs once at setup, so it can't do this
|
||||||
# Off') decodes to '00' for that course, matching the app graying the
|
# per-course check -- validate_fn runs on every write attempt instead,
|
||||||
# control out there. 'F0'/'00' is treated as available/unavailable on that
|
# rejecting an on-write for a course whose byte isn't 'F0' with a
|
||||||
# evidence. exists_fn (device-level presence) still only runs once, against
|
# user-facing error rather than silently no-opping. The read/write/
|
||||||
# the setup-time snapshot, so it isn't a fit for this per-course check --
|
# presence machinery is laundry.bool_option_switch, shared with
|
||||||
# validate_fn runs on every write attempt instead (dispatched from
|
# dishwasher's storm-wash/auto-release-dry toggles; only this per-course
|
||||||
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
|
# gating is washer-only.
|
||||||
# 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).
|
|
||||||
def _bool_option_switch(key, icon, prefix, availability_field):
|
def _bool_option_switch(key, icon, prefix, availability_field):
|
||||||
def validate(p, rep, resources):
|
def validate(p, rep, resources):
|
||||||
"""Reject turning on when the selected course's byte in
|
"""Reject turning on when the selected course's byte in
|
||||||
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
`availability_field` isn't 'F0'. Turning off is never blocked.
|
||||||
back to allowing the write whenever the availability data can't be
|
Falls back to allowing the write whenever the availability data
|
||||||
resolved (unrecognized course, missing/mismatched-length bitmap)
|
can't be resolved (unrecognized course, missing/mismatched-length
|
||||||
rather than guessing -- a false rejection is worse than an
|
bitmap) -- a false rejection is worse than an occasional no-op."""
|
||||||
occasional no-op write."""
|
|
||||||
if p != "On":
|
if p != "On":
|
||||||
return None
|
return None
|
||||||
opts = rep.get("x.com.samsung.da.options") or []
|
opts = rep.get("x.com.samsung.da.options") or []
|
||||||
|
|||||||
@@ -25,19 +25,13 @@ DISPENSE = Capability(
|
|||||||
{"x.com.samsung.da.desiredType": p},
|
{"x.com.samsung.da.desiredType": p},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# Only a handful of discrete temperatures are selectable (not a
|
# Only a handful of discrete temperatures are selectable -- a select
|
||||||
# continuous range) -- a select over the live-reported set, not a
|
# over the live-reported set, not a number with invented bounds.
|
||||||
# number with invented bounds.
|
# Newer boards (issue #196) don't populate supportedHotTemperatures
|
||||||
#
|
# at all, reporting a hotwaterRange/hotwaterLevel pair instead with
|
||||||
# Newer boards (issue #196, RWP70F15ANW) don't populate
|
# no confirmed write contract -- gate the entity off entirely there
|
||||||
# supportedHotTemperatures at all -- they report a hotwaterRange
|
# rather than guess at that pair's meaning (an empty options list
|
||||||
# (min/max) and a hotwaterLevel (step count?) instead, with no
|
# otherwise left current_option rendering "unknown").
|
||||||
# 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(
|
SelectDesc(
|
||||||
key="hot_water_temperature",
|
key="hot_water_temperature",
|
||||||
field="x.com.samsung.da.tempDesiredHotWater",
|
field="x.com.samsung.da.tempDesiredHotWater",
|
||||||
@@ -53,12 +47,10 @@ DISPENSE = Capability(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
# Bounds and step come live from the device's own
|
# Bounds and step come live from the device's own
|
||||||
# desiredCapacityRange/capacityResolution fields, not a hardcoded
|
# desiredCapacityRange/capacityResolution, not a hardcoded constant.
|
||||||
# constant -- see the adding-device-support skill's "never hard-code
|
# No unit is set: capacityUnit reads "C" on this dump, which can't
|
||||||
# the one dump's values" section. No unit is set: capacityUnit reads
|
# be right for a volume field, so it's left unset rather than
|
||||||
# "C" on this dump, which can't be right for a volume field, so per
|
# assumed to be mL.
|
||||||
# the 'don't guess' rule the unit is left unset rather than assumed
|
|
||||||
# to be mL.
|
|
||||||
NumberDesc(
|
NumberDesc(
|
||||||
key="dispense_capacity",
|
key="dispense_capacity",
|
||||||
field="x.com.samsung.da.desiredCapacity",
|
field="x.com.samsung.da.desiredCapacity",
|
||||||
@@ -159,24 +151,16 @@ FAVORITE_CAPACITY = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
|
|
||||||
# for the hot water dispensed alongside brewing, same shape as
|
|
||||||
# FAVORITE_CAPACITY above.
|
|
||||||
|
|
||||||
|
|
||||||
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
|
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
|
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
|
||||||
still ambiguous:
|
still ambiguous: href absent -> True (fallback may claim the entity);
|
||||||
|
href present but an unfetched stub ({}) -> False (pending, not
|
||||||
- href entirely absent from this device -> definitely no clash, the
|
confirmed absence -- LOCK's own exists_fn optimistically includes
|
||||||
switchHotwater fallback below may claim the entity.
|
itself through a stub too, so returning True would register both
|
||||||
- href present but an unfetched stub ({}) -> outcome pending, *not* a
|
descriptors under one key until the next poll); href present and
|
||||||
confirmed absence. LOCK's own exists_fn optimistically includes itself
|
fetched -> the real answer."""
|
||||||
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")
|
rep = resources.get("/status/lock/vs/0")
|
||||||
if rep is None:
|
if rep is None:
|
||||||
return True
|
return True
|
||||||
@@ -189,24 +173,15 @@ FAVORITE_HOTWATER = Capability(
|
|||||||
href="/favorite/hotwater/vs/0",
|
href="/favorite/hotwater/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
entities=(
|
entities=(
|
||||||
# Despite the resource/field naming, switchHotwater's value domain is
|
# Despite the naming, switchHotwater's value domain is
|
||||||
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
|
# Locked/Unlocked, not an enable flag (issue #144) -- the same
|
||||||
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
|
# hot-water lock as LOCK.hotwater_lock below, surfaced through this
|
||||||
# this href on boards that don't populate /status/lock/vs/0's
|
# href on boards that don't populate /status/lock/vs/0's
|
||||||
# hotwaterLock field. Shares that descriptor's key so only one "Hot
|
# hotwaterLock. Shares that descriptor's key so only one "Hot water
|
||||||
# water lock" entity ever appears.
|
# lock" entity appears; both halves need an exists_fn since
|
||||||
#
|
# adapter.flatten() only ever honors exists_fn, not entity.py's
|
||||||
# Both halves of this fallback pair need an exists_fn, not just this
|
# implicit field-presence default -- without it, whichever
|
||||||
# one: adapter.flatten() (the coordinator.data source every entity's
|
# same-keyed descriptor is processed last would silently win.
|
||||||
# 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(
|
SwitchDesc(
|
||||||
key="hotwater_lock",
|
key="hotwater_lock",
|
||||||
field="x.com.samsung.da.switchHotwater",
|
field="x.com.samsung.da.switchHotwater",
|
||||||
@@ -222,18 +197,12 @@ FAVORITE_HOTWATER = Capability(
|
|||||||
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
|
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# Issue #196: `supportedList` is only the four *fixed* presets
|
# Issue #196: `supportedList` is only the four fixed presets -- the
|
||||||
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
|
# app also lets the user add one custom value to their own display
|
||||||
# the user add one custom value to their own display list via its
|
# list, which shows up in `showList` but never in `supportedList`.
|
||||||
# "temperatures to display" editor (a wheel picker bounded by
|
# Reading from `supportedList` meant a unit whose current default
|
||||||
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
|
# was that custom value rendered as "unknown"; `showList` is a
|
||||||
# and that custom value shows up in `showList`
|
# superset that always includes the actual current default.
|
||||||
# (['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(
|
SelectDesc(
|
||||||
key="favorite_hotwater_temperature",
|
key="favorite_hotwater_temperature",
|
||||||
field="x.com.samsung.da.favorite.defaultTemperature",
|
field="x.com.samsung.da.favorite.defaultTemperature",
|
||||||
@@ -291,11 +260,9 @@ CUP_STATE = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
|
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
|
||||||
# air_purifier.py's same-named hrefs, but this board's own values differ
|
# air_purifier.py's same-named hrefs, but this board's own supportedModes
|
||||||
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
|
# (voice/fixedTone/mute) differs from both, so these read the device's own
|
||||||
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
|
# supported list/range rather than reusing either.
|
||||||
# reject a live-supported value, so these read the device's own supported
|
|
||||||
# list/range like air_purifier's versions do.
|
|
||||||
SOUND_MODE = Capability(
|
SOUND_MODE = Capability(
|
||||||
href="/settings/sound/mode/vs/0",
|
href="/settings/sound/mode/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -359,9 +326,8 @@ SOUND_VOLUME = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
|
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
|
||||||
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
|
# (no sibling unit field on this resource) so it's left unitless rather
|
||||||
# which at least has an -- albeit suspect -- capacityUnit) so it's left
|
# than assumed to be mL.
|
||||||
# unitless rather than assumed to be mL.
|
|
||||||
STATISTIC_POUR = Capability(
|
STATISTIC_POUR = Capability(
|
||||||
href="/statistic/pour/vs/0",
|
href="/statistic/pour/vs/0",
|
||||||
poll_tier="cold",
|
poll_tier="cold",
|
||||||
@@ -387,12 +353,8 @@ LOCK = Capability(
|
|||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
entities=(
|
entities=(
|
||||||
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
||||||
# above (issue #144); see the comment there for why this half also
|
# above (issue #144); see the comment there. A stub rep ({}) still
|
||||||
# needs an explicit exists_fn now that the two share a key in
|
# counts as "present" here, matching entity.py's own default.
|
||||||
# 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(
|
SwitchDesc(
|
||||||
key="hotwater_lock",
|
key="hotwater_lock",
|
||||||
field="x.com.samsung.da.hotwaterLock",
|
field="x.com.samsung.da.hotwaterLock",
|
||||||
@@ -430,29 +392,21 @@ LOCK = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
|
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
|
||||||
# confirmed contract, following the 'don't guess' rule.
|
# confirmed contract, following the 'don't guess' rule.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_WP_IGNORED = [
|
_WP_IGNORED = [
|
||||||
# supportedModes carries a single opaque wizard-workflow token
|
# supportedModes carries a single opaque wizard-workflow token and
|
||||||
# ('HOMECARE_WIZARD_V2') and modes reports a completely different,
|
# modes reports an unrelated value not even in supportedModes --
|
||||||
# unrelated value ('WATERFILTER_DISABLE') not even present in
|
# internal plumbing, not a real mode select.
|
||||||
# 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",
|
"/mode/vs/0",
|
||||||
# Static support-flags blob (automation.supported.modes/options) -- no
|
# Static support-flags blob -- no live "current setting" field.
|
||||||
# live "current automation setting" field to expose.
|
|
||||||
"/automation/waterpurifier/vs/0",
|
"/automation/waterpurifier/vs/0",
|
||||||
# Coffee-capable variant (issue #107). All four are static
|
# Coffee-capable variant (issue #107): static capability-advertisement
|
||||||
# capability-advertisement blobs or empty -- no live "current recipe" /
|
# blobs or empty, unlike /favorite/coffee/vs/0 (COFFEE above) which
|
||||||
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0
|
# does carry live brew status.
|
||||||
# (COFFEE above), which does carry live brew status.
|
|
||||||
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata
|
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata
|
||||||
"/coffee/custom/recipe/vs/0", # publisher.support: allowed custom-recipe slot IDs
|
"/coffee/custom/recipe/vs/0", # allowed custom-recipe slot IDs
|
||||||
"/recipe/coffee/vs/0", # same publisher.support shape, no per-recipe content
|
"/recipe/coffee/vs/0", # same shape, no per-recipe content
|
||||||
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
|
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ class BoundEntity:
|
|||||||
key_override: str | None = None
|
key_override: str | None = None
|
||||||
instance_name: str | None = None
|
instance_name: str | None = None
|
||||||
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
||||||
# `href` above is always the *actual*, on-the-wire href for that
|
# `href` above is always the actual, on-the-wire href for that
|
||||||
# subdevice -- MAIN's
|
# subdevice -- MAIN's to_actual is the identity transform, so a device
|
||||||
# to_actual is the identity transform, so every device with no subdevices
|
# with no subdevices is unaffected.
|
||||||
# behaves exactly as before this field existed.
|
|
||||||
subdevice: Subdevice = MAIN
|
subdevice: Subdevice = MAIN
|
||||||
|
|
||||||
|
|
||||||
@@ -101,24 +100,19 @@ def discover(
|
|||||||
tier_log: Callable[[str, str], None] | None = None,
|
tier_log: Callable[[str, str], None] | None = None,
|
||||||
subdevice: Subdevice = MAIN,
|
subdevice: Subdevice = MAIN,
|
||||||
) -> list[BoundEntity]:
|
) -> list[BoundEntity]:
|
||||||
"""`tier_log(href, poll_tier)` fires for every href a capability actually
|
"""`tier_log(href, poll_tier)` fires for every href a capability
|
||||||
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
|
actually matches, even a no-entity "coverage-only" capability that
|
||||||
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows.
|
`_bind()` turns into zero `BoundEntity` rows. Callers that need a
|
||||||
Callers that need a href's poll cadence (the coordinator's hot/warm
|
href's poll cadence must use this, not `bound` -- a coverage-only
|
||||||
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
|
capability's `poll_tier` would otherwise never appear in `bound`.
|
||||||
coverage-only capability's `poll_tier` would otherwise be silently
|
|
||||||
dropped since it never appears in `bound`.
|
|
||||||
|
|
||||||
`resources` is always keyed by *canonical* hrefs -- for a subdevice
|
`resources` is always keyed by canonical hrefs -- for a subdevice
|
||||||
(issue #177) that means its own canonical view (see
|
(issue #177), its own canonical view (see subdevices.canonical_view),
|
||||||
subdevices.canonical_view), the same shape as a plain single-subdevice
|
the same shape as a single-subdevice device's resources dict, so
|
||||||
device's resources dict, so registry lookups/rt_filter/match_fn/
|
registry lookups behave identically regardless of which subdevice is
|
||||||
instance_suffix all behave identically regardless of which subdevice is
|
being discovered. `subdevice` only affects the href stamped onto each
|
||||||
being discovered.
|
BoundEntity and the href `log`/`tier_log` report -- the real,
|
||||||
`subdevice` only affects the *href* stamped onto each BoundEntity (via
|
subscribable/pollable path, not the canonical one.
|
||||||
`_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.
|
|
||||||
"""
|
"""
|
||||||
out: list[BoundEntity] = []
|
out: list[BoundEntity] = []
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ class SamsungEntityDescription:
|
|||||||
# here, so a descriptor only sets this to share one catalog entry across
|
# here, so a descriptor only sets this to share one catalog entry across
|
||||||
# several descriptors, or to point at a differently-named one.
|
# several descriptors, or to point at a differently-named one.
|
||||||
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
||||||
# callable form receives the coordinator's full href->rep resource
|
# callable form receives the full href->rep snapshot and returns the key
|
||||||
# snapshot and returns the key to use -- for a descriptor shared across
|
# to use -- for a descriptor shared across board generations whose
|
||||||
# board generations whose state-code meaning isn't guaranteed consistent
|
# state-code meaning isn't consistent between them; see
|
||||||
# between them; see laundry.cycle_select's table-id-gated resolver.
|
# laundry.cycle_select's table-id-gated resolver.
|
||||||
translation_placeholders: Mapping[str, str] | None = None
|
translation_placeholders: Mapping[str, str] | None = None
|
||||||
# Dynamic resources such as fridge compartments and ice makers use a
|
# Dynamic resources such as fridge compartments and ice makers use a
|
||||||
# device-provided or href-derived instance label inside a translated name.
|
# device-provided or href-derived instance label inside a translated name.
|
||||||
@@ -59,10 +59,9 @@ class SensorDesc(SamsungEntityDescription):
|
|||||||
unit: str | None = None
|
unit: str | None = None
|
||||||
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
|
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'
|
options: tuple | None = None # required by HA when device_class == 'enum'
|
||||||
# Opt-in: gate this sensor's reported value behind the user-configurable
|
# Opt-in: gate this value behind CONF_FINISH_TIME_HYSTERESIS_MINUTES
|
||||||
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
|
# (see sensor.py). Only for values expected to jitter between
|
||||||
# values that are expected to jitter around their "true" value between
|
# device-side revisions -- not a general-purpose flag.
|
||||||
# device-side revisions -- not a general-purpose flag every sensor should set.
|
|
||||||
hysteresis: bool = False
|
hysteresis: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -102,10 +101,9 @@ class NumberDesc(SamsungEntityDescription):
|
|||||||
native_min: float | None = None
|
native_min: float | None = None
|
||||||
native_max: float | None = None
|
native_max: float | None = None
|
||||||
step: float | None = None
|
step: float | None = None
|
||||||
# Override native_min/native_max/step from the live rep, when set --
|
# Override native_min/max/step from the live rep, when set -- same
|
||||||
# same "static default, live override" shape as unit_fn, for resources
|
# "static default, live override" shape as unit_fn, for resources whose
|
||||||
# whose sane bounds depend on a per-device value (e.g. a temperature
|
# bounds depend on a per-device value (e.g. Celsius vs. Fahrenheit).
|
||||||
# setpoint reported in Celsius on one device, Fahrenheit on another).
|
|
||||||
native_min_fn: Callable[[dict], float] | None = None
|
native_min_fn: Callable[[dict], float] | None = None
|
||||||
native_max_fn: Callable[[dict], float] | None = None
|
native_max_fn: Callable[[dict], float] | None = None
|
||||||
step_fn: Callable[[dict], float] | None = None
|
step_fn: Callable[[dict], float] | None = None
|
||||||
@@ -120,27 +118,23 @@ class TimeDesc(SamsungEntityDescription):
|
|||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class ClimateDesc(SamsungEntityDescription):
|
class ClimateDesc(SamsungEntityDescription):
|
||||||
# A composite entity: it binds one *primary* resource (its href) but the
|
# Composite entity: binds one primary resource (its href) but the
|
||||||
# climate platform reads sibling resources (power, temperature, wind) from
|
# climate platform reads sibling resources from the coordinator
|
||||||
# the coordinator snapshot and writes to several of them. write_fn takes a
|
# snapshot and writes to several of them. write_fn takes a (kind,
|
||||||
# (kind, value) payload from the platform and returns the (path_segs, body)
|
# value) payload and returns the (path_segs, body) for that sub-write.
|
||||||
# for that one sub-write, so a single desc drives multi-resource writes.
|
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class FanDesc(SamsungEntityDescription):
|
class FanDesc(SamsungEntityDescription):
|
||||||
# Composite fan entity: reads power from /power/0 and speed/support data
|
# 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
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class WaterHeaterDesc(SamsungEntityDescription):
|
class WaterHeaterDesc(SamsungEntityDescription):
|
||||||
# Composite water_heater entity: binds one primary resource (its href,
|
# Composite water_heater entity, same (kind, value) -> (path_segs,
|
||||||
# typically an operation-mode resource) but the water_heater platform
|
|
||||||
# reads sibling resources (power, temperature) from the coordinator
|
|
||||||
# snapshot and writes to several of them. Same (kind, value) -> (path_segs,
|
|
||||||
# body) write_fn shape as ClimateDesc/FanDesc.
|
# body) write_fn shape as ClimateDesc/FanDesc.
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|||||||
@@ -22,24 +22,19 @@ def is_placeholder_serial(serial: str) -> bool:
|
|||||||
|
|
||||||
The ARTIK051_DONGLE_REF firmware family reports the literal string
|
The ARTIK051_DONGLE_REF firmware family reports the literal string
|
||||||
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
|
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
|
||||||
check doesn't catch it, and the resolved serial feeds both the HA
|
check doesn't catch it, and two such units on the same install silently
|
||||||
device-registry identifier and every entity's unique_id (entity.py), so
|
collide, dropping the second one's entities (issue #83).
|
||||||
two such units on the same install silently collide and the second one's
|
|
||||||
entities get dropped (issue #83).
|
|
||||||
|
|
||||||
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
|
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry family reports a
|
||||||
reports a flash-unset sentinel instead -- every character the same
|
flash-unset sentinel instead -- every character the same repeated hex
|
||||||
repeated hex digit (a washer and a dryer, two different physical units,
|
digit -- which the 'nothing' check doesn't catch either, aborting the
|
||||||
both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which the
|
second unit's config flow as already configured.
|
||||||
'nothing' check above doesn't catch either, so the second unit's config
|
|
||||||
flow aborted as already configured.
|
|
||||||
|
|
||||||
Lives here, rather than being duplicated in config_flow.py and
|
Lives here rather than duplicated in config_flow.py/coordinator.py: the
|
||||||
coordinator.py as it once was, because the config flow now resolves the
|
config flow resolves the serial once and persists it for the
|
||||||
serial once and persists it on the entry for the coordinator to seed its
|
coordinator to seed its registry keys from (issue #236), so two copies
|
||||||
registry keys from (issue #236). Two copies of this rule meant the two
|
of this rule could let the two sides disagree and orphan a registry
|
||||||
sides could disagree about what a device's identity is -- and a
|
entry.
|
||||||
disagreement is exactly what orphans a registry entry.
|
|
||||||
"""
|
"""
|
||||||
s = serial.strip()
|
s = serial.strip()
|
||||||
if s.lower().startswith("nothing"):
|
if s.lower().startswith("nothing"):
|
||||||
@@ -65,15 +60,12 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
|
|||||||
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||||
"""The model string to name and register a device under.
|
"""The model string to name and register a device under.
|
||||||
|
|
||||||
`model_num` is /information/vs/0's x.com.samsung.da.modelNum, which many
|
`model_num` is /information/vs/0's modelNum, which many boards report
|
||||||
boards report as `<model>|<board>` -- only the part before the pipe is the
|
as `<model>|<board>` -- only the part before the pipe is recognizable.
|
||||||
model a user would recognize. A board that reports no modelNum at all
|
A board reporting no modelNum falls back to /oic/p's mnmo. Shared with
|
||||||
falls back to /oic/p's mnmo, which read_identity already parsed.
|
resolve_serial's motivation: two copies of this split rule could let
|
||||||
|
the config flow and the coordinator's post-poll recompute disagree, and
|
||||||
Shared with resolve_serial's motivation: the config flow resolves this
|
a device renaming itself after the first poll is the visible symptom.
|
||||||
once and persists it on the entry, and the coordinator recomputes it after
|
|
||||||
the first poll. Two copies of the split rule would let those two disagree,
|
|
||||||
and a device that renames itself on the first poll is the visible symptom.
|
|
||||||
"""
|
"""
|
||||||
if model_num:
|
if model_num:
|
||||||
return model_num.split("|", 1)[0]
|
return model_num.split("|", 1)[0]
|
||||||
@@ -81,13 +73,11 @@ def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def device_display_name(device_type_name: str | None, model: str) -> str:
|
def device_display_name(device_type_name: str | None, model: str) -> str:
|
||||||
"""The HA device name for a resolved device type + model.
|
"""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
|
||||||
Shared by the config flow (which builds the entry's stored identity) and
|
device is first registered under matches what discovery produces later
|
||||||
the coordinator's post-discovery rebuild, so the name a device is first
|
-- otherwise every setup would rename the device once the first poll
|
||||||
registered under is the same string discovery would produce later --
|
landed."""
|
||||||
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"
|
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}"
|
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||||
|
|
||||||
@@ -120,15 +110,13 @@ def _get_links(sess, path) -> list:
|
|||||||
def _device_types(d: dict) -> tuple[str, ...]:
|
def _device_types(d: dict) -> tuple[str, ...]:
|
||||||
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
|
"""/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
|
The one standardized "what am I" field in OCF: alongside the generic
|
||||||
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
|
'oic.wk.d' it carries a concrete type like 'oic.d.airconditioner' or a
|
||||||
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
|
SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
|
||||||
now consults this first, ahead of board-part-number parsing, via
|
consults this first, via `for_device_by_oic_type`, but only a minority
|
||||||
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a
|
of dumps populate it, so the modelNum/description path stays
|
||||||
minority of dumps populate it, so the modelNum/description path stays
|
load-bearing. Kept whole in diagnostics (see `raw` below) so issue
|
||||||
load-bearing for everything else. It's also kept whole in diagnostics
|
reports keep surfacing types the table doesn't know about yet.
|
||||||
(see `raw` below) so incoming issue reports keep surfacing types that
|
|
||||||
table doesn't know about yet.
|
|
||||||
"""
|
"""
|
||||||
rt = d.get("rt")
|
rt = d.get("rt")
|
||||||
if isinstance(rt, str):
|
if isinstance(rt, str):
|
||||||
@@ -142,17 +130,13 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
|||||||
p = _get(sess, ["oic", "p"])
|
p = _get(sess, ["oic", "p"])
|
||||||
d = _get(sess, ["oic", "d"])
|
d = _get(sess, ["oic", "d"])
|
||||||
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
|
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
|
||||||
# RETRIEVE on it returns every Resource/Collection href this endpoint
|
# RETRIEVE returns every Resource/Collection href this endpoint hosts,
|
||||||
# hosts, not just the one /device/0 seed path the coordinator polls.
|
# not just /device/0. Relevant for the "Composite Device" model (issue
|
||||||
# Relevant for the OCF "Composite Device" model (issue #177: a single
|
# #177: one physical device exposing more than one logical subdevice,
|
||||||
# physical device -- one IP, one /oic/p -- exposing more than one logical
|
# each its own Collection). registry.subdevices.enumerate_subdevices
|
||||||
# subdevice, each as its own Collection resource, same rt shape as our own
|
# reads this to find a board's `/device/<n>` siblings -- that probing
|
||||||
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
|
# used to run right here on every _connect_session/reconnect and moved
|
||||||
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's
|
# to that module so it only runs once, at first discovery.
|
||||||
# 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"])
|
res = _get_links(sess, ["oic", "res"])
|
||||||
return DeviceIdentity(
|
return DeviceIdentity(
|
||||||
manufacturer=p.get("mnmn") or "Samsung",
|
manufacturer=p.get("mnmn") or "Samsung",
|
||||||
@@ -160,8 +144,8 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
|||||||
name=d.get("n") or "",
|
name=d.get("n") or "",
|
||||||
serial=serial,
|
serial=serial,
|
||||||
device_types=_device_types(d),
|
device_types=_device_types(d),
|
||||||
# Kept whole rather than field-by-field: these resources are outside
|
# Kept whole rather than field-by-field: outside the /device/0 dump
|
||||||
# the /device/0 dump diagnostics already captures, and we don't yet
|
# diagnostics already captures, and we don't yet know which fields
|
||||||
# know which of their fields will turn out to identify a device type.
|
# will turn out to identify a device type.
|
||||||
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
|
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,19 +30,12 @@ _SENSITIVE_SUBSTRINGS = (
|
|||||||
"secret",
|
"secret",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
|
# Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the
|
||||||
# with bare one- and two-letter keys that the rules above cannot see, being
|
# unit with bare one/two-letter keys too short for the substring rules above
|
||||||
# far too short to match on -- 'di' alone is a substring of 'condition',
|
# ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the
|
||||||
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
|
# 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
|
||||||
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
|
# that resource is `rt`, which is not redacted.
|
||||||
# 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"})
|
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,36 @@
|
|||||||
"""Subdevice ("composite device") support for one physical connection exposing
|
"""Subdevice ("composite device") support for one physical connection
|
||||||
more than one logical indoor subdevice -- issue #177.
|
exposing more than one logical indoor subdevice -- issue #177.
|
||||||
|
|
||||||
Two reporters, two different board families, two genuinely different
|
Three discovery patterns, unified by the same shape: a logical subdevice is
|
||||||
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
|
a seed collection path to poll, plus an href transform between the
|
||||||
session (see DESIGN-177.md section 1 for the full evidence trail; the two
|
canonical href the registry knows (e.g. `/mode/vs/0`) and the actual
|
||||||
diagnostics dumps this was built against come from the Pattern A and
|
on-the-wire href.
|
||||||
Pattern B reporters, respectively -- they each filed one of the two
|
|
||||||
reports this module unifies):
|
|
||||||
|
|
||||||
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's
|
- **Pattern A -- indexed siblings** (`ARTIK051_DONGLE_FAC_18K`). `/oic/res`
|
||||||
board). `/oic/res` lists three complete parallel resource sets whose
|
lists parallel resource sets by trailing index (`/mode/vs/0`,
|
||||||
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`,
|
`/mode/vs/1`, ...); each sibling has its own `/device/<n>` Collection.
|
||||||
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s
|
- **Pattern B -- UUID-prefixed tree** (`TP2X_FAC_BORA_21K`). `/oic/res`
|
||||||
batch carries only the index-0 hrefs -- the sibling subdevices are
|
hides the tree; `/subdevices/vs/0`'s `subdeviceIdList` gives the UUID.
|
||||||
reachable only via their own `/device/<n>` collection.
|
`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).
|
A non-empty seed batch is necessary but not sufficient for a candidate to
|
||||||
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
|
be a real second subdevice: an unused SmartThings slot (e.g. the Pattern A
|
||||||
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
|
reporter's own `/device/2`) answers the same shape with constant/echoed
|
||||||
same UUID appears as a literal href prefix in `/oic/res`
|
reps and no live state. Gating on resource shape would need per-family
|
||||||
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
|
domain knowledge, so `discover_partitioned` instead gates at the *entity*
|
||||||
that reporter's unit is narrower than early issue #177 writeups suggested: a
|
layer: a candidate is only materialized if it produces at least one live,
|
||||||
single individual `GET /<uuid>/information/vs/0` was read by hand through
|
non-`None`, primary (no `entity_category`), non-meter bound entity. The
|
||||||
the debug panel and came back carrying a different model/serial than the
|
meter exclusion (issue #214) covers a second failure mode: an unused slot
|
||||||
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
|
reporting a populated whole-appliance energy counter, which is the
|
||||||
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
|
appliance's own bookkeeping, not evidence of a second indoor unit -- see
|
||||||
second subdevice exists at that prefix, but not evidence that `GET
|
|
||||||
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
|
|
||||||
around) itself returns anything. Issue #205, the same unit on a later
|
|
||||||
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
|
|
||||||
So `enumerate_subdevices` tries it first (a future board might genuinely
|
|
||||||
expose it) and falls back, when it's empty, to probing every href the
|
|
||||||
master itself answered this cycle individually under the UUID prefix --
|
|
||||||
the only thing ever actually confirmed to work for this pattern -- on the
|
|
||||||
assumption that a composite device's siblings share the master's resource
|
|
||||||
surface. See `Subdevice.flat_hrefs`.
|
|
||||||
|
|
||||||
Pattern C -- UUID prefix advertised only via `/oic/res`
|
|
||||||
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
|
|
||||||
`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0`
|
|
||||||
(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer
|
|
||||||
subdevice's UUID appears nowhere except as the path prefix of the
|
|
||||||
`x.com.samsung.da.multidevice` link in `/oic/res`, and
|
|
||||||
`GET /<uuid>/device/0` answers the washer's own full Collection batch
|
|
||||||
(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's
|
|
||||||
transform with the UUID sourced from the link prefix instead of
|
|
||||||
`subdeviceIdList`.
|
|
||||||
|
|
||||||
All of these are "the same thing wearing different clothes": a logical subdevice is a
|
|
||||||
seed collection path to poll, plus an href transform between the canonical
|
|
||||||
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
|
|
||||||
detection signals don't overlap on either captured board (the Pattern A
|
|
||||||
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
|
|
||||||
no `/device/1`), so no disambiguation logic is needed --
|
|
||||||
`enumerate_subdevices` checks both and materializes any candidate whose
|
|
||||||
seed answers with a non-empty batch.
|
|
||||||
|
|
||||||
A non-empty seed batch is necessary but not sufficient for the *candidate*
|
|
||||||
to actually be a live second subdevice, though: the Pattern A reporter's
|
|
||||||
own board also has a `/device/2` -- a third, unused SmartThings slot --
|
|
||||||
that answers with the exact same 14-href shape as the real `/device/1`
|
|
||||||
sibling, populated with three constant/echoed/shape-only reps (a region
|
|
||||||
code identical to every other subdevice's, an /information rep echoing the
|
|
||||||
*same* model string as subdevice 1, and a /temperatures items[] entry with
|
|
||||||
an id/description but no current/desired/minimum/maximum reading) and
|
|
||||||
nothing resembling live climate state. Gating on *resource* shape/hrefs
|
|
||||||
turned out to be the wrong layer -- it would need per-family domain
|
|
||||||
knowledge (which hrefs mean "in use" for a washer's second drum, a
|
|
||||||
fridge's second compartment, ...) baked into a registry field before any
|
|
||||||
of those families could use this module at all. `discover_partitioned`
|
|
||||||
instead gates at the *entity* layer, after discovery+flattening: a
|
|
||||||
candidate is only kept if it produced at least one *primary* (no
|
|
||||||
`entity_category`), non-meter bound entity whose flattened value isn't
|
|
||||||
`None` -- e.g. the Pattern A reporter's /device/2 does flatten to an
|
|
||||||
`alarm_code` value, but that entity is diagnostic-category and derived from
|
|
||||||
an empty /alarms/vs/2, so it doesn't count. This reuses the same
|
|
||||||
primary/config/diagnostic taxonomy every registry already declares (see the
|
|
||||||
adding-device-support skill's entity-taxonomy section) instead of adding a
|
|
||||||
second, parallel domain-knowledge mechanism.
|
|
||||||
|
|
||||||
The meter carve-out is issue #214, and it's the same "an unused slot still
|
|
||||||
answers *something*" problem one layer further in: that reporter's
|
|
||||||
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
|
|
||||||
all empty {} -- the /device/2 shape above -- but which also reports a
|
|
||||||
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
|
|
||||||
that materialized the slot as a phantom second air conditioner. See
|
|
||||||
`_has_live_primary_entity`.
|
`_has_live_primary_entity`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -102,18 +48,15 @@ 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
|
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
|
||||||
# discovery signal (issue #241): a subdevice tree whose UUID is advertised
|
# discovery signal (issue #241).
|
||||||
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
|
|
||||||
_UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/")
|
_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
|
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
|
||||||
# second logical subdevice's Collection on this board (moved here from
|
# second subdevice's Collection (moved here from identity.py, issue #177,
|
||||||
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
|
# since the old read_identity fired these on every _connect_session
|
||||||
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
|
# including reconnects, when enumeration only needs to run once). A plain
|
||||||
# including every reconnect, for information enumeration only needs once).
|
# tolerated-404 RETRIEVE, not the kind of guess the write-contract
|
||||||
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
|
# 'don't guess' rule is about. Widen only if a board needs more siblings.
|
||||||
# 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.
|
|
||||||
_SPECULATIVE_DEVICE_INDICES = (1, 2)
|
_SPECULATIVE_DEVICE_INDICES = (1, 2)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,22 +65,20 @@ class Subdevice:
|
|||||||
"""One logical indoor subdevice reachable over a single physical
|
"""One logical indoor subdevice reachable over a single physical
|
||||||
connection.
|
connection.
|
||||||
|
|
||||||
`kind='main'` is the subdevice this config entry actually connects to and
|
`kind='main'` is the subdevice this config entry actually connects to
|
||||||
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
|
and always exists (see MAIN below) -- its `to_actual`/`to_canonical`
|
||||||
identity transform, so every existing single-subdevice device keeps
|
are the identity transform, so a single-subdevice device behaves
|
||||||
behaving exactly as it did before this module existed. `'indexed'`/
|
exactly as before this module existed. `'indexed'`/`'prefixed'` are
|
||||||
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
|
Pattern A/B above; `key` is the trailing index string ('1', '2', ...)
|
||||||
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
|
or the full subdevice UUID, and `seed_path` is the Collection href (as
|
||||||
Collection href (as path segments) whose batch response
|
path segments) whose batch enumerates/refreshes that subdevice.
|
||||||
enumerates/refreshes that subdevice.
|
|
||||||
|
|
||||||
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
|
`flat_hrefs` is non-empty only for a 'prefixed' subdevice with no
|
||||||
expose its own Collection at `seed_path` (issue #205 -- not even
|
Collection at `seed_path` (issue #205). When set, `seed_path` is
|
||||||
TP2X_FAC_BORA_21K, the board this pattern was built against, always
|
meaningless (left as `()`) and this subdevice's state comes from
|
||||||
does). When set, `seed_path` is meaningless (left as `()`) and this
|
GETting each of these canonical hrefs individually under its prefix
|
||||||
subdevice's state comes from GETting each of these canonical hrefs
|
instead -- see enumerate_subdevices' fallback and
|
||||||
individually under its prefix instead of one Collection batch -- see
|
coordinator._poll_subdevice_seed.
|
||||||
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kind: str # 'main' | 'indexed' | 'prefixed'
|
kind: str # 'main' | 'indexed' | 'prefixed'
|
||||||
@@ -150,13 +91,11 @@ class Subdevice:
|
|||||||
on-the-wire href for this subdevice."""
|
on-the-wire href for this subdevice."""
|
||||||
if self.kind == "indexed":
|
if self.kind == "indexed":
|
||||||
head, sep, tail = canonical.rpartition("/")
|
head, sep, tail = canonical.rpartition("/")
|
||||||
# Only the index-0 trailing segment is ours to rewrite --
|
# Only the index-0 trailing segment is ours to rewrite -- not a
|
||||||
# deliberately not a "replace any trailing digit" rule, which
|
# "replace any trailing digit" rule, which would misread a
|
||||||
# would misread a genuine multi-instance resource (the fridge's
|
# genuine multi-instance resource (e.g. the fridge's
|
||||||
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
|
# '/door/vs/1') as a subdevice's. No registry declares a
|
||||||
# registry declares a non-zero trailing index today and no
|
# non-zero trailing index today.
|
||||||
# fixture in the corpus contains one (verified across the whole
|
|
||||||
# corpus), so the strict rule costs nothing.
|
|
||||||
if tail == "0":
|
if tail == "0":
|
||||||
return f"{head}{sep}{self.key}"
|
return f"{head}{sep}{self.key}"
|
||||||
return canonical
|
return canonical
|
||||||
@@ -179,25 +118,24 @@ class Subdevice:
|
|||||||
return actual
|
return actual
|
||||||
|
|
||||||
def owns(self, actual: str) -> bool:
|
def owns(self, actual: str) -> bool:
|
||||||
"""True if `actual` belongs to this subdevice's namespace. MAIN never
|
"""True if `actual` belongs to this subdevice's namespace. MAIN
|
||||||
"owns" anything by this definition -- it gets whatever's left after
|
never "owns" anything by this definition -- it gets whatever's
|
||||||
every other subdevice's hrefs are excluded (see canonical_view)."""
|
left after every other subdevice's hrefs are excluded (see
|
||||||
|
canonical_view)."""
|
||||||
if self.kind == "main":
|
if self.kind == "main":
|
||||||
return False
|
return False
|
||||||
return self.to_canonical(actual) is not None
|
return self.to_canonical(actual) is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def key_prefix(self) -> str:
|
def key_prefix(self) -> str:
|
||||||
"""Prefix that guarantees a unique entity key/unique_id (see
|
"""Prefix guaranteeing a unique entity key/unique_id (see
|
||||||
adapter._key). '' for MAIN -- the master's flattened state
|
adapter._key). '' for MAIN, so the master's flattened state keys
|
||||||
keys must stay byte-identical to every device this integration
|
stay byte-identical to every device shipped before issue #177. The
|
||||||
shipped before issue #177, so no golden file changes. The full
|
full subdevice UUID is used verbatim (non-alphanumerics stripped)
|
||||||
subdevice UUID is used verbatim (non-alphanumerics stripped, not
|
rather than an enumeration-order ordinal, since it's device-reported
|
||||||
truncated or replaced with an ordinal) because it's device-reported
|
and stable across reconnects; it never appears in a user-visible
|
||||||
and stable across reconnects/restarts, unlike an ordinal assigned by
|
string, since HA derives entity_id from device+entity name, not
|
||||||
enumeration order -- and it never appears in a user-visible string
|
unique_id.
|
||||||
(see DESIGN-177.md section 6): HA derives the visible entity_id from
|
|
||||||
the device name + entity name, not from unique_id.
|
|
||||||
"""
|
"""
|
||||||
if self.kind == "indexed":
|
if self.kind == "indexed":
|
||||||
return f"subdevice{self.key}_"
|
return f"subdevice{self.key}_"
|
||||||
@@ -219,16 +157,15 @@ def canonical_view(
|
|||||||
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
|
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
|
||||||
and friends are written against.
|
and friends are written against.
|
||||||
|
|
||||||
For MAIN this is the snapshot *minus* every href owned by one of the
|
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`
|
other subdevices in `subdevices` -- otherwise a sibling's own
|
||||||
would leak into the master's view under the same canonical key
|
`/mode/vs/1` would leak into the master's view under the canonical key
|
||||||
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
|
('/mode/vs/0') the master's own resource also maps to. For an
|
||||||
silently mixing two subdevices' state together. For an indexed/prefixed
|
indexed/prefixed subdevice it's the reverse: only the hrefs that
|
||||||
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
|
subdevice owns, rewritten back through `to_canonical`.
|
||||||
back through `to_canonical`.
|
|
||||||
|
|
||||||
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
|
`subdevices` may or may not include MAIN itself -- MAIN.owns() is
|
||||||
False, so including it is harmless.
|
always False, so including it is harmless.
|
||||||
"""
|
"""
|
||||||
if subdevice.kind == "main":
|
if subdevice.kind == "main":
|
||||||
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
|
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
|
||||||
@@ -245,11 +182,9 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
|
|||||||
normalized so every href actually carries this subdevice's prefix/index.
|
normalized so every href actually carries this subdevice's prefix/index.
|
||||||
|
|
||||||
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
|
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
|
||||||
href in its own `/device/<n>` batch (confirmed against the Pattern A
|
href in its own `/device/<n>` batch. A prefixed subdevice's batch
|
||||||
reporter's dump). A prefixed subdevice's batch entries may or may not
|
entries may or may not already carry the `/<id>` prefix (unconfirmed),
|
||||||
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
|
so it's added when missing.
|
||||||
reporter's board was never probed live before the subdevice id was
|
|
||||||
known), so it's added when missing.
|
|
||||||
"""
|
"""
|
||||||
if subdevice.kind != "prefixed":
|
if subdevice.kind != "prefixed":
|
||||||
return batch
|
return batch
|
||||||
@@ -265,9 +200,8 @@ def _iter_oic_res_hrefs(oic_res):
|
|||||||
|
|
||||||
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
|
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
|
||||||
-- see identity.py's read_identity/_get_links), so that's the shape
|
-- 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
|
handled here. Tolerant of a flat link-list too, and of anything else by
|
||||||
rules it out, and _get_links' own posture already treats any list-shaped
|
yielding nothing.
|
||||||
body as possible) and of anything else by yielding nothing.
|
|
||||||
"""
|
"""
|
||||||
for entry in oic_res or []:
|
for entry in oic_res or []:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
@@ -290,8 +224,7 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
|
|||||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||||
shared tolerated-absence posture for both callers below, which differ
|
shared tolerated-absence posture for both callers below."""
|
||||||
only in which body shape they accept."""
|
|
||||||
try:
|
try:
|
||||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
code, pl = sess.get(list(path_segs), timeout=10.0)
|
||||||
if code == 0x45 and pl:
|
if code == 0x45 and pl:
|
||||||
@@ -311,10 +244,8 @@ def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
|||||||
|
|
||||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
||||||
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
"""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
|
batch). Used for `/multidevice/vs/0`: listed in `/oic/res` but absent
|
||||||
`/oic/res` on the Pattern A reporter's board but absent from
|
from `/device/0`'s batch, so it needs its own RETRIEVE."""
|
||||||
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
|
||||||
single Property map, not a [devcol-rep, ...] list."""
|
|
||||||
body = _get_raw(sess, path_segs)
|
body = _get_raw(sess, path_segs)
|
||||||
return body if isinstance(body, dict) else {}
|
return body if isinstance(body, dict) else {}
|
||||||
|
|
||||||
@@ -329,34 +260,27 @@ def enumerate_subdevices(
|
|||||||
connection.
|
connection.
|
||||||
|
|
||||||
Runs once, at first discovery, in an executor, under the coordinator's
|
Runs once, at first discovery, in an executor, under the coordinator's
|
||||||
session lock -- every GET here is a plain RETRIEVE (the write-contract
|
session lock -- every GET here is a plain RETRIEVE. Returns the
|
||||||
'don't guess' rule doesn't apply to reading an extra resource to find
|
*candidate* subdevices and the resources already fetched while probing
|
||||||
out whether it's there). Returns the *candidate* subdevices and the resources
|
them (normalized to real hrefs), so the coordinator's first discovery
|
||||||
already fetched while probing them (already normalized to real hrefs),
|
poll doesn't need to re-poll them.
|
||||||
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
|
`probe_log(seed_href, found)` fires for every seed attempted, whether
|
||||||
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
|
or not it answered, so diagnostics can tell "checked, nothing there"
|
||||||
can tell "checked, nothing there" apart from "never checked", the same
|
apart from "never checked".
|
||||||
posture the speculative-probe code this replaces used to document in
|
|
||||||
identity.py.
|
|
||||||
|
|
||||||
Every candidate whose seed answers with a non-empty batch is returned
|
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
|
here -- this function can't tell a real sibling from an unused
|
||||||
SmartThings slot that merely answers the same shape (the Pattern A
|
SmartThings slot that answers the same shape; that requires
|
||||||
reporter's `/device/2`); that requires discovering+flattening the
|
discovering+flattening the candidate's own entities first, which is
|
||||||
candidate's own entities first, which is `discover_partitioned`'s job,
|
`discover_partitioned`'s job. See this module's docstring.
|
||||||
not this one's.
|
|
||||||
See this module's docstring.
|
|
||||||
"""
|
"""
|
||||||
subdevices: list[Subdevice] = []
|
subdevices: list[Subdevice] = []
|
||||||
fetched: dict[str, dict] = {}
|
fetched: dict[str, dict] = {}
|
||||||
# Case-insensitive -- the same UUID can reach here once from
|
# Case-insensitive -- the same UUID can reach here once from
|
||||||
# subdeviceIdList and once from an /oic/res link prefix with different
|
# subdeviceIdList and once from an /oic/res link prefix with different
|
||||||
# casing (Samsung's own fields disagree on this elsewhere too, e.g. the
|
# casing, and probing it twice would materialize the same physical
|
||||||
# redaction-prone subdeviceIdList handling below), and probing it twice
|
# subdevice as two Subdevice candidates.
|
||||||
# would materialize the same physical subdevice as two Subdevice
|
|
||||||
# candidates under two different keys.
|
|
||||||
probed_ids: set[str] = set()
|
probed_ids: set[str] = set()
|
||||||
|
|
||||||
def _probed(seed_href: str, batch: dict) -> None:
|
def _probed(seed_href: str, batch: dict) -> None:
|
||||||
@@ -379,29 +303,22 @@ def enumerate_subdevices(
|
|||||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||||
subdevices.append(subdevice)
|
subdevices.append(subdevice)
|
||||||
return
|
return
|
||||||
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
|
# Fallback (issue #205): even the reference TP2X_FAC_BORA_21K board
|
||||||
# pattern was built against -- turns out not to always expose its own
|
# doesn't always expose its own `/<uuid>/device/0` Collection. With
|
||||||
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
|
# no Collection to seed from and no per-UUID entry in /oic/res to
|
||||||
# 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
|
|
||||||
# enumerate hrefs from, the only signal left is that a composite
|
# enumerate hrefs from, the only signal left is that a composite
|
||||||
# device's siblings are the same physical board family as the
|
# device's siblings share the master's own resource surface -- so
|
||||||
# subdevice this config entry already talks to -- so probe every
|
# probe every href the master answered this cycle, individually,
|
||||||
# href the master itself answered this cycle, individually, under
|
# under this UUID's prefix, and keep whichever answer. Each is a
|
||||||
# this UUID's prefix, and keep whichever ones answer. Each is a
|
# plain tolerated-404 RETRIEVE.
|
||||||
# plain tolerated-404 RETRIEVE, same posture as every other probe in
|
|
||||||
# this function.
|
|
||||||
#
|
#
|
||||||
# Known gap, not yet guarded against: a firmware that answers *any*
|
# Known gap: a firmware that echoes the master's own state back
|
||||||
# request under an unrecognized prefix (echoing the master's own
|
# under an unrecognized prefix, rather than 4.04ing, would pass
|
||||||
# state back rather than 4.04ing) would pass every one of these
|
# every probe here and could materialize a phantom duplicate. Every
|
||||||
# probes and, if the echoed state also clears discover_partitioned's
|
# board seen so far genuinely 4.04s on paths it doesn't own (issue
|
||||||
# liveness gate, materialize a phantom duplicate of the master
|
# #205's unit answered only 1 of 31 probes), so this hasn't been
|
||||||
# rather than a real sibling. Every board seen so far genuinely
|
# guarded against -- the fix would compare a candidate's confirmed
|
||||||
# 4.04s on paths it doesn't own (issue #205's own unit answered only
|
# reps against the master's own values for the same hrefs.
|
||||||
# 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.
|
|
||||||
flat_hrefs = []
|
flat_hrefs = []
|
||||||
first = True
|
first = True
|
||||||
for href in sorted(resources):
|
for href in sorted(resources):
|
||||||
@@ -427,35 +344,26 @@ def enumerate_subdevices(
|
|||||||
|
|
||||||
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
|
||||||
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
|
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
|
# Tolerate anything but a list of strings -- this field is
|
||||||
# (it matches the 'deviceid' substring rule in redact.py) and the existing
|
# redaction-prone (matches redact.py's 'deviceid' rule) and a shipped
|
||||||
# airconditioner_fac_bora fixture carries the literal string
|
# fixture carries the literal string 'REDACTED' there. That must yield
|
||||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
|
# zero subdevices, not a crash -- issue #177 is additive and must never
|
||||||
# crash -- issue #177 is additive, it must never break an already-working
|
# break an already-working single-climate-entity device.
|
||||||
# single-climate-entity device.
|
|
||||||
ids = raw_ids if isinstance(raw_ids, list) else []
|
ids = raw_ids if isinstance(raw_ids, list) else []
|
||||||
listed = sorted(i for i in ids if isinstance(i, str) and i)
|
listed = sorted(i for i in ids if isinstance(i, str) and i)
|
||||||
for sub_id in listed:
|
for sub_id in listed:
|
||||||
_probe_prefixed(sub_id)
|
_probe_prefixed(sub_id)
|
||||||
|
|
||||||
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
||||||
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third
|
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
|
||||||
# multidevice shape: the board answers numofsubdevice='2' on
|
# /subdevices/vs/0 and /device/<n> 404s; the only trace of the sibling
|
||||||
# /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList
|
# is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice
|
||||||
# -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's).
|
# link). Its own tree answers a full Collection at /<uuid>/device/0,
|
||||||
# The only trace of the sibling is a UUID-prefixed link in /oic/res
|
# exactly Pattern B's transform, so every UUID path prefix seen in
|
||||||
# itself: the x.com.samsung.da.multidevice link,
|
# /oic/res is treated as a candidate. _probe_prefixed's probed_ids
|
||||||
# '/<uuid>/multidevice/vs/0' on the reporting board. Its washer tree
|
# guard (not a set difference against `listed`) is what keeps an id
|
||||||
# answers a full Collection at /<uuid>/device/0, exactly Pattern B's
|
# already named by subdeviceIdList from being probed twice, since the
|
||||||
# transform -- so treat every UUID path prefix seen in /oic/res as a
|
# two sources can disagree on case.
|
||||||
# prefixed-subdevice candidate. Probing is the same tolerated-404
|
|
||||||
# RETRIEVE as everything else here, and discover_partitioned's
|
|
||||||
# entity-level liveness gate still decides materialization, so a board
|
|
||||||
# that advertises a UUID link without a live sibling behind it
|
|
||||||
# contributes nothing. _probe_prefixed's probed_ids guard -- not a set
|
|
||||||
# difference against `listed` here -- is what keeps an id already named
|
|
||||||
# by subdeviceIdList from being probed and materialized a second time,
|
|
||||||
# since the two sources can disagree on that UUID's case.
|
|
||||||
linked = sorted(
|
linked = sorted(
|
||||||
{
|
{
|
||||||
m.group(1)
|
m.group(1)
|
||||||
@@ -477,10 +385,9 @@ def enumerate_subdevices(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not indices:
|
if not indices:
|
||||||
# A board that hides its whole tree from /oic/res (Pattern B's
|
# A board that hides its whole tree from /oic/res gives us nothing
|
||||||
# reporter board does this too, but it has no /device/<n> to find
|
# to enumerate from -- fall back to the bounded speculative probe
|
||||||
# regardless) gives us nothing to enumerate from -- fall back to the
|
# this replaces from identity.py.
|
||||||
# bounded speculative probe this replaces from identity.py.
|
|
||||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||||
for n in indices:
|
for n in indices:
|
||||||
seed = ("device", str(n))
|
seed = ("device", str(n))
|
||||||
@@ -492,19 +399,13 @@ def enumerate_subdevices(
|
|||||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||||
subdevices.append(subdevice)
|
subdevices.append(subdevice)
|
||||||
|
|
||||||
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
|
# /multidevice/vs/0: listed in /oic/res on some boards but never in
|
||||||
# board lists it in /oic/res but it never appears in /device/0's batch,
|
# /device/0's batch, so it needs its own RETRIEVE. A plain corroborating
|
||||||
# so it needs its own RETRIEVE. It's a plain corroborating count
|
# count (numofsubdevice), confirmed read-only -- captured for
|
||||||
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
# diagnostics only, folded into the merged resources dict like any
|
||||||
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
# other href (see airconditioner._AC_IGNORED). Not a gate:
|
||||||
# into the merged resources dict like any other href (see
|
# discover_partitioned's entity-level liveness check decides
|
||||||
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
|
# materialization without it.
|
||||||
# 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_seed = ("multidevice", "vs", "0")
|
||||||
multidevice = _get_property(sess, multidevice_seed)
|
multidevice = _get_property(sess, multidevice_seed)
|
||||||
_probed(_seed_href(multidevice_seed), multidevice)
|
_probed(_seed_href(multidevice_seed), multidevice)
|
||||||
@@ -516,23 +417,21 @@ def enumerate_subdevices(
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SkippedSubdevice:
|
class SkippedSubdevice:
|
||||||
"""A candidate `enumerate_subdevices` found whose seed answered, but that
|
"""A candidate `enumerate_subdevices` found whose seed answered, but
|
||||||
`discover_partitioned`'s entity-level liveness gate rejected -- an
|
that `discover_partitioned`'s entity-level liveness gate rejected -- an
|
||||||
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
|
unused SmartThings slot, not a real second subdevice. Kept around so a
|
||||||
real second subdevice. Kept around (rather than silently dropped) so a
|
|
||||||
caller can log/report what was skipped and why."""
|
caller can log/report what was skipped and why."""
|
||||||
|
|
||||||
subdevice: Subdevice
|
subdevice: Subdevice
|
||||||
hrefs: tuple[str, ...]
|
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
|
# than a reading of the subdevice's own hardware -- excluded from the
|
||||||
# liveness gate below (issue #214). HA's own running-total state classes
|
# liveness gate below (issue #214). HA's running-total state classes cover
|
||||||
# cover most of them; the consumption device classes catch the rest, since a
|
# most of them; the consumption device classes catch the rest (a descriptor
|
||||||
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
|
# may deliberately declare no state_class, e.g. common.ENERGY_METER's
|
||||||
# monthly totals reset at each billing boundary, so they aren't
|
# monthly totals that reset at each billing boundary).
|
||||||
# `total_increasing`).
|
|
||||||
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
|
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
|
||||||
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
|
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
|
||||||
|
|
||||||
@@ -549,35 +448,26 @@ def _is_meter(desc) -> bool:
|
|||||||
|
|
||||||
def _has_live_primary_entity(bound, state: dict) -> bool:
|
def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||||
"""True if flattening `bound` (one candidate subdevice's BoundEntity
|
"""True if flattening `bound` (one candidate subdevice's BoundEntity
|
||||||
list) produced at least one non-`None` value for a *primary* entity --
|
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"
|
(`entity_category` unset) that isn't a cumulative meter (`_is_meter`).
|
||||||
tier (see the adding-device-support skill's entity-taxonomy section) --
|
|
||||||
that isn't a cumulative meter (`_is_meter`).
|
|
||||||
|
|
||||||
This is the materialization gate itself (see this module's docstring).
|
This is the materialization gate itself (see this module's docstring).
|
||||||
Two exclusions, both for the same reason -- the question this answers is
|
Two exclusions, both because the question this answers is "is a
|
||||||
"is a physical subdevice installed at this slot?", and neither kind of
|
physical subdevice installed at this slot?", and neither kind of value
|
||||||
value can speak to it:
|
can speak to it:
|
||||||
|
|
||||||
- **Non-primary entities.** The Pattern A reporter's `/device/2` does
|
- **Non-primary entities.** An unused slot can still flatten to a
|
||||||
flatten to one non-`None` value (`alarm_code`), but that entity is
|
diagnostic-category value derived from an empty resource (e.g. a
|
||||||
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a
|
formatted `alarm_code` off an empty `/alarms/vs/2`) -- that proves
|
||||||
config/diagnostic entity reading "something" proves nothing about
|
nothing about whether hardware is there.
|
||||||
whether hardware is there.
|
- **Cumulative meters** (issue #214). An unused slot has been seen
|
||||||
- **Cumulative meters** (issue #214). An unused slot on the issue #214
|
reporting a populated whole-appliance `cumulativePower` while every
|
||||||
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
|
operational rep on it is empty `{}`. A single-split AC has one
|
||||||
populated `cumulativePower` while every operational rep on it
|
compressor and one energy meter, so a whole-appliance total showing
|
||||||
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
|
up under a second index is the appliance's own bookkeeping, not
|
||||||
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
|
evidence of a second indoor unit. A genuinely installed subdevice
|
||||||
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
|
reports its own operational state too, and that is what still passes
|
||||||
counter. That counter got the slot materialized as a phantom second
|
this gate.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
from .adapter import _key # see discover_partitioned's deferred-import note
|
from .adapter import _key # see discover_partitioned's deferred-import note
|
||||||
|
|
||||||
@@ -596,64 +486,53 @@ def discover_partitioned(
|
|||||||
tier_log: Callable[[str, str], None] | None = None,
|
tier_log: Callable[[str, str], None] | None = None,
|
||||||
oic_device_types: Sequence[str] = (),
|
oic_device_types: Sequence[str] = (),
|
||||||
):
|
):
|
||||||
"""Bind every href in `resources` (the merged, real-href snapshot -- main
|
"""Bind every href in `resources` (the merged, real-href snapshot --
|
||||||
plus every enumerated subdevice's seed) to entities, partitioned by which
|
main plus every enumerated subdevice's seed) to entities, partitioned
|
||||||
subdevice owns it.
|
by which subdevice owns it.
|
||||||
|
|
||||||
Main pass runs over hrefs owned by no subdevice -- otherwise every
|
Main pass runs over hrefs owned by no subdevice -- otherwise every
|
||||||
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
|
`/mode/vs/1` would land in `unbound_hrefs` too and raise a spurious
|
||||||
device's registry claims that literal href) and raise a spurious
|
coverage-gap repair. Then one pass per candidate subdevice over its own
|
||||||
coverage-gap repair. Then one pass per *candidate* subdevice over its own
|
|
||||||
canonical view, resolving that subdevice's own device type from 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
|
`/information/vs/0` when it reports one, falling back to the master's
|
||||||
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
|
registry otherwise -- a sibling that fails to answer its own identity
|
||||||
airconditioner), falling back to the master's registry otherwise --
|
resource is still treated as the same appliance type as the master.
|
||||||
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.
|
|
||||||
|
|
||||||
Each candidate is discovered and flattened *twice*: once silently to
|
Each candidate is discovered and flattened twice: once silently to
|
||||||
evaluate `_has_live_primary_entity` (this module's materialization
|
evaluate `_has_live_primary_entity`, and, only if that passes, a second
|
||||||
gate -- see its docstring and this module's own), and, only if that
|
time with `log`/`tier_log` wired so its coverage gaps and poll tiers
|
||||||
passes, a second time with `log`/`tier_log` wired so its coverage gaps
|
actually count. A candidate that fails the gate contributes nothing at
|
||||||
and poll tiers actually count. A candidate that fails the gate
|
all, as if it had never answered its seed.
|
||||||
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.
|
|
||||||
|
|
||||||
`oic_device_types` (from the master's own `/oic/d`, see
|
`oic_device_types` (from the master's own `/oic/d`) is passed only to
|
||||||
registry/identity.py) is passed only to the *master's* resolution --
|
the master's resolution -- subdevices resolve from their own
|
||||||
subdevices have no `/oic/d` of their own read today (they resolve from
|
`/information/vs/0` or fall back to the master's whole registry, and
|
||||||
their own `/information/vs/0` or fall back to the master's whole
|
blindly applying the master's OCF device type to every subdevice would
|
||||||
registry, as documented above), and blindly applying the master's OCF
|
be wrong the moment a composite appliance pairs two different device
|
||||||
device type to every subdevice's own model-based resolution would be
|
types under one connection.
|
||||||
wrong the moment a composite appliance ever pairs two genuinely
|
|
||||||
different device types under one connection.
|
|
||||||
|
|
||||||
Returns `(bound, device_type_name, materialized, skipped)`:
|
Returns `(bound, device_type_name, materialized, skipped)`:
|
||||||
- `bound`: the concatenated BoundEntity list (main + every materialized
|
- `bound`: the concatenated BoundEntity list (main + every materialized
|
||||||
subdevice).
|
subdevice).
|
||||||
- `device_type_name`: the *master's* resolved device type (used for
|
- `device_type_name`: the master's resolved device type (used for
|
||||||
logging/device naming; each subdevice's own resolved type only affects
|
logging/device naming; each subdevice's own resolved type only
|
||||||
which capabilities bind its hrefs, not this).
|
affects which capabilities bind its hrefs).
|
||||||
- `materialized`: the subset of `subdevices` that passed the gate, in the
|
- `materialized`: the subset of `subdevices` that passed the gate, in
|
||||||
same order -- what the caller should keep as its live subdevice roster
|
the same order -- what the caller should keep as its live subdevice
|
||||||
going forward (poll seeds, canonical_resources, device_info_for, ...).
|
roster going forward (poll seeds, canonical_resources,
|
||||||
|
device_info_for, ...).
|
||||||
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
||||||
"""
|
"""
|
||||||
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
|
# Deferred import: discovery.py imports Subdevice/MAIN from this module
|
||||||
# module scope, so importing discover() back here at module scope would
|
# at module scope, so importing discover() back here at module scope
|
||||||
# be a circular import. By the time this function actually runs both
|
# would be circular. By the time this function runs both modules are
|
||||||
# modules are fully loaded. adapter.py imports discovery.py, so the same
|
# fully loaded; adapter.py imports discovery.py, so the same applies to
|
||||||
# applies to flatten()/_key().
|
# flatten()/_key().
|
||||||
from .adapter import flatten
|
from .adapter import flatten
|
||||||
from .discovery import discover
|
from .discovery import discover
|
||||||
|
|
||||||
# Same computation canonical_view does for MAIN (snapshot minus every
|
# Same computation canonical_view does for MAIN -- reuse it rather than
|
||||||
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
|
# re-deriving owned_elsewhere here too.
|
||||||
# owned_elsewhere here too.
|
|
||||||
main_view = canonical_view(MAIN, resources, subdevices)
|
main_view = canonical_view(MAIN, resources, subdevices)
|
||||||
|
|
||||||
reg = resolve_registry(main_view, device_types=oic_device_types)
|
reg = resolve_registry(main_view, device_types=oic_device_types)
|
||||||
|
|||||||
@@ -52,34 +52,26 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
|||||||
def _display(value, translation_key: str | None):
|
def _display(value, translation_key: str | None):
|
||||||
"""Turn a raw device option/state value into what's shown in the UI.
|
"""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` is the entity's already-resolved key (it can itself
|
||||||
translation_key can itself be a callable -- see entities.py -- so
|
be a callable -- see entities.py -- so callers pass the resolved
|
||||||
callers pass the resolved value, e.g. self.translation_key, not
|
value, not the raw descriptor field).
|
||||||
the raw descriptor field).
|
|
||||||
|
|
||||||
An entity with a translation_key looks its state up in the shipped
|
An entity with a translation_key looks its state up in the shipped
|
||||||
translation catalog, whose state keys are lowercase -- so those values
|
translation catalog, whose state keys are lowercase, and the device
|
||||||
must be lowercased exactly to match, and the device still expects
|
still expects that same raw casing back on write (mapped back via
|
||||||
that same raw casing back on write (callers map the displayed value
|
_raw_options()). Everything else has no catalog lookup, so there's no
|
||||||
back to raw via _raw_options()).
|
reason to destroy the device's own casing: only two cosmetic fixups
|
||||||
|
apply, title-casing a fully lowercase token ("voice") and spacing a
|
||||||
Everything else has no catalog lookup, so there's no reason to
|
PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value
|
||||||
destroy the device's own casing. Only two cosmetic fixups apply: a
|
("AI Wash") matches neither and passes through unchanged.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
if translation_key:
|
if translation_key:
|
||||||
known = translated_states("select", translation_key)
|
known = translated_states("select", translation_key)
|
||||||
if not known:
|
if not known:
|
||||||
# No state table for this key: either the entity isn't translated
|
# No state table for this key: either untranslated, or its
|
||||||
# at all, or its name is translated but its options deliberately
|
# options deliberately aren't (an unrecognized course table).
|
||||||
# aren't (an unrecognized course table, say). Either way the
|
|
||||||
# opaque device value is the best thing to show.
|
|
||||||
return value
|
return value
|
||||||
if translated := _translation_state(value, known):
|
if translated := _translation_state(value, known):
|
||||||
return translated
|
return translated
|
||||||
@@ -99,12 +91,11 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
|||||||
desc = cast(SelectDesc, self._bound.desc)
|
desc = cast(SelectDesc, self._bound.desc)
|
||||||
if callable(desc.options):
|
if callable(desc.options):
|
||||||
# Per-device option list computed from the full resource
|
# Per-device option list computed from the full resource
|
||||||
# snapshot (not just this entity's own href) -- e.g. a course
|
# snapshot -- e.g. a course list decoded from a sibling
|
||||||
# list decoded from a sibling resource. There is no static
|
# resource. No static fallback: when unpopulated, the callable
|
||||||
# fallback: when that resource isn't populated the callable
|
# returns [] and exists_fn suppresses the entity entirely. Uses
|
||||||
# returns [] and the entity's exists_fn suppresses it entirely.
|
# this subdevice's canonical view (issue #177), not the raw
|
||||||
# This entity's own subdevice's canonical view (issue #177), not
|
# snapshot -- see LocalThingsEntity._resources.
|
||||||
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
|
|
||||||
return list(desc.options(self._resources) or [])
|
return list(desc.options(self._resources) or [])
|
||||||
if desc.options_field:
|
if desc.options_field:
|
||||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||||
|
|||||||
@@ -1,38 +1,29 @@
|
|||||||
"""Water heater platform for Local Things.
|
"""Water heater platform for Local Things.
|
||||||
|
|
||||||
Second composite entity in this integration (see climate.py's module
|
Second composite entity in this integration (see climate.py's module
|
||||||
docstring for the general pattern this follows): a single HA water_heater
|
docstring for the general pattern): a single HA water_heater card for a
|
||||||
card for a Samsung EHS heat pump's domestic hot water (DHW) loop. It binds
|
Samsung EHS heat pump's domestic hot water (DHW) loop. It binds the primary
|
||||||
the primary `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities
|
`WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities in
|
||||||
in registry/capabilities/ehs.py) so the registry still tracks it, and reads
|
registry/capabilities/ehs.py) and reads the sibling `/power/dhw/vs/0` and
|
||||||
the sibling `/power/dhw/vs/0` and `/temperatures/dhw/vs/0` resources straight
|
`/temperatures/dhw/vs/0` resources straight from the coordinator snapshot,
|
||||||
from the coordinator snapshot -- the same cross-resource read climate.py uses
|
the same cross-resource read climate.py uses.
|
||||||
for the AC's power/temperature/wind siblings.
|
|
||||||
|
|
||||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
|
Writes go through `coordinator.async_send_command`: DHW's `write_fn`
|
||||||
DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the
|
(ehs._dhw_write) maps each `(kind, value)` payload to the right
|
||||||
right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs
|
`(path_segs, body)`, applying the optimistic value/settle guard to that
|
||||||
and applies the optimistic value/settle guard to that same href -- not the
|
resource's own href rather than the bound `/mode/dhw/vs/0` href.
|
||||||
bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets
|
|
||||||
fresh state back for, power, mode and temperature alike.
|
|
||||||
|
|
||||||
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
|
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
|
Power) map onto HA's own standard water_heater states, the same mapping
|
||||||
Home Assistant's core `smartthings` integration uses for this exact Samsung
|
HA core's `smartthings` integration uses for this exact Samsung capability
|
||||||
capability over the cloud API (`samsungce.ehsThermostat` /
|
(`samsungce.ehsThermostat`), just title-cased to match this OCF resource's
|
||||||
`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/
|
spelling. Reusing HA's standard states means no state translation catalog
|
||||||
STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF
|
entry is needed for them.
|
||||||
resource's own code spelling. Reusing HA's standard states means no *state*
|
|
||||||
translation catalog entry is needed for them (see the entity_component
|
|
||||||
fallback in homeassistant.components.water_heater.strings.json).
|
|
||||||
|
|
||||||
Naming is a separate question from that, and the answer here differs from
|
Naming differs from climate.py's: the AC *is* the device, so its card takes
|
||||||
climate.py's: the AC *is* the device, so its climate card takes the bare
|
the bare device name. An EHS unit has two loops, and DHW isn't "the
|
||||||
device name (`_attr_name = None`). An EHS unit has two loops, and the DHW
|
device" (siblings are named "Zone Mode"/"Zone Target Temperature"), so this
|
||||||
one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target
|
entity is named through the catalog via `translation_key='dhw'`
|
||||||
Temperature", so a card labelled just "EHS" would misrepresent which loop
|
|
||||||
it drives. This entity is named through the catalog like every other
|
|
||||||
descriptor here, via the DHW descriptor's `translation_key='dhw'`
|
|
||||||
(entity.water_heater.dhw.name -> "Hot water").
|
(entity.water_heater.dhw.name -> "Hot water").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -73,8 +64,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
_MODES_FIELD = "x.com.samsung.da.modes"
|
_MODES_FIELD = "x.com.samsung.da.modes"
|
||||||
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||||
|
|
||||||
# Device mode <-> HA water_heater operation state -- see the module
|
# Device mode <-> HA water_heater operation state -- see module docstring.
|
||||||
# docstring above for the SmartThings-cloud precedent this mirrors.
|
|
||||||
_DEVICE_TO_STATE: dict[str, str] = {
|
_DEVICE_TO_STATE: dict[str, str] = {
|
||||||
"Eco": STATE_ECO,
|
"Eco": STATE_ECO,
|
||||||
"Std": STATE_HEAT_PUMP,
|
"Std": STATE_HEAT_PUMP,
|
||||||
@@ -83,12 +73,10 @@ _DEVICE_TO_STATE: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
|
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
|
||||||
|
|
||||||
# Read-side lookup, case-folded. climate.py resolves write codes from the
|
# Read-side lookup, case-folded: this map is bijective (unlike climate.py's
|
||||||
# unit's own supportedModes because two spellings there mean one HA value
|
# 'Wind'/'Fan' -> FAN_ONLY), so the write side uses _STATE_TO_DEVICE
|
||||||
# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can
|
# directly; only the read side needs to absorb a board spelling the same
|
||||||
# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board
|
# code differently ('eco'/'ECO').
|
||||||
# spelling the same code differently ('eco'/'ECO'), and case is the one
|
|
||||||
# variation worth absorbing rather than warning about.
|
|
||||||
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
|
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
|
||||||
|
|
||||||
|
|
||||||
@@ -132,21 +120,18 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
|||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
# No _attr_name here: unlike climate.py's AC, this is one loop of a
|
# No _attr_name here: unlike climate.py's AC, this is one loop of a
|
||||||
# two-loop device and takes a catalog name ("Hot water") through the
|
# two-loop device and takes a catalog name through translation_key.
|
||||||
# descriptor's translation_key -- see the module docstring.
|
|
||||||
self._attr_supported_features = (
|
self._attr_supported_features = (
|
||||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||||
| WaterHeaterEntityFeature.ON_OFF
|
| WaterHeaterEntityFeature.ON_OFF
|
||||||
)
|
)
|
||||||
# Raw device codes already logged by _warn_unmapped -- these
|
# Raw device codes already logged by _warn_unmapped -- read on every
|
||||||
# properties are read on every coordinator refresh, so an un-deduped
|
# refresh, so un-deduped would spam the log for an unrecognized code.
|
||||||
# warning would spam the log for any unit reporting a genuinely
|
|
||||||
# unrecognized code.
|
|
||||||
self._warned_unmapped: set[str] = set()
|
self._warned_unmapped: set[str] = set()
|
||||||
|
|
||||||
def _rep(self, href: str) -> dict:
|
def _rep(self, href: str) -> dict:
|
||||||
"""`href` is one of this module's canonical HREF_* constants --
|
"""`href` is one of this module's canonical HREF_* constants,
|
||||||
translated through this bound entity's own subdevice (issue #177),
|
translated through this bound entity's own subdevice (issue #177),
|
||||||
same as climate.py's identical helper."""
|
same as climate.py's identical helper."""
|
||||||
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||||
@@ -188,14 +173,10 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
|||||||
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
|
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
|
||||||
|
|
||||||
def _range(self) -> list | None:
|
def _range(self) -> list | None:
|
||||||
"""The device's own (minimum, maximum) pair, or None.
|
"""The device's own (minimum, maximum) pair, or None. Both ends
|
||||||
|
together or neither -- same rule as climate._range(); a board
|
||||||
Both ends together or neither, deliberately -- same rule as
|
reporting only minimum would otherwise pair it with HA's own
|
||||||
climate._range(). A board reporting minimum but not maximum would
|
default maximum, silently wrong."""
|
||||||
otherwise pair a device minimum (40) with HA's own default maximum
|
|
||||||
(140 °F), which looks plausible and is silently wrong on a unit
|
|
||||||
that really allows 62.
|
|
||||||
"""
|
|
||||||
rep = self._rep(TEMPERATURE_HREF)
|
rep = self._rep(TEMPERATURE_HREF)
|
||||||
lo = _num(rep.get("x.com.samsung.da.minimum"))
|
lo = _num(rep.get("x.com.samsung.da.minimum"))
|
||||||
hi = _num(rep.get("x.com.samsung.da.maximum"))
|
hi = _num(rep.get("x.com.samsung.da.maximum"))
|
||||||
@@ -213,8 +194,7 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def target_temperature_step(self) -> float:
|
def target_temperature_step(self) -> float:
|
||||||
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0
|
# `is None`, not `or` -- `or` would collapse a genuine 0 (issue #160).
|
||||||
# into the fallback.
|
|
||||||
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
|
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
|
||||||
return 0.5 if step is None else step
|
return 0.5 if step is None else step
|
||||||
|
|
||||||
@@ -245,13 +225,11 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
|
|||||||
# -- writes ---------------------------------------------------------------
|
# -- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
async def async_set_temperature(self, **kwargs) -> None:
|
async def async_set_temperature(self, **kwargs) -> None:
|
||||||
# HA's water_heater.set_temperature service takes an optional
|
# HA's water_heater.set_temperature service can carry an optional
|
||||||
# operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same
|
# operation_mode; honor it, setting the mode first (which also
|
||||||
# as climate.set_temperature does with hvac_mode. Honour it, and set
|
# powers the loop on) so a dashboard "boost to 55" button that
|
||||||
# it first -- that also powers the loop on when it was off -- so a
|
# carries a mode actually changes mode, not just the setpoint. Same
|
||||||
# dashboard "boost to 55" button that carries a mode actually changes
|
# fix as climate.async_set_temperature.
|
||||||
# mode, instead of only moving the setpoint. Same fix as the AC's
|
|
||||||
# (see climate.async_set_temperature).
|
|
||||||
operation_mode = kwargs.get("operation_mode")
|
operation_mode = kwargs.get("operation_mode")
|
||||||
if operation_mode is not None:
|
if operation_mode is not None:
|
||||||
await self.async_set_operation_mode(operation_mode)
|
await self.async_set_operation_mode(operation_mode)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# AC filter-time counter reset: not solved
|
||||||
|
|
||||||
|
`registry/capabilities/airconditioner.py`'s `filter_time` sensor
|
||||||
|
(`FilterTime_<N>` option token, tenths of an hour) has no reset entity. This
|
||||||
|
is where the failed attempts to find one are kept, so the next attempt starts
|
||||||
|
from the evidence instead of from scratch. Not finding a mechanism is not the
|
||||||
|
same as it not existing.
|
||||||
|
|
||||||
|
## What the reset actually is
|
||||||
|
|
||||||
|
Samsung models it as a **command**, not a value write: capability
|
||||||
|
`custom.dustFilter`, command `resetDustFilter`, no arguments (implemented in
|
||||||
|
several SmartThings HA forks; not in the core integration). That reframes
|
||||||
|
every attempt below — nothing changes the counter by writing to it, because
|
||||||
|
the board zeroes it itself on receiving a command.
|
||||||
|
|
||||||
|
## Tried, all against a live unit, all failed
|
||||||
|
|
||||||
|
- `FilterTime_0` via the single-token options merge that works for every
|
||||||
|
other setting on this href — accepted with no error, then discarded. 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.
|
||||||
|
|
||||||
|
## Where to look next
|
||||||
|
|
||||||
|
- 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.
|
||||||
Reference in New Issue
Block a user