Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867f4b0ae8 | ||
|
|
9da775a8de | ||
|
|
6ee60beae9 | ||
|
|
fffe923afc | ||
|
|
a6d818dfc0 | ||
|
|
cec3dd4a68 | ||
|
|
5dbe990c1d | ||
|
|
9228da1d9c | ||
|
|
ce60b6967b | ||
|
|
a7dc1db8ff | ||
|
|
8551974719 | ||
|
|
e2dcc75ed5 | ||
|
|
c203bd42c5 | ||
|
|
42fd9c2aa4 | ||
|
|
df5b704f3e | ||
|
|
26c9168fb7 | ||
|
|
edf77309ba | ||
|
|
f07ae4020e | ||
|
|
dd953b8150 | ||
|
|
b820a96277 | ||
|
|
eed04faaed | ||
|
|
960eca2d6a | ||
|
|
789aaf9849 | ||
|
|
e3e7f4f43c | ||
|
|
a3cc918343 | ||
|
|
69f93be4dc | ||
|
|
50bb893407 | ||
|
|
77c2d7831e | ||
|
|
252306838d | ||
|
|
455ed5b27c | ||
|
|
26e4c9c167 | ||
|
|
2d772f16a7 | ||
|
|
30bd0fd2af | ||
|
|
4e47a1c3d9 | ||
|
|
bea5206c06 | ||
|
|
75e985d392 | ||
|
|
93f45cb356 | ||
|
|
15279066b5 | ||
|
|
69844d9829 |
@@ -98,6 +98,70 @@ Each device has its own **Configure** option in Settings > Devices & Services, u
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Reading and writing resources directly
|
||||
|
||||
Two HA actions, `localthings.write_resource` and `localthings.read_resource`, talk to a device's OCF resources directly instead of through this integration's entity model. They exist for two overlapping jobs: pinning down a device-specific write contract (the reverse-engineering work `docs/investigations/` and the provenance comments throughout `registry/capabilities/` are all about), and driving a resource this integration doesn't model as an entity yet, without waiting on a release.
|
||||
|
||||
Both take a `device_id` (a device picker filtered to this integration) and resolve to exactly one appliance — a target that expands to more than one LocalThings device is rejected rather than silently fanned out across all of them. `href` is always canonical (e.g. `/mode/vs/0`); if the device you targeted is a subdevice — an oven's second cavity, an AC's second indoor unit — it's translated to the real on-the-wire href for you (`/mode/vs/1`, say), and the response reports both forms so there's no ambiguity about what was actually sent.
|
||||
|
||||
`write_resource` exists because a single write, one at a time, isn't enough to probe some boards. Issue #300's Samsung wall oven answers `2.04 Changed` to a settings write while idle and then silently reverts it — the write only sticks once a cycle is already running. Finding what actually triggers a cycle needs an *ordered sequence* of writes to different resources, with real delays between them, and a way to check afterward whether anything actually held:
|
||||
|
||||
```yaml
|
||||
action: localthings.write_resource
|
||||
data:
|
||||
device_id: abc123...
|
||||
writes:
|
||||
- href: /mode/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.modes: ["Bake"]
|
||||
settle: 5
|
||||
- href: /operational/state/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.state: "Run"
|
||||
verify_after: 30
|
||||
```
|
||||
|
||||
Mind the shapes: what you write is sent verbatim, so the field names and types have to be the ones that resource actually uses. `/mode/vs/0` takes `modes` as an *array* on this board; a bare string, or the singular `mode`, is a different field the device will simply ignore. `read_resource` (below) with no `href` is the quickest way to see the real shape of everything before you write to any of it.
|
||||
|
||||
Each write in `writes` (1-10 of them) needs `href` and a non-empty `payload`, sent verbatim as a partial-rep POST — this bypasses the remote-control-off block and every `write_fn`/`validate_fn` a normal entity write goes through, and sends exactly the fields you give it, so it can misconfigure your appliance if you get it wrong. `settle` (0-30s, default 0) is how long to wait *after* that write before starting the next one.
|
||||
|
||||
By default the whole sequence holds the device session from the first write to the last, settle delays included, so a routine poll or another entity's write can't land between two steps and blur which write the appliance was reacting to. The cost is that nothing else on that device updates until the sequence ends — up to 10 × 30s if you ask for the maximum of both. Set `hold_session_lock: false` to take the session per write and release it across the waits instead, trading that certainty for a device whose entities keep updating throughout.
|
||||
|
||||
The response has one `results` entry per write, with `before`/`after` reps and a `changed` flag (every key/value in `payload` present and equal in the immediate readback):
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "abc123...",
|
||||
"results": [
|
||||
{"href": "/mode/vs/0", "actual_href": "/mode/vs/0", "code": "2.04", "raw_code": 68,
|
||||
"accepted": true, "before": {...}, "after": {...}, "changed": true},
|
||||
...
|
||||
],
|
||||
"verified": {
|
||||
"/mode/vs/0": {"code": "2.05", "raw_code": 69, "rep": {...}, "held": false}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`verify_after` (0-60s, default 0, omit to skip) is what actually answers the "did it stick" question: after the sequence finishes, it waits that long and then re-reads every distinct href the sequence touched, reporting the result under `verified`, keyed by canonical href. `changed` tells you the write was accepted and reflected immediately; `held` tells you whether it was still there N seconds later, or whether the board quietly put it back — issue #300's exact symptom. Where an href was written more than once in a sequence, `held` compares against the *last* payload sent to it. A `held` of `null` means the re-read itself didn't come back (check `code` next to it) — unknown, deliberately not reported as a revert.
|
||||
|
||||
If the session drops partway through a sequence, the action raises rather than returning, and the error names how many writes completed and which — the appliance is left holding a partial sequence, so knowing where it stopped is the difference between a usable result and starting over blind.
|
||||
|
||||
`read_resource` is the read half, and it's deliberately not just a cache lookup:
|
||||
|
||||
```yaml
|
||||
action: localthings.read_resource
|
||||
data:
|
||||
device_id: abc123...
|
||||
href: /mode/vs/0
|
||||
```
|
||||
|
||||
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
|
||||
|
||||
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Docker Compose dev environment
|
||||
@@ -135,6 +199,8 @@ custom_components/localthings/
|
||||
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
|
||||
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
services.py write_resource/read_resource actions (device resolution, href translation)
|
||||
services.yaml Selectors/descriptions for the two services above
|
||||
const.py Domain, config keys, probe ports
|
||||
entity.py Base entity wiring capability registry -> HA entity
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
|
||||
@@ -172,6 +238,11 @@ email, access tokens, device IDs, MAC addresses, serial numbers) before it's gen
|
||||
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
|
||||
support for hardware the maintainers don't have.
|
||||
|
||||
When a diagnostics dump alone isn't enough to pin down how a resource actually behaves — whether a write sticks,
|
||||
what order things need to happen in, whether the device reverts a change on its own — the `localthings.write_resource`
|
||||
and `localthings.read_resource` actions from Part 5 are the tool for probing it directly and reporting back what
|
||||
you found.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new appliance type
|
||||
|
||||
@@ -10,14 +10,23 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.identity import resolve_serial
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# Services are process-global, registered once here rather than per
|
||||
# config entry (issue #300) -- see services.async_setup_services.
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
def _serial_from_unique_id(entry: ConfigEntry) -> str:
|
||||
"""The device identity a pre-v2 entry was created with.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ temperature and wind resources alike.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -79,7 +80,11 @@ from .registry.capabilities.airconditioner import (
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
extend_option_code_bit,
|
||||
has_extend_option_code,
|
||||
has_option_code,
|
||||
is_legacy_board,
|
||||
option_code_bit,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
from .registry.entities import ClimateDesc
|
||||
@@ -131,6 +136,10 @@ PRESET_AI_COMFORT = "ai_comfort"
|
||||
# not modeled as a preset either: nothing confirms it's user-selectable.
|
||||
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
|
||||
|
||||
# Seconds to let a legacy board settle into Cool before the WindFree token is
|
||||
# written after it -- see _legacy_preset_needs_cool for the measurement.
|
||||
_NANO_AFTER_MODE_DELAY = 3
|
||||
|
||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
||||
_DEVICE_TO_FAN: dict[str, str] = {
|
||||
@@ -267,17 +276,125 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
|
||||
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
|
||||
|
||||
# Good Sleep occupies the same Comode_ slot as the presets above, so a unit
|
||||
# running it reports Comode_Sleep -- or Comode_NanoSleep, which the board
|
||||
# will produce by itself when nano wind is asked for while the timer runs.
|
||||
# Neither is in the list above, and a preset_mode outside preset_modes is
|
||||
# not a state HA allows, so they are added for boards that have the Sleep_
|
||||
# token these codes come with.
|
||||
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
|
||||
|
||||
# HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode
|
||||
# this transcription has never seen, which is the same "cannot judge" case as
|
||||
# a board that publishes no capability map -- and gets the same fallback.
|
||||
_LEGACY_KNOWN_HVAC = frozenset(
|
||||
{"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE}
|
||||
)
|
||||
|
||||
# Which comfort modes a legacy board offers in which HVAC mode, and which of
|
||||
# them it has at all. Both come from the appliance rather than from a table
|
||||
# per model: the unit publishes its capabilities as two bit maps in
|
||||
# /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates
|
||||
# each item on a bit plus the current mode. _legacy_preset_codes() below is
|
||||
# that logic, transcribed from the app's updateOptionsList() so the two can be
|
||||
# compared line by line, with the bit each rule reads named in the comment.
|
||||
#
|
||||
# Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off
|
||||
# the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model)
|
||||
# Auto but never Heat, and Fast Turbo and Comfort in Heat because oc[12] is
|
||||
# set. d'light Cool is gated the same way on oc[2], which is zero here -- and
|
||||
# the appliance refuses the token locally too.
|
||||
#
|
||||
# Single User is deliberately not modelled, though the app does gate it on
|
||||
# oc[3] / oc[11]: it has no token of its own. The app's own Single User
|
||||
# command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded
|
||||
# 24 desired alongside it, so there is nothing to write that would be
|
||||
# distinguishable from the Smart preset below, and no name to give it that
|
||||
# the appliance would recognise.
|
||||
|
||||
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
|
||||
"""Comfort-mode codes this unit offers in this HVAC mode.
|
||||
|
||||
Derived only for boards that publish *both* capability maps. One map on
|
||||
its own is not enough: every eoc-gated rule would then read None, and
|
||||
None means "this board does not publish the map", never "the feature is
|
||||
absent". The FAC/CAC boards on record carry only the older map, with
|
||||
values small enough that RAC bit positions all read as zeros, so
|
||||
requiring both also keeps these rules inside the family they were
|
||||
documented for.
|
||||
|
||||
Within the derived path, a bit that cannot be read (a malformed or
|
||||
over-wide token) is treated as permission rather than denial for the
|
||||
codes the unconditional list already carried -- losing a working preset
|
||||
to a parsing failure is worse than offering one too many. Codes that were
|
||||
never in that list (d'light) still need their bit to be explicitly set.
|
||||
"""
|
||||
rep = self._rep(MODE_HREF)
|
||||
if (
|
||||
not has_option_code(rep)
|
||||
or not has_extend_option_code(rep)
|
||||
or hvac not in self._LEGACY_KNOWN_HVAC
|
||||
):
|
||||
return list(self._LEGACY_PRESET_CODES)
|
||||
|
||||
cool = hvac in ("Cool", _AI_COMFORT_MODE)
|
||||
heat = hvac in ("Heat", "HeatClean")
|
||||
codes = ["Off"]
|
||||
# WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto
|
||||
# unless this is an 18K model (eoc[30]), where the app switches to Cool for
|
||||
# it instead -- which is what _legacy_preset_needs_cool does.
|
||||
nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE
|
||||
if hvac == "Auto":
|
||||
nano_mode_ok = extend_option_code_bit(rep, 30) is not False
|
||||
if extend_option_code_bit(rep, 31) is not False and nano_mode_ok:
|
||||
codes.append("Nano")
|
||||
if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo
|
||||
codes.append("Speed")
|
||||
if cool:
|
||||
codes.append("2Step")
|
||||
if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set
|
||||
codes.append("DlightCool")
|
||||
# Quiet reads oc[10] with no mode condition in the app, but the owner of
|
||||
# the unit above sees it in Cool and Heat only, on the remote as well as
|
||||
# in the app -- the observation wins over the reading.
|
||||
if option_code_bit(rep, 10) is not False and (cool or heat):
|
||||
codes.append("Quiet")
|
||||
if cool or (heat and option_code_bit(rep, 12) is not False): # Comfort
|
||||
codes.append("Comfort")
|
||||
# Smart Saver has no bit of its own and the app hides it from every single
|
||||
# RAC outright (showSaverOption = false), yet the appliance accepts it and
|
||||
# behaves as Samsung documents -- so absence from the app is not absence
|
||||
# from the hardware. Cool-only, per that documentation.
|
||||
if hvac == "Cool":
|
||||
codes.append("Smart")
|
||||
# Good Sleep, which shares this slot: the app enables it in Cool, Heat and
|
||||
# AIComfort only. Both codes, because the board turns Sleep into NanoSleep
|
||||
# by itself when WindFree is running.
|
||||
if (cool or heat) and any(
|
||||
isinstance(option, str) and option.startswith("Sleep_") for option in options
|
||||
):
|
||||
codes += self._LEGACY_SLEEP_PRESET_CODES
|
||||
return codes
|
||||
|
||||
def _legacy_convenient(self) -> dict:
|
||||
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
|
||||
/mode/vs/0's options, for boards that have no convenient resource."""
|
||||
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
|
||||
for option in options:
|
||||
if isinstance(option, str) and option.startswith("Comode_"):
|
||||
return {
|
||||
_MODES_FIELD: [option.split("_", 1)[1]],
|
||||
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES),
|
||||
}
|
||||
return {}
|
||||
active = next(
|
||||
(o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")),
|
||||
None,
|
||||
)
|
||||
if active is None:
|
||||
return {}
|
||||
|
||||
codes = self._legacy_preset_codes(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options)
|
||||
# Whatever the unit is actually running has to be listed whether the
|
||||
# rules expect it there or not -- a preset_mode outside preset_modes is
|
||||
# not a state HA allows, and the appliance has the last word on what it
|
||||
# is doing (a remote can put it in a mode these rules would not offer).
|
||||
if active not in codes:
|
||||
codes.append(active)
|
||||
return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes}
|
||||
|
||||
def _legacy_airflow(self) -> dict:
|
||||
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel
|
||||
@@ -622,6 +739,38 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
if self._rep(WIND_OSCILLATION_HREF):
|
||||
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
|
||||
|
||||
async def _legacy_preset_needs_cool(self, code: str) -> None:
|
||||
"""Switch a legacy board to Cool first when the preset needs it.
|
||||
|
||||
WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit
|
||||
is in Auto is answered 2.04 Changed and dropped (measured, still
|
||||
Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST
|
||||
does not help -- the mode moves and the token is still dropped, so the
|
||||
board judges the option against the mode it was in. Sent as its own write
|
||||
first, it holds. The appliance's own app pairs `modes: Cool` with its nano
|
||||
command for the same reason.
|
||||
|
||||
Auto only. The app's builder also covers AIComfort, but its
|
||||
`updateOptionsList()` disables the WindFree button there outright, so that
|
||||
pairing can never fire -- and `_legacy_preset_codes()` likewise does not
|
||||
offer `Nano` in AIComfort, which would leave such a branch unreachable.
|
||||
|
||||
The pause is measured, not padding: back to back (same session, no gap at
|
||||
all) the token was dropped again, two seconds apart it held. Three is that
|
||||
with a little margin, and it only ever runs for this one preset in this
|
||||
one HVAC mode.
|
||||
"""
|
||||
if not self._legacy_preset() or code != "Nano":
|
||||
return
|
||||
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto":
|
||||
return
|
||||
# Same resolver the rest of the platform uses -- the device code for an HA
|
||||
# mode is read off the unit's own supportedModes rather than assumed.
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ("mode", self._device_code_for_hvac(HVACMode.COOL))
|
||||
)
|
||||
await asyncio.sleep(_NANO_AFTER_MODE_DELAY)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode == PRESET_AI_COMFORT:
|
||||
# Writes the primary mode resource, not the convenient one --
|
||||
@@ -633,6 +782,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
|
||||
for code in self._supported(CONVENIENT_HREF):
|
||||
if _preset_to_ha(code) == preset_mode:
|
||||
await self._legacy_preset_needs_cool(code)
|
||||
kind = "preset_legacy" if self._legacy_preset() else "preset"
|
||||
await self.coordinator.async_send_command(self._bound, (kind, code))
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@ import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
@@ -55,6 +56,7 @@ from .const import (
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
PROBE_MAX_WORKERS,
|
||||
PROBE_PORT_RANGE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
@@ -163,6 +165,17 @@ def _fetch_samsung_uuid() -> str:
|
||||
raise RuntimeError(f"UUID not found in {_SAMSUNG_CLOUD_HOST} certificate subject")
|
||||
|
||||
|
||||
def _normalize_pem(text: str) -> str:
|
||||
"""Strip a pasted PEM's BOM, CRLF endings, and blank lines before
|
||||
`cryptography` sees it -- a text editor's copy carries all three and
|
||||
fails with an opaque InvalidHeader, while the same file dumped via
|
||||
`type` doesn't (issue #291)."""
|
||||
text = text.lstrip("\ufeff")
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [line for line in text.split("\n") if line.strip()]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str, str]:
|
||||
"""Mint a fresh RSA-2048 leaf cert signed by the CA.
|
||||
|
||||
@@ -722,8 +735,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if leaf_cert and leaf_key:
|
||||
existing_leaf = (leaf_cert, leaf_key)
|
||||
else:
|
||||
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
|
||||
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
|
||||
# Normalized here, not just before minting: this is also
|
||||
# what gets stored and reused to re-mint the leaf later.
|
||||
self._ca_cert_pem = _normalize_pem(user_input[CONF_CA_CERT_PEM])
|
||||
self._ca_key_pem = _normalize_pem(user_input[CONF_CA_KEY_PEM])
|
||||
|
||||
try:
|
||||
info = await self.hass.async_add_executor_job(
|
||||
@@ -764,7 +779,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -912,8 +927,33 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"payload": "empty_payload"}, payload
|
||||
)
|
||||
# Goes through the write_resource service (issue #300), not
|
||||
# coord.async_raw_write directly, so there is exactly one code
|
||||
# path that performs a raw write. MAIN's own device -- the
|
||||
# panel's href dropdown already lists actual hrefs off
|
||||
# coord.last_resources, and MAIN.to_actual is identity, so
|
||||
# this preserves the panel's existing behavior byte for byte.
|
||||
dev = dr.async_get(self.hass).async_get_device(
|
||||
identifiers=coord.device_info["identifiers"]
|
||||
)
|
||||
if dev is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
try:
|
||||
code, new_rep = await coord.async_raw_write(href, payload)
|
||||
response = await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": href, "payload": payload}]},
|
||||
target={"device_id": dev.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
results = (response or {}).get("results")
|
||||
first = results[0] if isinstance(results, list) and results else None
|
||||
raw_code = first.get("raw_code") if isinstance(first, dict) else None
|
||||
after = first.get("after") if isinstance(first, dict) else None
|
||||
if not isinstance(raw_code, int) or not isinstance(after, dict):
|
||||
raise RuntimeError("write_resource service returned an unexpected shape")
|
||||
code, new_rep = raw_code, after
|
||||
except Exception:
|
||||
_LOGGER.exception("debug raw write failed for %s", href)
|
||||
return self._show_debug_edit_form(href, current, {"base": "write_failed"}, payload)
|
||||
|
||||
@@ -96,3 +96,9 @@ SUMMARY_INTERVAL_S = 30.0
|
||||
DEVICE_SUPPORT_ISSUE_URL = (
|
||||
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
|
||||
)
|
||||
|
||||
# Service names (services.py), shared with config_flow.py so the
|
||||
# options-flow debug panel calls the exact same service a user could call
|
||||
# from an automation (issue #300) -- one code path performs a raw write.
|
||||
SERVICE_WRITE_RESOURCE = "write_resource"
|
||||
SERVICE_READ_RESOURCE = "read_resource"
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any
|
||||
import cbor2
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -100,6 +100,68 @@ def _local_source_port(host: str) -> int:
|
||||
return DTLS_LOCAL_PORT_BASE + offset
|
||||
|
||||
|
||||
# Debug raw write/read caps (issue #300) -- generous enough for a real
|
||||
# probing session (the wall-oven reporter's own sequences run well under
|
||||
# 10 steps) while bounding how long one service call can hold up polling.
|
||||
_DEBUG_MAX_WRITES = 10
|
||||
_DEBUG_MAX_SETTLE_S = 30.0
|
||||
_DEBUG_MAX_VERIFY_AFTER_S = 60.0
|
||||
|
||||
|
||||
def _href_to_path_segs(href: str) -> list[str]:
|
||||
"""'/mode/vs/0' -> ['mode', 'vs', '0'], the shape `sess.get`/`sess.post`
|
||||
take. Shared by every raw debug read/write path."""
|
||||
return [s for s in str(href).strip("/").split("/") if s]
|
||||
|
||||
|
||||
def normalize_href(href: str) -> str:
|
||||
"""A user-typed href in one canonical spelling. Public because
|
||||
services.py must normalize before `Subdevice.to_actual`, which rewrites
|
||||
only a trailing '0' segment: '/mode/vs/0/' slips through it unchanged
|
||||
and would land on the master's resource, not the subdevice's."""
|
||||
return "/" + "/".join(_href_to_path_segs(href))
|
||||
|
||||
|
||||
def _coap_code_str(code: int) -> str:
|
||||
"""Raw CoAP response code -> its 'C.DD' rendering (e.g. 0x44 -> '2.04'),
|
||||
the class/detail split RFC 7252 §12.1.2 defines. `raw_code` is kept
|
||||
alongside it in every debug response so a caller can format it
|
||||
differently."""
|
||||
return f"{code >> 5}.{code & 0x1F:02d}"
|
||||
|
||||
|
||||
def _coap_accepted(code: int) -> bool:
|
||||
"""True for a 2.xx class CoAP response."""
|
||||
return (code >> 5) == 2
|
||||
|
||||
|
||||
def _validate_debug_write_item(item: dict) -> tuple[list[str], str, dict, float]:
|
||||
"""The checks `async_raw_write` has always applied to a single write
|
||||
(issue #54), reused per-item by `async_raw_write_sequence` (issue
|
||||
#300). Payload is checked before href, matching the original
|
||||
single-write order -- not load-bearing for any test, just avoiding a
|
||||
silent behavior change in the refactor."""
|
||||
payload = item.get("payload")
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_payload_empty",
|
||||
)
|
||||
path_segs = _href_to_path_segs(item.get("href", ""))
|
||||
if not path_segs:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="resource_href_required",
|
||||
)
|
||||
settle = item.get("settle") or 0.0
|
||||
if not 0 <= settle <= _DEBUG_MAX_SETTLE_S:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_settle_out_of_range",
|
||||
)
|
||||
return path_segs, "/" + "/".join(path_segs), payload, settle
|
||||
|
||||
|
||||
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Manages one Samsung appliance: session, discovery, polling."""
|
||||
|
||||
@@ -204,6 +266,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._consecutive_poll_timeouts = 0
|
||||
self._unbound_hrefs: list[str] = []
|
||||
self._reconnect_times: list[float] = []
|
||||
# See _maybe_retry_observe_mode: last_mode_change_ts alone doesn't
|
||||
# move on a failed attempt, so this tracks attempts too.
|
||||
self._last_observe_attempt_ts = 0.0
|
||||
# Set by both reconnect paths (poll and command) that hand back a
|
||||
# session with zero OBSERVE registrations while mode was still
|
||||
# observe; consumed once to trigger an immediate resubscribe
|
||||
# instead of waiting out _RECOVERY_RETRY_S.
|
||||
self._resubscribe_due = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session management (all blocking — must run in executor)
|
||||
@@ -731,29 +801,72 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
|
||||
async def _attempt_observe_mode(self) -> None:
|
||||
"""Called once, right after first discovery. Blocking (sleeps for
|
||||
the whole grace period) — must run in an executor."""
|
||||
"""Called once, right after first discovery, after a reconnect
|
||||
downgrades from observe, and periodically while polling. Two
|
||||
phases: subscribing holds `_session_lock` (each send is fire-and-
|
||||
forget, not a network round trip); the grace wait that follows
|
||||
does not, so a concurrent command write isn't blocked for the
|
||||
whole ~15s wait (issue #294) -- only the brief subscribe burst.
|
||||
|
||||
The wait can outlast a reconnect elsewhere (the poll path's own
|
||||
recovery, or a command retry), which would otherwise let a stale
|
||||
success commit observe mode against a session that's already been
|
||||
replaced -- claiming "Push" with nothing left to notice it's dead.
|
||||
`self._session is sess` re-checked under the lock right before
|
||||
committing closes that: `sess` keeps the old object alive, so
|
||||
identity can't be recycled onto a new one.
|
||||
"""
|
||||
hrefs = self._hot_hrefs + self._warm_hrefs
|
||||
if not hrefs:
|
||||
return
|
||||
if self._session is None:
|
||||
# _poll_once already connects on a real poll; only fires if the
|
||||
# session was closed out from under us concurrently.
|
||||
await self.hass.async_add_executor_job(self._connect_session)
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
self._last_observe_attempt_ts = time.monotonic()
|
||||
async with self._session_lock:
|
||||
if self._session is None:
|
||||
# _poll_once already connects on a real poll; only fires if
|
||||
# the session was closed out from under us concurrently.
|
||||
await self.hass.async_add_executor_job(self._connect_session)
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
return
|
||||
subscribed = await self.hass.async_add_executor_job(
|
||||
self._observe.subscribe_hrefs, sess, hrefs
|
||||
)
|
||||
if not subscribed:
|
||||
self._observe.abandon_observe_attempt()
|
||||
return
|
||||
await self.hass.async_add_executor_job(
|
||||
self._observe.try_enter_observe_mode,
|
||||
sess,
|
||||
hrefs,
|
||||
self._OBSERVE_GRACE_PERIOD_S,
|
||||
reached = await self.hass.async_add_executor_job(
|
||||
self._observe.await_observe_notifies, subscribed, self._OBSERVE_GRACE_PERIOD_S
|
||||
)
|
||||
async with self._session_lock:
|
||||
stale_session = self._session is not sess
|
||||
if not reached or stale_session:
|
||||
self._observe.abandon_observe_attempt()
|
||||
if stale_session:
|
||||
# A reconnect elsewhere replaced the session while this
|
||||
# attempt waited -- that session has never been tried,
|
||||
# so retry it next cycle instead of leaving it
|
||||
# unsubscribed for up to _RECOVERY_RETRY_S, which
|
||||
# _last_observe_attempt_ts (already stamped above, for
|
||||
# the now-abandoned session) would otherwise throttle
|
||||
# for (issue #294).
|
||||
self._resubscribe_due = True
|
||||
return
|
||||
self._observe.enter_observe_mode(sess, subscribed)
|
||||
|
||||
async def _maybe_retry_observe_mode(self) -> None:
|
||||
"""While in poll-only mode, periodically re-attempt observe mode
|
||||
so a device that gains internet access recovers push automatically."""
|
||||
if time.monotonic() - self._observe.last_mode_change_ts < _RECOVERY_RETRY_S:
|
||||
so a device that gains internet access recovers push automatically.
|
||||
|
||||
Gated on the more recent of the two timestamps, not just
|
||||
`last_mode_change_ts`: `_set_mode` only stamps that on an actual
|
||||
transition, so a device that never successfully enters observe
|
||||
mode would otherwise leave this throttle open forever after the
|
||||
first `_RECOVERY_RETRY_S` window -- re-attempting (and paying the
|
||||
subscribe-burst lock) on every single poll cycle instead of every
|
||||
`_RECOVERY_RETRY_S`.
|
||||
"""
|
||||
last_attempt = max(self._observe.last_mode_change_ts, self._last_observe_attempt_ts)
|
||||
if time.monotonic() - last_attempt < _RECOVERY_RETRY_S:
|
||||
return
|
||||
await self._attempt_observe_mode()
|
||||
|
||||
@@ -805,7 +918,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._subpoll_task.cancel()
|
||||
self._subpoll_task = None
|
||||
|
||||
just_downgraded_from_observe = False
|
||||
async with self._session_lock:
|
||||
try:
|
||||
resources = await self.hass.async_add_executor_job(self._poll_once)
|
||||
@@ -834,6 +946,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
resources = await self.hass.async_add_executor_job(self._poll_once)
|
||||
except Exception as e2:
|
||||
self._log.error("poll failed after reconnect: %s", e2)
|
||||
# Without this, a fully unreachable device left the
|
||||
# connection-mode sensor stuck on "Push" forever -- only
|
||||
# the success branch below ever downgraded it (issue
|
||||
# #287). No just_downgraded_from_observe here: there's no
|
||||
# live session this cycle to resubscribe on.
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
self._observe.downgrade_to_poll()
|
||||
snapshot = self._cache.snapshot()
|
||||
# Same precondition as _defer_reconnect_for (issue #254):
|
||||
# degraded-but-successful data only makes sense once
|
||||
@@ -855,7 +974,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"poll and resubscribing on the new session"
|
||||
)
|
||||
self._observe.downgrade_to_poll()
|
||||
just_downgraded_from_observe = True
|
||||
self._resubscribe_due = True
|
||||
|
||||
if not self._discovered:
|
||||
# One-time (issue #177): find sibling subdevices before the
|
||||
@@ -888,7 +1007,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
for href, rep in resources.items():
|
||||
self._observe.apply(href, rep, source=source)
|
||||
|
||||
if first_cycle or just_downgraded_from_observe:
|
||||
if first_cycle or self._resubscribe_due:
|
||||
self._resubscribe_due = False
|
||||
await self._attempt_observe_mode()
|
||||
elif self._observe.mode == MODE_POLL:
|
||||
await self._maybe_retry_observe_mode()
|
||||
@@ -910,7 +1030,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None:
|
||||
"""Write a value to the device. Fire-and-forget.
|
||||
"""Write a value to the device. Retries once on a dead session
|
||||
(issue #294); raises HomeAssistantError if that retry fails too.
|
||||
|
||||
A description-level validate_fn (SwitchDesc only, currently) rejects
|
||||
a write with a user-facing message ahead of write_fn's silent
|
||||
@@ -1012,24 +1133,60 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
|
||||
def _do_put():
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
self._log.info("PUT %s → code %#04x", write_href, code)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_do_put)
|
||||
except Exception as e:
|
||||
self._log.error("command failed for %s: %s", write_href, e)
|
||||
else:
|
||||
await self.async_request_refresh()
|
||||
# Mirrors the poll path's reconnect-and-retry (issue #294): a PUT
|
||||
# landing on a session Samsung's firmware closed between polls used
|
||||
# to be silently lost -- no retry, no user-facing error.
|
||||
async with self._session_lock:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_do_put)
|
||||
except Exception as e:
|
||||
self._log.warning("command failed for %s, reconnecting: %s", write_href, e)
|
||||
await self.hass.async_add_executor_job(self._close_session)
|
||||
# The session is dead the moment it's closed, so any OBSERVE
|
||||
# subscriptions on it are too -- downgrade here, before the
|
||||
# retry, so a retry that also fails doesn't leave mode
|
||||
# claiming "Push" on a session that no longer exists
|
||||
# (issue #294; the poll path handles the same fact for its
|
||||
# own reconnect the same way, unconditionally on close).
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
self._observe.downgrade_to_poll()
|
||||
self._resubscribe_due = True
|
||||
await asyncio.sleep(self._RECONNECT_PAUSE_S)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_do_put)
|
||||
except Exception as e2:
|
||||
self._log.error("command failed for %s after reconnect: %s", write_href, e2)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"href": write_href, "error": str(e2)},
|
||||
) from e2
|
||||
# The retry's own reconnect pause + second PUT can eat well
|
||||
# into the settle window armed above, leaving too little of
|
||||
# it for the confirming poll below and reviving the
|
||||
# revert-then-reapply symptom settle_s exists to prevent
|
||||
# (issue #9). Re-arm it fresh now that the write actually
|
||||
# landed.
|
||||
self._observe.mark_write_pending(
|
||||
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
|
||||
)
|
||||
await self.async_request_refresh()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Debug raw write (issue #54): a power-user escape hatch for the
|
||||
# options-flow debug panel to POST an arbitrary partial body without a
|
||||
# new release. Deliberately bypasses the remote-control block and all
|
||||
# write_fn/validate_fn above -- use with care.
|
||||
# Debug raw write/read (issue #54, extended for issue #300): a
|
||||
# power-user escape hatch shared by the options-flow debug panel and
|
||||
# the write_resource/read_resource services (services.py) for probing
|
||||
# a device's write contract directly. Deliberately bypasses the
|
||||
# remote-control block and all write_fn/validate_fn above -- use with
|
||||
# care.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
||||
@@ -1055,17 +1212,34 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._log.debug("raw write follow-up read failed: %s", e)
|
||||
return code, new_rep
|
||||
|
||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||
"""Debug-only arbitrary write (issue #54). Bypasses remote-control
|
||||
and write_fn/validate_fn; sends `body` verbatim as a partial-rep
|
||||
PATCH to `href`. Returns (coap_code, new_rep) read back right
|
||||
after."""
|
||||
if not isinstance(body, dict) or not body:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_payload_empty",
|
||||
)
|
||||
path_segs = [s for s in href.strip("/").split("/") if s]
|
||||
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict]:
|
||||
"""Debug primitive: a live GET, deliberately bypassing the cache
|
||||
(issue #300) -- the cache can be up to a poll interval stale,
|
||||
exactly the staleness that makes testing whether a write held or
|
||||
got silently reverted by the board unreliable. Blocking -- runs in
|
||||
executor."""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
code, payload = sess.get(path_segs, timeout=10.0)
|
||||
rep: dict = {}
|
||||
if code == 0x45 and payload:
|
||||
try:
|
||||
body = cbor2.loads(payload)
|
||||
except Exception as e:
|
||||
self._log.debug("raw read decode failed for %s: %s", href, e)
|
||||
body = None
|
||||
if isinstance(body, dict):
|
||||
self._observe.apply(href, body, source="poll")
|
||||
rep = body
|
||||
return code, rep
|
||||
|
||||
async def async_raw_read(self, href: str) -> tuple[int, dict]:
|
||||
"""Debug-only live GET (issue #300, backs the read_resource
|
||||
service). Same href validation as async_raw_write."""
|
||||
path_segs = _href_to_path_segs(href)
|
||||
if not path_segs:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -1073,10 +1247,150 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
norm_href = "/" + "/".join(path_segs)
|
||||
async with self._session_lock:
|
||||
code, new_rep = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, body, norm_href
|
||||
return await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, path_segs, norm_href
|
||||
)
|
||||
|
||||
async def async_raw_write_sequence(
|
||||
self,
|
||||
writes: list[dict],
|
||||
*,
|
||||
verify_after: float = 0.0,
|
||||
hold_session_lock: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Debug-only ordered multi-write (issue #300): a Samsung wall oven
|
||||
board discards settings writes while idle and only keeps them once
|
||||
a cycle is already running, which no single-write debug pass can
|
||||
probe for.
|
||||
|
||||
`hold_session_lock` (default) keeps `_session_lock` for the whole
|
||||
sequence, settle waits included, so nothing interleaves between
|
||||
steps and blurs which write the appliance reacted to -- at the cost
|
||||
of blocking polls and entity writes for the sequence's full length
|
||||
(up to 10 x 30s). Pass False to take the lock per write and release
|
||||
it across the waits, for a long sequence where a stalled poll costs
|
||||
more than an interleaved read.
|
||||
|
||||
`writes` are already on-the-wire hrefs: subdevice translation
|
||||
(canonical -> actual) is services.py's job, not this method's --
|
||||
this primitive has no notion of subdevices, same as the original
|
||||
single-write async_raw_write never did.
|
||||
|
||||
`async_raw_write` below delegates here with a one-item sequence, so
|
||||
its signature/return and tests/test_coordinator_raw_write.py stay
|
||||
unchanged.
|
||||
"""
|
||||
if not writes or len(writes) > _DEBUG_MAX_WRITES:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_too_many_writes",
|
||||
)
|
||||
if not 0 <= verify_after <= _DEBUG_MAX_VERIFY_AFTER_S:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_verify_after_out_of_range",
|
||||
)
|
||||
# Validate every item before touching the session -- a rejected
|
||||
# call must fail before any write goes out (same posture the
|
||||
# single-write path has always had; see
|
||||
# test_raw_write_validation_errors_do_not_touch_the_session).
|
||||
parsed = [_validate_debug_write_item(w) for w in writes]
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
last_payload_by_href: dict[str, dict] = {}
|
||||
# Exactly one of these is the real lock, never both -- asyncio.Lock
|
||||
# isn't reentrant, so nesting the same one would deadlock.
|
||||
outer_lock = self._session_lock if hold_session_lock else contextlib.nullcontext()
|
||||
try:
|
||||
async with outer_lock:
|
||||
for i, (path_segs, href, payload, settle) in enumerate(parsed):
|
||||
per_write_lock = (
|
||||
contextlib.nullcontext() if hold_session_lock else self._session_lock
|
||||
)
|
||||
async with per_write_lock:
|
||||
before = self.resource(href)
|
||||
code, after = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, payload, href
|
||||
)
|
||||
last_payload_by_href[href] = payload
|
||||
results.append(
|
||||
{
|
||||
"href": href,
|
||||
"code": _coap_code_str(code),
|
||||
"raw_code": code,
|
||||
"accepted": _coap_accepted(code),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"changed": all(after.get(k) == v for k, v in payload.items()),
|
||||
}
|
||||
)
|
||||
# Under the default this wait happens inside the lock, so
|
||||
# nothing lands between two writes to blur which one the
|
||||
# appliance reacted to; see hold_session_lock above.
|
||||
if settle and i < len(parsed) - 1:
|
||||
await asyncio.sleep(settle)
|
||||
except Exception as err:
|
||||
# A drop partway leaves the appliance holding whatever already
|
||||
# landed, so the error has to say which writes got through.
|
||||
done = ", ".join(r["href"] for r in results) or "none"
|
||||
self._log.warning(
|
||||
"raw write sequence failed after %d of %d writes (completed: %s): %s",
|
||||
len(results),
|
||||
len(parsed),
|
||||
done,
|
||||
err,
|
||||
)
|
||||
await self.async_request_refresh()
|
||||
raise HomeAssistantError(
|
||||
f"Raw write sequence failed after {len(results)} of {len(parsed)} writes "
|
||||
f"(completed: {done}). The appliance may be holding a partial sequence."
|
||||
) from err
|
||||
|
||||
response: dict[str, Any] = {"results": results}
|
||||
if verify_after > 0:
|
||||
# Released, not held, across this wait: holding _session_lock
|
||||
# through up to 60s would stall the summary poll for that whole
|
||||
# window (same reasoning as _attempt_observe_mode's grace wait,
|
||||
# issue #294). A poll interleaving here is harmless -- just
|
||||
# another read of the same hrefs.
|
||||
await asyncio.sleep(verify_after)
|
||||
verified: dict[str, Any] = {}
|
||||
async with self._session_lock:
|
||||
for href in dict.fromkeys(r["href"] for r in results):
|
||||
vcode, vrep = await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, _href_to_path_segs(href), href
|
||||
)
|
||||
# None, not False, when the re-read brought back nothing
|
||||
# to compare: every comparison against an empty rep is
|
||||
# False, which would report a 4.04 as a revert -- the one
|
||||
# distinction verify_after exists to draw.
|
||||
read_ok = _coap_accepted(vcode) and bool(vrep)
|
||||
verified[href] = {
|
||||
"code": _coap_code_str(vcode),
|
||||
"raw_code": vcode,
|
||||
"rep": vrep,
|
||||
"held": (
|
||||
all(vrep.get(k) == v for k, v in last_payload_by_href[href].items())
|
||||
if read_ok
|
||||
else None
|
||||
),
|
||||
}
|
||||
response["verified"] = verified
|
||||
|
||||
# Hasten a summary poll so entities on other resources catch up
|
||||
# too -- a debug write can affect siblings, not just its href.
|
||||
# too -- a debug write can affect siblings, not just its href. Once
|
||||
# per sequence, not per write: the whole point of ordering writes
|
||||
# under one lock hold is to control exactly what the device sees
|
||||
# and when, which a refresh racing in mid-sequence would undermine.
|
||||
await self.async_request_refresh()
|
||||
return code, new_rep
|
||||
return response
|
||||
|
||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||
"""Debug-only arbitrary write (issue #54). Bypasses remote-control
|
||||
and write_fn/validate_fn; sends `body` verbatim as a partial-rep
|
||||
PATCH to `href`. Returns (coap_code, new_rep) read back right
|
||||
after -- a thin single-write wrapper over async_raw_write_sequence
|
||||
(issue #300)."""
|
||||
sequence = await self.async_raw_write_sequence([{"href": href, "payload": body}])
|
||||
only = sequence["results"][0]
|
||||
return only["raw_code"], only["after"]
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.2"
|
||||
],
|
||||
"version": "0.19.0"
|
||||
"version": "0.20.0"
|
||||
}
|
||||
|
||||
@@ -171,17 +171,16 @@ class ObserveManager:
|
||||
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
|
||||
)
|
||||
|
||||
def try_enter_observe_mode(
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — subscribes to every href then waits up to
|
||||
`grace_period_s`, returning early once `success_fraction` of hrefs
|
||||
have notified. Caller must run this in an executor, never on the
|
||||
event loop."""
|
||||
def subscribe_hrefs(self, session, hrefs: list[str]) -> set[str]:
|
||||
"""Register OBSERVE on every href; returns the ones that took.
|
||||
Blocking — run in an executor.
|
||||
|
||||
Split from the grace wait below (issue #294) so the coordinator can
|
||||
hold its session lock for just these sends -- each is a fire-and-
|
||||
forget UDP datagram (DtlsCoapSession.subscribe doesn't wait for the
|
||||
device's ack), unlike the wait, which can block for the whole grace
|
||||
period and must not hold a lock a command write is also waiting on.
|
||||
"""
|
||||
with self._notify_cond:
|
||||
self._notified.clear()
|
||||
subscribed: set[str] = set()
|
||||
@@ -192,30 +191,65 @@ class ObserveManager:
|
||||
subscribed.add(href)
|
||||
except Exception as e:
|
||||
self.log.warning("subscribe %s failed: %s", href, e)
|
||||
return subscribed
|
||||
|
||||
def await_observe_notifies(
|
||||
self,
|
||||
subscribed: set[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — waits up to `grace_period_s`, returning early once
|
||||
`success_fraction` of `subscribed` have notified. Touches no
|
||||
session; safe to run without holding a session lock."""
|
||||
if not subscribed:
|
||||
self._stop_refresh_task()
|
||||
self._set_mode(MODE_POLL)
|
||||
self.subscribed_hrefs = set()
|
||||
return False
|
||||
|
||||
def _fraction_reached() -> bool:
|
||||
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
|
||||
|
||||
with self._notify_cond:
|
||||
reached = self._notify_cond.wait_for(
|
||||
_fraction_reached,
|
||||
timeout=grace_period_s,
|
||||
)
|
||||
return self._notify_cond.wait_for(_fraction_reached, timeout=grace_period_s)
|
||||
|
||||
if reached:
|
||||
self.subscribed_hrefs = subscribed
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
return True
|
||||
def enter_observe_mode(self, session, subscribed: set[str]) -> None:
|
||||
"""Commit a successful attempt. Caller must have re-confirmed
|
||||
`session` is still the live one under its session lock (issue
|
||||
#294) -- committing against a session a reconnect already replaced
|
||||
would claim observe mode with nothing left to notice it's dead."""
|
||||
self.subscribed_hrefs = set(subscribed)
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
|
||||
def abandon_observe_attempt(self) -> None:
|
||||
"""Drop a failed or stale attempt: no subscriptions worth keeping."""
|
||||
self._stop_refresh_task()
|
||||
self.subscribed_hrefs = set()
|
||||
self._set_mode(MODE_POLL)
|
||||
|
||||
def try_enter_observe_mode(
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — subscribes to every href then waits up to
|
||||
`grace_period_s`, returning early once `success_fraction` of hrefs
|
||||
have notified. Caller must run this in an executor, never on the
|
||||
event loop.
|
||||
|
||||
Single-threaded convenience wrapper around the phase split above
|
||||
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
|
||||
abandon_observe_attempt) for callers -- direct and most existing
|
||||
tests -- that don't need the lock-scoping those phases exist for."""
|
||||
subscribed = self.subscribe_hrefs(session, hrefs)
|
||||
if not subscribed:
|
||||
self.abandon_observe_attempt()
|
||||
return False
|
||||
if self.await_observe_notifies(subscribed, grace_period_s, success_fraction):
|
||||
self.enter_observe_mode(session, subscribed)
|
||||
return True
|
||||
self.abandon_observe_attempt()
|
||||
return False
|
||||
|
||||
def _set_mode(self, mode: str) -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@ uses unconditionally.
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
"""
|
||||
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -46,6 +46,28 @@ REGISTRY = DeviceRegistry(
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
# TP1X_DA-AC-FAC-class (issue #319): shares its /display/vs/0,
|
||||
# /settings/sound/output/vs/0 and /settings/sound/volume/vs/0
|
||||
# shape with the sibling TP1X_DA-AC-AIR board in air_purifier.py.
|
||||
air_purifier.DISPLAY,
|
||||
air_purifier.SOUND_OUTPUT,
|
||||
air_purifier.SOUND_VOLUME,
|
||||
airconditioner.SOUND_MODE,
|
||||
airconditioner.ABSENCE_CLEAN,
|
||||
airconditioner.MDS_ABSENCE_CLEAN,
|
||||
airconditioner.ENERGY_SAVING,
|
||||
airconditioner.EDGE_LIGHTING,
|
||||
airconditioner.LIGHT_STATEFUL,
|
||||
# System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000):
|
||||
# WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/
|
||||
# DEVICE_ACTIVE reuse air_purifier.py's identical shapes.
|
||||
# AIR_LEVEL_CHECK is not this-device-specific -- see its
|
||||
# removal from _AC_IGNORED above.
|
||||
airconditioner.WINDFREE,
|
||||
airconditioner.WINDSLEEP,
|
||||
air_purifier.HEPA_FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIR_LEVEL_CHECK,
|
||||
*airconditioner.COVERAGE,
|
||||
]
|
||||
),
|
||||
|
||||
@@ -22,6 +22,12 @@ REGISTRY = DeviceRegistry(
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
# issue #314: /alarms/vs/0 and /kidslock/vs/0 are the same
|
||||
# generic shapes common.UNIVERSAL already models elsewhere --
|
||||
# picked individually rather than pulling in all of UNIVERSAL,
|
||||
# matching this registry's existing hand-picked-common style.
|
||||
common.ALARMS,
|
||||
common.KIDS_LOCK_VS_FALLBACK,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Oven device registry."""
|
||||
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ..capabilities import common, dishwasher, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -18,6 +18,9 @@ REGISTRY = DeviceRegistry(
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
# issue #300: /diagnosis/vs/0 is the same diagnosisStart shape
|
||||
# dishwasher.py and airconditioner.py already reuse.
|
||||
dishwasher.DIAGNOSIS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.WATER_FILTER,
|
||||
fridge.AIR_FILTER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
|
||||
@@ -31,6 +31,12 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
from .air_purifier import _AIR_QUALITY_SENSORS
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
# _AIR_QUALITY_SENSORS' fourth column (state_class) is deliberately discarded
|
||||
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
|
||||
# graded indices on that family, while this board has stamped all five as
|
||||
# `measurement` since it was added (issue #210). Consuming the column would
|
||||
# silently drop long-term statistics for two sensors on shipped devices, so
|
||||
# the shared rows supply only the key/icon/type here.
|
||||
SENSORS = Capability(
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -43,7 +49,7 @@ SENSORS = Capability(
|
||||
state_class="measurement",
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, _ in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
|
||||
@@ -51,12 +51,31 @@ def _has_top_level_modes(rep, resources):
|
||||
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
|
||||
|
||||
|
||||
# The fourth column is state_class, which is what makes Home Assistant keep
|
||||
# long-term statistics for a sensor -- without one, a reading is only in the
|
||||
# short-term recorder history and disappears with the next purge (10 days by
|
||||
# default), so it can't back a long-range air-quality graph. The values are
|
||||
# already numeric (sensor_item_value returns int), so nothing else was in the
|
||||
# way; three sensors in this same module (filter_progress, fan_speed_level,
|
||||
# hepa_filter_usage) already declare one.
|
||||
#
|
||||
# Only the three particulate readings get it. They fall monotonically with
|
||||
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
|
||||
# (issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
|
||||
# which is concentration behaviour, and an average over time is meaningful for
|
||||
# it. Odor and CleanLevel read 0-2 on every fixture and look like graded
|
||||
# indices instead, where the mean of a grade isn't obviously meaningful; left
|
||||
# without a state_class rather than guessing.
|
||||
#
|
||||
# Deliberately no device_class/unit here: pm1/pm25/pm10 would assert the
|
||||
# reading is a µg/m³ concentration, and the dumps never say so. That's a
|
||||
# separate call from making the series recordable at all.
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
("dust", "mdi:blur", "Dust"),
|
||||
("fine_dust", "mdi:blur", "FineDust"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust"),
|
||||
("odor", "mdi:scent", "Odor"),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel"),
|
||||
("dust", "mdi:blur", "Dust", "measurement"),
|
||||
("fine_dust", "mdi:blur", "FineDust", "measurement"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement"),
|
||||
("odor", "mdi:scent", "Odor", None),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel", None),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
@@ -67,9 +86,10 @@ AIR_QUALITY = Capability(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class=state_class,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
@@ -421,6 +441,10 @@ SOUND_VOLUME = Capability(
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
# Some boards (issue #319's AC) report minLevel/resolution but no
|
||||
# maxLevel -- native_max_fn would silently collapse to 0, giving a
|
||||
# slider with no real range instead of no entity at all.
|
||||
exists_fn=lambda rep, resources: "maxLevel" in rep,
|
||||
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
|
||||
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
|
||||
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
|
||||
|
||||
@@ -206,6 +206,28 @@ def _mode_options(rep):
|
||||
return opts if isinstance(opts, (list, tuple)) else ()
|
||||
|
||||
|
||||
# Samsung's "System Fresh Air Ventilator" (PR #316, model
|
||||
# ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001) self-reports oic.d.
|
||||
# airconditioner and routes through this same CLIMATE capability, but its
|
||||
# /mode/vs/0 supportedModes are Purification/Ventilation/SmartVentilation --
|
||||
# none of which climate.py's HVAC-mode table knows, so hvac_mode collapses
|
||||
# to a single stuck value with no way to tell the three apart. Gated to
|
||||
# devices whose *entire* supported-mode set is this vocabulary, so it can't
|
||||
# false-positive on a real AC's Cool/Heat/Dry list.
|
||||
_VENTILATION_MODE_VALUES = frozenset(("Purification", "Ventilation", "SmartVentilation"))
|
||||
|
||||
|
||||
def _is_ventilation_mode_device(rep, resources):
|
||||
supported = rep.get("x.com.samsung.da.supportedModes")
|
||||
if not isinstance(supported, (list, tuple)) or not supported:
|
||||
return False
|
||||
return set(supported) <= _VENTILATION_MODE_VALUES
|
||||
|
||||
|
||||
def _ventilation_mode_write(payload, rep, href=None):
|
||||
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [payload]}
|
||||
|
||||
|
||||
def _has_display_light_option(rep, resources):
|
||||
"""True when the panel light lives in /mode/vs/0's `Light_*` option
|
||||
token rather than a dedicated /light/vs/0 switch -- the two encodings
|
||||
@@ -245,6 +267,59 @@ def _option_token(rep, prefix):
|
||||
return None
|
||||
|
||||
|
||||
def option_bit(rep, prefix, index, width):
|
||||
"""One capability bit out of the `OptionCode_<n>` / `ExtendOptionCode_<n>` token.
|
||||
|
||||
The appliance packs which features it has into two integers. Its own app reads
|
||||
them by turning the number into binary, left-padding to a fixed width, and
|
||||
indexing that *string* -- so index 0 is the most significant bit, and the
|
||||
indices below are the app's own (`racOptionCodeValue[12]`, and so on), kept
|
||||
identical so the two can be compared line by line.
|
||||
|
||||
Returns None when the token is absent, which is not the same as a zero: a bit
|
||||
that is present and 0 says the feature is missing, while no token at all says
|
||||
only that this board does not publish the map.
|
||||
"""
|
||||
raw = _option_token(rep, prefix)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
bits = format(int(raw), f"0{width}b")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(bits) != width or not 0 <= index < width:
|
||||
return None # a number too wide for the map is not one we can read
|
||||
return bits[index] == "1"
|
||||
|
||||
|
||||
def option_code_bit(rep, index):
|
||||
"""A bit of the 16-wide `OptionCode` map."""
|
||||
return option_bit(rep, "OptionCode", index, 16)
|
||||
|
||||
|
||||
def extend_option_code_bit(rep, index):
|
||||
"""A bit of the 32-wide `ExtendOptionCode` map."""
|
||||
return option_bit(rep, "ExtendOptionCode", index, 32)
|
||||
|
||||
|
||||
def has_option_code(rep):
|
||||
"""Whether this board publishes the 16-wide capability map at all."""
|
||||
return _option_token(rep, "OptionCode") is not None
|
||||
|
||||
|
||||
def has_extend_option_code(rep):
|
||||
"""Whether this board publishes the 32-wide capability map at all.
|
||||
|
||||
Its own name in the app is "Single RAC new option code, as old option code
|
||||
is full", and every RAC-class dump on record carries it while the FAC/CAC
|
||||
ones carry only the older map with values small enough that RAC bit
|
||||
positions read as zeros. So its presence is the closest thing available to
|
||||
"this is the family those bit positions were documented for" -- a proxy,
|
||||
not a proof, and used only to decide whether to read the map at all.
|
||||
"""
|
||||
return _option_token(rep, "ExtendOptionCode") is not None
|
||||
|
||||
|
||||
def is_legacy_board(resources):
|
||||
"""True for the board generation whose airflow lives in /airflow/vs/0
|
||||
rather than /wind/strength/vs/0 -- every AC dump on record has one shape
|
||||
@@ -311,16 +386,78 @@ def _option_switch_write(prefix):
|
||||
return write
|
||||
|
||||
|
||||
def _option_number_write(prefix):
|
||||
def _option_number_write(prefix, factor=1):
|
||||
"""Write a numeric options token. `factor` converts the entity's unit into the
|
||||
token's own: good_sleep is offered in hours while the token counts half hours."""
|
||||
|
||||
def write(payload, rep, href=None):
|
||||
return (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": option_write(prefix, str(round(float(payload))))},
|
||||
{"x.com.samsung.da.options": option_write(prefix, str(round(float(payload) * factor)))},
|
||||
)
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _good_sleep_write(payload, rep, href=None):
|
||||
"""Good Sleep needs its mode token in the same write as its duration.
|
||||
|
||||
`Sleep_<n>` on its own is answered 2.04 Changed and then thrown away:
|
||||
measured on an ARTIK051_KRAC_18K, writing `["Sleep_4"]` left the token at
|
||||
`Sleep_0` at both +8s and +45s, while the same value written together with
|
||||
`Comode_Sleep` held. So the number is a parameter of the mode, not a
|
||||
setting of its own, and the appliance's app never sends one without the
|
||||
other either.
|
||||
|
||||
Which mode token goes with it depends on nano wind, the way the app decides
|
||||
it: nano and Good Sleep share the single `Comode_` slot, so running both is
|
||||
`Comode_NanoSleep`, and switching the timer off while nano is on leaves nano
|
||||
running rather than turning everything off.
|
||||
"""
|
||||
half_hours = round(float(payload) * 2)
|
||||
nano = _option_token(rep, "Comode") in ("Nano", "NanoSleep")
|
||||
if half_hours:
|
||||
comode = "Comode_NanoSleep" if nano else "Comode_Sleep"
|
||||
else:
|
||||
comode = "Comode_Nano" if nano else "Comode_Off"
|
||||
return (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": [comode, f"Sleep_{half_hours}"]},
|
||||
)
|
||||
|
||||
|
||||
# What the appliance itself picks when a Good Sleep mode is asked for with no
|
||||
# duration to go with it: writing a bare `Comode_Nano` over a live
|
||||
# `Comode_Sleep`/`Sleep_4` came back as `Comode_NanoSleep`/`Sleep_16`. Used only
|
||||
# when a sleep preset is selected while the timer reads 0.
|
||||
_DEFAULT_SLEEP_HALF_HOURS = 16
|
||||
|
||||
|
||||
def _preset_options(code, rep):
|
||||
"""The options array for a legacy preset write.
|
||||
|
||||
One `Comode_` token has to express both nano wind and Good Sleep, so
|
||||
selecting nano while the timer is running means `Comode_NanoSleep` -- and it
|
||||
has to carry the duration, because the board otherwise supplies its own.
|
||||
Measured: `["Comode_Nano"]` written over `Comode_Sleep`/`Sleep_4` came back
|
||||
as `Comode_NanoSleep`/`Sleep_16`, silently turning the user's two hours into
|
||||
eight. Writing the pair keeps the two hours.
|
||||
|
||||
Leaving a sleep mode needs no such care: the board zeroes the duration by
|
||||
itself. Measured on the same unit -- a bare `["Comode_Off"]` written over
|
||||
`Comode_Sleep`/`Sleep_4` read back as `Comode_Off`/`Sleep_0` at +8s and +38s
|
||||
-- so the `none` preset cannot leave a stale token behind for the next nano
|
||||
selection to pick up as a running timer.
|
||||
"""
|
||||
sleep = _option_token(rep, "Sleep")
|
||||
running = sleep not in (None, "0")
|
||||
if code == "Nano" and running:
|
||||
code = "NanoSleep"
|
||||
if code in ("Sleep", "NanoSleep"):
|
||||
return [f"Comode_{code}", f"Sleep_{sleep if running else _DEFAULT_SLEEP_HALF_HOURS}"]
|
||||
return option_write("Comode", code)
|
||||
|
||||
|
||||
def _odor_controller_active(rep):
|
||||
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
|
||||
option token (matches the SmartThings cloud's airConditionerOdorController
|
||||
@@ -401,7 +538,7 @@ def _climate_write(payload, rep, href=None):
|
||||
if kind == "swing_legacy":
|
||||
return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value})
|
||||
if kind == "preset_legacy":
|
||||
return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Comode", value)})
|
||||
return (["mode", "vs", "0"], {"x.com.samsung.da.options": _preset_options(value, rep)})
|
||||
if kind == "preset":
|
||||
return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value})
|
||||
return None
|
||||
@@ -417,6 +554,17 @@ CLIMATE = Capability(
|
||||
rep_fn=_first_mode,
|
||||
write_fn=_climate_write,
|
||||
),
|
||||
# Purification/Ventilation/SmartVentilation mode select (PR #316) --
|
||||
# _is_ventilation_mode_device gates this to devices using that
|
||||
# vocabulary exclusively, so a real AC's climate card is unaffected.
|
||||
SelectDesc(
|
||||
key="ventilation_mode",
|
||||
rep_fn=_first_mode,
|
||||
exists_fn=_is_ventilation_mode_device,
|
||||
options_field="x.com.samsung.da.supportedModes",
|
||||
icon="mdi:air-filter",
|
||||
write_fn=_ventilation_mode_write,
|
||||
),
|
||||
# Panel light switch for boards that encode it in /mode/vs/0's options
|
||||
# instead of a dedicated /light/vs/0 (see _has_display_light_option).
|
||||
# Shares the switch.display_light translation key with DISPLAY_LIGHT
|
||||
@@ -528,16 +676,34 @@ CLIMATE = Capability(
|
||||
icon="mdi:air-filter",
|
||||
entity_category="config",
|
||||
),
|
||||
# "Good Sleep" timer. 0 = off; the upper bound is a guess (only 0 has
|
||||
# been observed on hardware), so a write above 0 is unverified.
|
||||
# "Good Sleep" timer, offered in hours. 0 = off.
|
||||
#
|
||||
# The token counts *half* hours, so it is halved on the way in and doubled
|
||||
# on the way out. The appliance's own app pairs a picker of durations with
|
||||
# the values it puts on the wire, one to one:
|
||||
#
|
||||
# 0:00 0:30 1:00 1:30 2:00 2:30 3:00 4:00 5:00 ... 12:00
|
||||
# 0 1 2 3 4 5 6 8 10 ... 24
|
||||
#
|
||||
# which also pins the maximum at 12 hours (its own help text says so:
|
||||
# "will be turned off after a selected period of time (Max. 12 hours)").
|
||||
# Before this the token was published as if it were hours: the entity
|
||||
# capped at 12, which set six, and twelve hours could not be asked for at
|
||||
# all.
|
||||
#
|
||||
# Half-hour steps are what the app offers below three hours; above that it
|
||||
# offers whole hours only, and a half hour up there is untested rather than
|
||||
# known-bad. A Number cannot change step part-way, and turning this into a
|
||||
# Select of the app's sixteen values would change the entity's domain on
|
||||
# every unit that already has it, so the step stays 0.5 throughout.
|
||||
NumberDesc(
|
||||
key="good_sleep",
|
||||
rep_fn=_option_token_num("Sleep"),
|
||||
rep_fn=_option_token_num("Sleep", divisor=2),
|
||||
exists_fn=_has_option_token("Sleep"),
|
||||
write_fn=_option_number_write("Sleep"),
|
||||
write_fn=_good_sleep_write,
|
||||
native_min=0,
|
||||
native_max=12,
|
||||
step=1,
|
||||
step=0.5,
|
||||
unit="h",
|
||||
icon="mdi:sleep",
|
||||
entity_category="config",
|
||||
@@ -1034,6 +1200,253 @@ HUMIDITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# TP1X_DA-AC-FAC-class additions (issue #319): most of these hrefs are the
|
||||
# same shapes air_purifier.py already models on the sibling TP1X_DA-AC-AIR
|
||||
# board (DISPLAY/SOUND_OUTPUT/SOUND_VOLUME, reused directly in the
|
||||
# registry); SOUND_MODE and the two below are genuinely new.
|
||||
|
||||
SOUND_MODE = Capability(
|
||||
href="/settings/sound/mode/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# Values seen (mute/tone/voice) are exactly laundry.SOUND_MODE's
|
||||
# vocabulary, so this shares that catalog entry -- but reads the
|
||||
# live supportedModes field rather than laundry's static tuple,
|
||||
# since this resource carries one (issue #319).
|
||||
#
|
||||
# exists_fn is required, not optional here: this board's rep never
|
||||
# reports a live 'mode' value ({"supportedModes": [...]} only), and
|
||||
# entity.py's default field-presence gate would otherwise keep the
|
||||
# select from ever registering -- adapter.flatten() (what the
|
||||
# golden/tests read) has no such gate, so it would look bound while
|
||||
# silently absent from HA. Register on supportedModes' presence
|
||||
# instead; current_option reads unknown until the device reports
|
||||
# 'mode' live.
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
exists_fn=lambda rep, resources: bool(rep.get("supportedModes")),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "mode", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Absence-detection auto air clean (issue #319) -- a plain On/Off toggle,
|
||||
# sibling feature to ABSENCE_POWER_SAVING above but on its own href.
|
||||
ABSENCE_CLEAN = Capability(
|
||||
href="/csi/absenceclean/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="absence_clean",
|
||||
field="mode",
|
||||
icon="mdi:broom",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["csi", "absenceclean", "vs", "0"],
|
||||
{"mode": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# The CAC-class board (issue #191) reports the identical {mode,
|
||||
# supportedModes: [On, Off]} shape under /mds/absenceclean/vs/0 instead --
|
||||
# confirmed against that board's own fixture, not guessed. Shares
|
||||
# ABSENCE_CLEAN's key/translation: no dump has ever reported both hrefs
|
||||
# together, so there's nothing for the two to collide over in
|
||||
# adapter.flatten().
|
||||
MDS_ABSENCE_CLEAN = Capability(
|
||||
href="/mds/absenceclean/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="absence_clean",
|
||||
field="mode",
|
||||
icon="mdi:broom",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["mds", "absenceclean", "vs", "0"],
|
||||
{"mode": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Energy-saving schedule (issue #319). `mode` is a device-chosen preset
|
||||
# (e.g. Cooling_60/Off_180) with no confirmed unit for the trailing number
|
||||
# (minutes seen elsewhere on this board are unprefixed ints, not
|
||||
# underscore-suffixed) -- exposed as a select over the live options rather
|
||||
# than translating labels we can't confirm. `state`/`operatingStatus` stay
|
||||
# bare diagnostic passthroughs for the same reason.
|
||||
ENERGY_SAVING = Capability(
|
||||
href="/csi/energysaving/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="energy_saving_mode",
|
||||
field="mode",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["csi", "energysaving", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="energy_saving_state",
|
||||
field="state",
|
||||
icon="mdi:leaf",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="energy_saving_operating_status",
|
||||
field="operatingStatus",
|
||||
icon="mdi:leaf",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# TP1X_DA-AC-CAC-01001-class additions (issue #288, six System A/C cassette
|
||||
# units on the same board test_airconditioner_cac.py's coverage-gap test
|
||||
# documents). `convenientMode`/`operatingOption` stay unexposed -- present
|
||||
# on every dump seen but no evidence of what either actually controls.
|
||||
EDGE_LIGHTING = Capability(
|
||||
href="/edgelighting/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="edge_lighting",
|
||||
field="status",
|
||||
icon="mdi:led-strip",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["edgelighting", "vs", "0"],
|
||||
{"status": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="edge_lighting_mode",
|
||||
field="mode",
|
||||
icon="mdi:led-strip-variant",
|
||||
entity_category="config",
|
||||
options_field="modeSupportedList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["edgelighting", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
# Color temperature in Kelvin (3000K/4000K/6500K), not a hue -- a
|
||||
# select over the live-reported codes rather than a light color_temp
|
||||
# entity, consistent with this project's other Kelvin-coded selects.
|
||||
SelectDesc(
|
||||
key="edge_lighting_color",
|
||||
field="colorOption",
|
||||
icon="mdi:palette",
|
||||
entity_category="config",
|
||||
options_field="colorSupportedList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["edgelighting", "vs", "0"],
|
||||
{"colorOption": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Second, distinct light resource on this board generation -- an
|
||||
# always-on-style indicator light with its own status/mode, not to be
|
||||
# confused with EDGE_LIGHTING (a different href/rep entirely) or
|
||||
# DISPLAY_LIGHT (/light/vs/0's ambient mood light).
|
||||
LIGHT_STATEFUL = Capability(
|
||||
href="/light/stateful/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="indicator_light",
|
||||
field="status",
|
||||
icon="mdi:led-on",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["light", "stateful", "vs", "0"],
|
||||
{"status": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="indicator_light_mode",
|
||||
field="mode",
|
||||
icon="mdi:led-variant-on",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["light", "stateful", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Wind-Free / Wind-Sleep mode toggles (PR #316, ACA-KR-TP2-21-AN9000). Each
|
||||
# on its own dedicated href, so unlike ventilation_mode above these need no
|
||||
# device gating -- absent on every other family's dump. Write contract
|
||||
# extrapolated from this file's other plain On/Off options-array fields
|
||||
# (AIR_PURIFY, AUTO_CLEAN); not confirmed live.
|
||||
#
|
||||
# NOT the same WindFree already modeled elsewhere: on regular AC boards,
|
||||
# WindFree is a `Comode_Nano` token inside /mode/vs/0's options[], surfaced
|
||||
# as a climate preset (climate.py's _LEGACY_PRESET_CODES/preset_mode) with
|
||||
# real coupling to hvac_mode (disabled in Heat/AIComfort/Auto, timing rules
|
||||
# on legacy boards). This device's windfree/windsleep are bare booleans on
|
||||
# their own hrefs with no such coupling evidenced -- same feature name,
|
||||
# different wire mechanism, so plain switches rather than folding into
|
||||
# climate.py's preset machinery.
|
||||
WINDFREE = Capability(
|
||||
href="/modeoption/windfree/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="windfree",
|
||||
field="x.com.samsung.da.windfree",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["modeoption", "windfree", "vs", "0"],
|
||||
{"x.com.samsung.da.windfree": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
WINDSLEEP = Capability(
|
||||
href="/modeoption/windsleep/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="windsleep",
|
||||
field="x.com.samsung.da.windsleep",
|
||||
icon="mdi:sleep",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["modeoption", "windsleep", "vs", "0"],
|
||||
{"x.com.samsung.da.windsleep": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
|
||||
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
|
||||
# so it's a measurement; the others stay string diagnostics (see
|
||||
@@ -1070,6 +1483,27 @@ AIR_QUALITY = Capability(
|
||||
("super_fine_dust", "mdi:weather-fog", "SuperFineDust"),
|
||||
)
|
||||
),
|
||||
# CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC
|
||||
# families don't report. Same field/shape air_monitor.SENSORS
|
||||
# already models with device_class='carbon_dioxide'/unit='ppm', so
|
||||
# this matches that descriptor rather than guessing fresh -- unlike
|
||||
# the pm10/pm25/pm1 mapping air_monitor.py's own docstring
|
||||
# deliberately rejects for the three dust-type keys above (Samsung's
|
||||
# two-tier PM10/PM2.5 convention doesn't confirm where a third tier
|
||||
# or PM1 fits), ppm for a field literally named CO2 isn't a guess of
|
||||
# that kind.
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
field="x.com.samsung.da.items",
|
||||
icon="mdi:molecule-co2",
|
||||
entity_category="diagnostic",
|
||||
device_class="carbon_dioxide",
|
||||
state_class="measurement",
|
||||
unit="ppm",
|
||||
exists_fn=_has_sensor_type("CO2"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1089,7 +1523,12 @@ _AC_IGNORED = [
|
||||
# state or documented write contract. /option/muteonce/vs/0 and
|
||||
# /selfcheck/vs/0 are deliberately NOT here -- see MUTE_ONCE above and
|
||||
# common.SELF_CHECK, both of which have a confirmed, modelable contract.
|
||||
"/airlevelcheck/vs/0", # periodic air-quality sensing scheduler plumbing
|
||||
# /airlevelcheck/vs/0 is deliberately NOT here either (PR #316):
|
||||
# despite this list's old description of it as "scheduler plumbing",
|
||||
# both the CAC and TP1X_DA_AC_RAC_01011 fixtures already carry real,
|
||||
# populated periodicSensingActivationState/autoExeState values here --
|
||||
# the AI-Purify feature air_purifier.AIR_LEVEL_CHECK already models,
|
||||
# reused below rather than reinvented.
|
||||
"/aisleep/vs/0", # AI-sleep feedback state (no actionable control)
|
||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap
|
||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||
@@ -1117,6 +1556,24 @@ _AC_IGNORED = [
|
||||
# registry.subdevices.enumerate_subdevices, hence the entry here rather
|
||||
# than a coverage gap.
|
||||
"/multidevice/vs/0",
|
||||
# TP1X_DA-AC-FAC-class-only (issue #319) -- scoped here rather than
|
||||
# promoted to the global ignore list since it'd collide with families
|
||||
# that do bind some of these. Only /dnd/autosleep/vs/0 has a precedent
|
||||
# (air_purifier.COVERAGE ignores the same href for the same reason);
|
||||
# the rest are new, each with its own reason below.
|
||||
"/dnd/autosleep/vs/0", # every field its inert default; needs a schedule editor
|
||||
"/outdoorsharing/vs/0", # empty on this dump -- outdoor-unit sharing plumbing
|
||||
"/lifestyle/survey/vs/0", # {list: [""]} placeholder, nothing to expose
|
||||
# supportedVoices carries opaque numeric voice-pack IDs ("100"/"101")
|
||||
# with no live current-selection field and, unlike SOUND_MODE's
|
||||
# self-descriptive mute/tone/voice codes, no confirmed human-readable
|
||||
# meaning to expose them under -- don't guess.
|
||||
"/settings/sound/voice/vs/0",
|
||||
# rssi/wifiFrequency (network housekeeping); lastEnergySavingTime and
|
||||
# cleaningStartTime are inert '1900-01-00' placeholders on this dump;
|
||||
# absenceInfo is an unconfirmed 48-slot P/A history blob with no
|
||||
# documented meaning -- don't guess what it encodes.
|
||||
"/csi/information/vs/0",
|
||||
]
|
||||
|
||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
||||
|
||||
@@ -25,7 +25,7 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
TimeDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .common import int_or_none, normalize_temp_unit
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in translations/en.json, keyed by the
|
||||
@@ -266,6 +266,39 @@ DOOR_ALERT = Capability(
|
||||
)
|
||||
|
||||
|
||||
# Internal deodorizing filter (issue #318, TP1X_REF_21K). Same
|
||||
# filterUsage/filterStatus field pair as common.WATER_FILTER, but
|
||||
# filterUsage here is already a 0-100 percentage with no filterCapacity to
|
||||
# divide by (confirmed by filterStatus=="wash" at filterUsage=="100") --
|
||||
# 'air_'-prefixed keys so a fridge with both a water and an air filter gets
|
||||
# two distinct entities rather than a unique_id collision.
|
||||
AIR_FILTER = Capability(
|
||||
href="/filter/airdustfilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="air_filter_usage",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="air_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _status_lock_write(field):
|
||||
return lambda p, rep, href=None: (
|
||||
["status", "lock", "vs", "0"],
|
||||
|
||||
@@ -401,10 +401,17 @@ OVEN_MODE = Capability(
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write,
|
||||
),
|
||||
# No exists_fn on the NV7000BS-class board this was proven against
|
||||
# (UpperLamp_ is always in its options[]) -- but issue #300's
|
||||
# steam-oven-class WALLOVEN board's options[] has no UpperLamp_
|
||||
# token at all, so this was a phantom, always-off, write-does-
|
||||
# nothing switch there. Same fastpreheat/NaturalSteam-class gap
|
||||
# issue #183 already fixed on the other switches below.
|
||||
SwitchDesc(
|
||||
key="lamp",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:track-light",
|
||||
exists_fn=_has_option("UpperLamp"),
|
||||
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
|
||||
write_fn=_option_switch_write("UpperLamp"),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Home Assistant services for direct OCF resource read/write access
|
||||
(issue #300): a raw-transport escape hatch for reverse-engineering a
|
||||
device's write contract -- an ordered multi-write sequence with settle
|
||||
delays and a delayed re-read, which the single-write options-flow debug
|
||||
panel can't express. Both sit on the same coordinator primitives the panel
|
||||
now calls too (config_flow.py), so there is exactly one code path that
|
||||
performs a raw write.
|
||||
|
||||
Kept thin on purpose: session/lock ownership lives on the coordinator
|
||||
(coordinator.py). This module only resolves the service call's device
|
||||
target to a `(coordinator, subdevice)` pair, translates canonical hrefs
|
||||
through that subdevice, and shapes the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
|
||||
from .coordinator import LocalThingsCoordinator, normalize_href
|
||||
from .registry.subdevices import MAIN, Subdevice
|
||||
|
||||
ATTR_HREF = "href"
|
||||
ATTR_PAYLOAD = "payload"
|
||||
ATTR_SETTLE = "settle"
|
||||
ATTR_WRITES = "writes"
|
||||
ATTR_VERIFY_AFTER = "verify_after"
|
||||
ATTR_HOLD_SESSION_LOCK = "hold_session_lock"
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
|
||||
_WRITE_ITEM_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_HREF): str,
|
||||
# Not `dict` here: a non-dict payload must fail the same way an
|
||||
# empty one does -- coordinator.async_raw_write_sequence's
|
||||
# ServiceValidationError -- not a raw schema vol.Invalid, so every
|
||||
# caller sees one consistent error shape regardless of which rule
|
||||
# a bad payload tripped.
|
||||
vol.Required(ATTR_PAYLOAD): object,
|
||||
vol.Optional(ATTR_SETTLE): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
# Structural validation only (types, and unwrapping a bare dict into a
|
||||
# one-item list) -- the semantic checks (non-empty payload, non-root href,
|
||||
# the 1..10/settle/verify_after ranges) live on
|
||||
# LocalThingsCoordinator.async_raw_write_sequence, so every caller gets the
|
||||
# same ServiceValidationError + translation key regardless of whether it
|
||||
# reached the primitive through this service, the options-flow panel, or a
|
||||
# future caller.
|
||||
_WRITE_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Required(ATTR_WRITES): vol.All(cv.ensure_list, [_WRITE_ITEM_SCHEMA]),
|
||||
vol.Optional(ATTR_VERIFY_AFTER): vol.Coerce(float),
|
||||
vol.Optional(ATTR_HOLD_SESSION_LOCK): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
_READ_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Optional(ATTR_HREF): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_target(
|
||||
hass: HomeAssistant, call: ServiceCall
|
||||
) -> tuple[LocalThingsCoordinator, Subdevice, str]:
|
||||
"""The one `(coordinator, subdevice, device_id)` a service call's
|
||||
device target names.
|
||||
|
||||
Deliberately strict about count, not just presence: the `target:
|
||||
device:` selector in services.yaml still lets a user pick an area or
|
||||
label in the picker, and the frontend expands that into a `device_id`
|
||||
list before the call reaches here -- more than one entry means an
|
||||
area/label fanned this out across several appliances, which a raw
|
||||
debug write must never do silently (issue #300).
|
||||
"""
|
||||
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID) or [])
|
||||
if len(device_ids) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_target_invalid",
|
||||
)
|
||||
device_id = device_ids[0]
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get(device_id)
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_found",
|
||||
)
|
||||
|
||||
for coordinator in hass.data.get(DOMAIN, {}).values():
|
||||
if device.identifiers & coordinator.device_info.get("identifiers", set()):
|
||||
return coordinator, MAIN, device_id
|
||||
for sub in coordinator.subdevices:
|
||||
if device.identifiers & coordinator.device_info_for(sub).get("identifiers", set()):
|
||||
return coordinator, sub, device_id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_loaded",
|
||||
)
|
||||
|
||||
|
||||
async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, device_id = _resolve_target(hass, call)
|
||||
writes_in: list[dict[str, Any]] = call.data[ATTR_WRITES]
|
||||
|
||||
# Canonical -> actual translation happens here, not in the coordinator
|
||||
# (issue #177), whose raw-write primitive has no notion of subdevices --
|
||||
# identity transform for MAIN. Normalized first, or a trailing slash
|
||||
# slips past to_actual onto the master (see coordinator.normalize_href).
|
||||
canonicals = [normalize_href(w[ATTR_HREF]) for w in writes_in]
|
||||
raw_writes = [
|
||||
{
|
||||
"href": subdevice.to_actual(canonical),
|
||||
"payload": w.get(ATTR_PAYLOAD),
|
||||
"settle": w.get(ATTR_SETTLE, 0.0),
|
||||
}
|
||||
for canonical, w in zip(canonicals, writes_in, strict=True)
|
||||
]
|
||||
sequence = await coordinator.async_raw_write_sequence(
|
||||
raw_writes,
|
||||
verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0),
|
||||
hold_session_lock=call.data.get(ATTR_HOLD_SESSION_LOCK, True),
|
||||
)
|
||||
|
||||
results = [
|
||||
{
|
||||
"href": canonical,
|
||||
"actual_href": result["href"],
|
||||
"code": result["code"],
|
||||
"raw_code": result["raw_code"],
|
||||
"accepted": result["accepted"],
|
||||
"before": result["before"],
|
||||
"after": result["after"],
|
||||
"changed": result["changed"],
|
||||
}
|
||||
for canonical, result in zip(canonicals, sequence["results"], strict=True)
|
||||
]
|
||||
response: dict[str, Any] = {"device_id": device_id, "results": results}
|
||||
if "verified" in sequence:
|
||||
# Keyed off the same normalized canonicals the sequence was built
|
||||
# from, so the lookup can't miss and return an actual href where the
|
||||
# contract promises a canonical one.
|
||||
canonical_by_actual = {subdevice.to_actual(c): c for c in canonicals}
|
||||
response["verified"] = {
|
||||
canonical_by_actual.get(actual_href, actual_href): verified
|
||||
for actual_href, verified in sequence["verified"].items()
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, _device_id = _resolve_target(hass, call)
|
||||
href = call.data.get(ATTR_HREF)
|
||||
if not href:
|
||||
# No href -> the cached snapshot, not a live sweep of every known
|
||||
# href: lets a user enumerate what exists without hammering the
|
||||
# device (see this module's docstring and the coordinator's
|
||||
# canonical_resources).
|
||||
snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)}
|
||||
return cast(ServiceResponse, snapshot)
|
||||
|
||||
# Same normalize-before-translate order as the write path above.
|
||||
canonical = normalize_href(href)
|
||||
actual_href = subdevice.to_actual(canonical)
|
||||
code, rep = await coordinator.async_raw_read(actual_href)
|
||||
read_result: dict[str, Any] = {
|
||||
"href": canonical,
|
||||
"actual_href": actual_href,
|
||||
"code": f"{code >> 5}.{code & 0x1F:02d}",
|
||||
"raw_code": code,
|
||||
"rep": rep,
|
||||
}
|
||||
return cast(ServiceResponse, read_result)
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the write_resource/read_resource services (issue #300).
|
||||
|
||||
Called once from `async_setup`, not per config entry: services are
|
||||
process-global, and `hass.services.async_register` on an
|
||||
already-registered name just replaces the handler, so re-registering
|
||||
on every entry setup would silently rebind to whichever entry loaded
|
||||
last. `async_unload_entry` must never call the inverse of this.
|
||||
"""
|
||||
|
||||
async def _handle_write(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_write_resource(hass, call)
|
||||
|
||||
async def _handle_read(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_read_resource(hass, call)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
_handle_write,
|
||||
schema=_WRITE_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
_handle_read,
|
||||
schema=_READ_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
write_resource:
|
||||
name: Write resource
|
||||
description: >-
|
||||
Send one or more raw partial-rep writes straight to a device's OCF
|
||||
resources, in order, with an optional settle delay between steps and a
|
||||
delayed re-read at the end -- for reverse-engineering a device's write
|
||||
contract (issue #300), not day-to-day control. This bypasses the
|
||||
remote-control-off block and every write_fn/validate_fn a normal entity
|
||||
write goes through, and sends exactly the fields you give it verbatim:
|
||||
it can misconfigure your appliance. Prefer a real entity, or the Debug
|
||||
write panel in the integration's Configure menu, for anything this
|
||||
integration already models.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The appliance to write to, or one of its subdevices.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: localthings
|
||||
writes:
|
||||
name: Writes
|
||||
description: >-
|
||||
1-10 writes to perform in order. Each item needs href (the
|
||||
canonical resource, e.g. /mode/vs/0) and payload (a non-empty
|
||||
object sent verbatim as a partial-rep POST); settle is how many
|
||||
seconds to wait after that write before starting the next one
|
||||
(0-30, default 0).
|
||||
required: true
|
||||
example: >-
|
||||
[{"href": "/mode/vs/0", "payload": {"x.com.samsung.da.modes":
|
||||
["Bake"]}, "settle": 3}]
|
||||
selector:
|
||||
object:
|
||||
hold_session_lock:
|
||||
name: Hold the session for the whole sequence
|
||||
description: >-
|
||||
Keep the device session for the entire sequence, settle delays
|
||||
included, so nothing else -- a routine poll, another entity's write
|
||||
-- can land between two steps and blur which write the appliance
|
||||
was reacting to. On by default. Turning it off takes the session
|
||||
per write and frees it across the waits, which lets entities keep
|
||||
updating during a long sequence at the cost of that certainty.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
verify_after:
|
||||
name: Verify after
|
||||
description: >-
|
||||
Seconds to wait after the whole sequence finishes before
|
||||
re-reading every href touched, to see whether the values held or
|
||||
were reverted by the board. 0 (default) skips verification.
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 60
|
||||
step: 0.5
|
||||
unit_of_measurement: seconds
|
||||
mode: box
|
||||
|
||||
read_resource:
|
||||
name: Read resource
|
||||
description: >-
|
||||
Read a device's OCF resources directly, bypassing this integration's
|
||||
entity model. Give an href for a live GET straight from the device --
|
||||
deliberately not the cache, which can be up to a poll interval stale --
|
||||
or omit it to get the cached snapshot of every resource this
|
||||
integration currently tracks on that device.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The appliance to read from, or one of its subdevices.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: localthings
|
||||
href:
|
||||
name: Resource href
|
||||
description: >-
|
||||
Canonical resource href to read (e.g. /mode/vs/0). Omit to get the
|
||||
cached snapshot of every tracked resource instead of a live GET.
|
||||
required: false
|
||||
example: /mode/vs/0
|
||||
selector:
|
||||
text:
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "Chytrý",
|
||||
"speed": "Rychlý",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Spánek",
|
||||
"nanosleep": "WindFree spánek",
|
||||
"longwind": "Dlouhý vánek",
|
||||
"motionindirect": "Nepřímý vzduch při pohybu",
|
||||
"motiondirect": "Přímý vzduch při pohybu",
|
||||
"drycomfort": "Komfortní sušení",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2stupňový"
|
||||
}
|
||||
}
|
||||
@@ -536,6 +538,9 @@
|
||||
"mute": "Ztlumeno"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Režim úspory energie"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Zvukový režim",
|
||||
"state": {
|
||||
@@ -663,6 +668,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Teplota mrazicí zóny"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Režim okrajového osvětlení",
|
||||
"state": {
|
||||
"smart": "Chytrý",
|
||||
"high": "Vysoký",
|
||||
"low": "Nízký"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Barva okrajového osvětlení",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Režim kontrolky",
|
||||
"state": {
|
||||
"smart": "Chytrý",
|
||||
"high": "Vysoký",
|
||||
"low": "Nízký"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Režim",
|
||||
"state": {
|
||||
"purification": "Čištění",
|
||||
"ventilation": "Větrání",
|
||||
"smartventilation": "Chytré větrání"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -785,6 +822,12 @@
|
||||
"indirect": "Nepřímý"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Stav úspory energie"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Provozní stav úspory energie"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Teplota"
|
||||
},
|
||||
@@ -1038,6 +1081,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Úspora energie při nepřítomnosti aktivní"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Čištění při nepřítomnosti"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Vyhýbání se proudu vzduchu při pohybu aktivní"
|
||||
},
|
||||
@@ -1202,6 +1248,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Alarm větrání"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Okrajové osvětlení"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Kontrolka"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Režim Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Noční režim"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1356,6 +1414,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intenzivní není u vybraného cyklu k dispozici."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Příkaz pro {href} selhal i po opětovném připojení: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Zadejte 1 až 10 zápisů."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "Hodnota settle musí být mezi 0 a 30 sekundami."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "Hodnota verify_after musí být mezi 0 a 60 sekundami."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Tato služba vyžaduje přesně jedno cílové zařízení."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Pro tento cíl nebylo nalezeno žádné odpovídající zařízení."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "Smart",
|
||||
"speed": "Speed",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sleep",
|
||||
"nanosleep": "WindFree sleep",
|
||||
"longwind": "Long wind",
|
||||
"motionindirect": "Motion indirect",
|
||||
"motiondirect": "Motion direct",
|
||||
"drycomfort": "Dry comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
@@ -536,6 +538,9 @@
|
||||
"mute": "Mute"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Energy saving mode"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Sound mode",
|
||||
"state": {
|
||||
@@ -663,6 +668,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Freezer temperature"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Edge lighting mode",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "High",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Edge lighting color",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Indicator light mode",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "High",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"purification": "Purification",
|
||||
"ventilation": "Ventilation",
|
||||
"smartventilation": "Smart Ventilation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -785,6 +822,12 @@
|
||||
"indirect": "Indirect"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Energy saving state"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Energy saving operating status"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
@@ -1038,6 +1081,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Absence power saving active"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Absence clean"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Motion-detect wind avoidance active"
|
||||
},
|
||||
@@ -1202,6 +1248,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilation alarm"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Edge lighting"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Indicator light"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Wind-Free mode"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Sleep mode"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1356,6 +1414,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensive isn't available on the selected cycle."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "The command to {href} failed even after reconnecting: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Provide between 1 and 10 writes."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle must be between 0 and 30 seconds."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after must be between 0 and 60 seconds."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "This service requires exactly one target device."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No matching device was found for that target."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "This device isn't connected yet. Try again once it has loaded."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "El modo intensivo no está disponible en el ciclo seleccionado."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "El comando para {href} falló incluso después de reconectar: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Proporciona entre 1 y 10 escrituras."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle debe estar entre 0 y 30 segundos."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after debe estar entre 0 y 60 segundos."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Este servicio requiere exactamente un dispositivo de destino."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No se encontró ningún dispositivo coincidente para ese destino."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Este dispositivo aún no está conectado. Vuelve a intentarlo cuando se haya cargado."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -274,11 +295,13 @@
|
||||
"smart": "Inteligente",
|
||||
"speed": "Rápido",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sueño",
|
||||
"nanosleep": "WindFree sueño",
|
||||
"longwind": "Viento prolongado",
|
||||
"motionindirect": "Indirecto al movimiento",
|
||||
"motiondirect": "Directo al movimiento",
|
||||
"drycomfort": "Confort seco",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 pasos"
|
||||
}
|
||||
}
|
||||
@@ -658,6 +681,9 @@
|
||||
"mute": "Silencio"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Modo de ahorro de energía"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Modo de sonido",
|
||||
"state": {
|
||||
@@ -785,6 +811,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatura del congelador"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Modo de iluminación perimetral",
|
||||
"state": {
|
||||
"smart": "Inteligente",
|
||||
"high": "Alto",
|
||||
"low": "Bajo"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Color de iluminación perimetral",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Modo de luz indicadora",
|
||||
"state": {
|
||||
"smart": "Inteligente",
|
||||
"high": "Alto",
|
||||
"low": "Bajo"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modo",
|
||||
"state": {
|
||||
"purification": "Purificación",
|
||||
"ventilation": "Ventilación",
|
||||
"smartventilation": "Ventilación inteligente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -904,6 +962,12 @@
|
||||
"indirect": "Indirecto"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Estado de ahorro de energía"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Estado de funcionamiento del ahorro de energía"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatura"
|
||||
},
|
||||
@@ -1160,6 +1224,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Ahorro por ausencia activo"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Limpieza por ausencia"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Evitación de viento por detección de movimiento activa"
|
||||
},
|
||||
@@ -1324,6 +1391,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Alarma de ventilación"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Iluminación perimetral"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Luz indicadora"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Modo Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Modo nocturno"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "Smart",
|
||||
"speed": "Veloce",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sonno",
|
||||
"nanosleep": "WindFree sonno",
|
||||
"longwind": "Vento prolungato",
|
||||
"motionindirect": "Indiretto al movimento",
|
||||
"motiondirect": "Diretto al movimento",
|
||||
"drycomfort": "Comfort asciugatura",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 fasi"
|
||||
}
|
||||
}
|
||||
@@ -536,6 +538,9 @@
|
||||
"mute": "Muto"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Modalità risparmio energetico"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Modalità audio",
|
||||
"state": {
|
||||
@@ -663,6 +668,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatura freezer"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Modalità illuminazione perimetrale",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "Alta",
|
||||
"low": "Bassa"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Colore illuminazione perimetrale",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Modalità spia luminosa",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "Alta",
|
||||
"low": "Bassa"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modalità",
|
||||
"state": {
|
||||
"purification": "Purificazione",
|
||||
"ventilation": "Ventilazione",
|
||||
"smartventilation": "Ventilazione intelligente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -785,6 +822,12 @@
|
||||
"indirect": "Indiretto"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Stato risparmio energetico"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Stato di funzionamento del risparmio energetico"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatura"
|
||||
},
|
||||
@@ -1038,6 +1081,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Risparmio energetico in assenza attivo"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Pulizia in assenza"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Elusione flusso d'aria a rilevamento movimento attiva"
|
||||
},
|
||||
@@ -1202,6 +1248,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Allarme ventilazione"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Illuminazione perimetrale"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Spia luminosa"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Modalità Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Modalità notte"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1356,6 +1414,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensivo non è disponibile per il ciclo selezionato."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Il comando per {href} è fallito anche dopo la riconnessione: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Specificare da 1 a 10 scritture."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle deve essere compreso tra 0 e 30 secondi."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after deve essere compreso tra 0 e 60 secondi."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Questo servizio richiede esattamente un dispositivo di destinazione."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Nessun dispositivo corrispondente trovato per quella destinazione."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Questo dispositivo non è ancora connesso. Riprova una volta caricato."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "스마트",
|
||||
"speed": "스피드",
|
||||
"nano": "무풍",
|
||||
"sleep": "숙면",
|
||||
"nanosleep": "무풍 수면",
|
||||
"longwind": "롱바람",
|
||||
"motionindirect": "간접풍",
|
||||
"motiondirect": "직접풍",
|
||||
"drycomfort": "쾌적 제습",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2단계"
|
||||
}
|
||||
}
|
||||
@@ -536,6 +538,9 @@
|
||||
"mute": "음소거"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "절전 모드"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "소리 모드",
|
||||
"state": {
|
||||
@@ -663,6 +668,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "냉동실 온도"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "엣지 라이팅 모드",
|
||||
"state": {
|
||||
"smart": "스마트",
|
||||
"high": "높음",
|
||||
"low": "낮음"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "엣지 라이팅 색상",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "표시등 모드",
|
||||
"state": {
|
||||
"smart": "스마트",
|
||||
"high": "높음",
|
||||
"low": "낮음"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "모드",
|
||||
"state": {
|
||||
"purification": "청정",
|
||||
"ventilation": "환기",
|
||||
"smartventilation": "스마트환기"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -785,6 +822,12 @@
|
||||
"indirect": "간접풍"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "절전 상태"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "절전 작동 상태"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "온도"
|
||||
},
|
||||
@@ -1038,6 +1081,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "부재 절전 작동"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "부재 청소"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "모션 감지 바람 회피 작동"
|
||||
},
|
||||
@@ -1202,6 +1248,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "환기 알림"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "엣지 라이팅"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "표시등"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "무풍 모드"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "취침 모드"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1356,6 +1414,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "선택한 코스에서는 강력 세탁을 사용할 수 없습니다."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "재연결 후에도 {href} 명령이 실패했습니다: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "1~10개의 쓰기를 지정하세요."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle 값은 0에서 30초 사이여야 합니다."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after 값은 0에서 60초 사이여야 합니다."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "이 서비스는 정확히 하나의 대상 기기가 필요합니다."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "해당 대상에 일치하는 기기를 찾을 수 없습니다."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "이 기기는 아직 연결되지 않았습니다. 로드된 후 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "Slim",
|
||||
"speed": "Snel",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Slaap",
|
||||
"nanosleep": "WindFree-slaap",
|
||||
"longwind": "Lange wind",
|
||||
"motionindirect": "Beweging indirect",
|
||||
"motiondirect": "Beweging direct",
|
||||
"drycomfort": "Droog comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
@@ -536,6 +538,9 @@
|
||||
"mute": "Stil"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Energiebesparingsmodus"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
@@ -663,6 +668,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatuur vriesgedeelte"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Randverlichtingsmodus",
|
||||
"state": {
|
||||
"smart": "Slim",
|
||||
"high": "Hoog",
|
||||
"low": "Laag"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Randverlichtingskleur",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Indicatorlampjemodus",
|
||||
"state": {
|
||||
"smart": "Slim",
|
||||
"high": "Hoog",
|
||||
"low": "Laag"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modus",
|
||||
"state": {
|
||||
"purification": "Zuivering",
|
||||
"ventilation": "Ventilatie",
|
||||
"smartventilation": "Slimme ventilatie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -785,6 +822,12 @@
|
||||
"indirect": "Indirect"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Energiebesparing status"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Bedrijfsstatus energiebesparing"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatuur"
|
||||
},
|
||||
@@ -1038,6 +1081,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Energiebesparing bij afwezigheid actief"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Reiniging bij afwezigheid"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Bewegingsdetectie luchtstroomvermijding actief"
|
||||
},
|
||||
@@ -1202,6 +1248,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilatiealarm"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Randverlichting"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Indicatorlampje"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Wind-Free modus"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Slaapmodus"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1356,6 +1414,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Geef tussen de 1 en 10 schrijfacties op."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle moet tussen 0 en 30 seconden liggen."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after moet tussen 0 en 60 seconden liggen."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Deze service vereist precies één doelapparaat."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Er is geen bijpassend apparaat gevonden voor dat doel."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1056
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "CT_E",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-06T14:24:02"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bluetooth/hood/status/vs/0",
|
||||
"rep": {
|
||||
"connectionState": "disconnected",
|
||||
"micomModelId": "",
|
||||
"firmwareVersion": "",
|
||||
"power": "off",
|
||||
"fanSpeed": 0,
|
||||
"lampState": "off",
|
||||
"timer": {},
|
||||
"rt": [
|
||||
"bluetoothHoodStatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.connected"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NV8000T-/KO0",
|
||||
"Pause_Off",
|
||||
"SyncFlex_Off",
|
||||
"FlexCoil_0",
|
||||
"MainTimerCurrent_0",
|
||||
"MainTimerSet_0",
|
||||
"MainTimerState_Ready",
|
||||
"IndependentTimerCheck_Enable",
|
||||
"OperationState0_Ready",
|
||||
"HotSurface0_Normal",
|
||||
"PowerLevel0_0",
|
||||
"OperationState1_Ready",
|
||||
"HotSurface1_Normal",
|
||||
"PowerLevel1_0",
|
||||
"OperationState2_Ready",
|
||||
"HotSurface2_Normal",
|
||||
"PowerLevel2_0",
|
||||
"OperationState3_Ready",
|
||||
"HotSurface3_Normal",
|
||||
"PowerLevel3_0",
|
||||
"OperationState4_Ready",
|
||||
"HotSurface4_Normal",
|
||||
"PowerLevel4_0",
|
||||
"OperationState5_Ready",
|
||||
"HotSurface5_Normal",
|
||||
"PowerLevel5_0"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"absence_clean",
|
||||
"ai_energy_level",
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_saving_mode",
|
||||
"energy_saving_operating_status",
|
||||
"energy_saving_state",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"tropical_night_mode",
|
||||
"uv_led",
|
||||
"ventilation_alarm"
|
||||
]
|
||||
}
|
||||
+18
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"absence_clean",
|
||||
"absence_power_saving_active",
|
||||
"absence_power_saving_mode",
|
||||
"air_filter_pm1_status",
|
||||
@@ -9,6 +10,7 @@
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
@@ -17,20 +19,36 @@
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"dust",
|
||||
"edge_lighting",
|
||||
"edge_lighting_color",
|
||||
"edge_lighting_mode",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"indicator_light",
|
||||
"indicator_light_mode",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"motion_detect_wind_active",
|
||||
"motion_detect_wind_mode",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"sound_volume",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode",
|
||||
"uv_led"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
@@ -19,10 +20,18 @@
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"mute_once",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"any_burner_active",
|
||||
"burner_0_state",
|
||||
"burner_1_state",
|
||||
"burner_2_state",
|
||||
"burner_3_state",
|
||||
"burner_4_state",
|
||||
"burner_5_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"firmware_update",
|
||||
"main_timer_current",
|
||||
"main_timer_state",
|
||||
"paired_hood_connected",
|
||||
"paired_hood_fan_speed",
|
||||
"paired_hood_firmware",
|
||||
"paired_hood_light",
|
||||
"paired_hood_model",
|
||||
"paired_hood_power",
|
||||
"power_state"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"diagnosis_status",
|
||||
"door_open",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"brightness_level",
|
||||
"cabinet_light_dim",
|
||||
"cabinet_light_switch",
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"day_brightness",
|
||||
"door_alert",
|
||||
"door_cooler_open",
|
||||
"door_freezer_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"fridge_sound",
|
||||
"ice_night_mode",
|
||||
"icemaker_one_enabled",
|
||||
"icemaker_one_making_status",
|
||||
"night_end",
|
||||
"night_start",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "OV_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-05T14:20:14"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.connected"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.lock": "Unlock"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.doors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_DA-KS-WALLOVEN-000002|40441841|5002011E021011150100000000000000",
|
||||
"x.com.samsung.da.description": "NW9000KD/AA1",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "240205",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "DE92-04418A_20062500, DE92-04011A_17041300",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "610",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"ConvectionBake",
|
||||
"ConvectionRoast",
|
||||
"Bake",
|
||||
"Broil",
|
||||
"SteamBake",
|
||||
"SteamRoast",
|
||||
"Easycook3",
|
||||
"Descale",
|
||||
"PyroFree",
|
||||
"Drain",
|
||||
"SelfClean",
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NW9000KD/AA1",
|
||||
"keepWarmReservation_Off",
|
||||
"meatprobe_disconnected",
|
||||
"NoPreheat_Off",
|
||||
"waterInlet_Closed",
|
||||
"steamAddLevel_0",
|
||||
"descaleAlarm_Normal",
|
||||
"descaleNewWaterAlarm_Off",
|
||||
"descaleEmptyWaterAlarm_Off",
|
||||
"steamGeneratorLevel_Empty",
|
||||
"steamUsingTime_66",
|
||||
"drainRequired_00",
|
||||
"steamState_Standby",
|
||||
"descaleState_Standby",
|
||||
"waterTankInSwitch_On",
|
||||
"pyroFreeState_Standby",
|
||||
"waterTankOutSwitch_Off",
|
||||
"Sound_On",
|
||||
"AdjustingTemp_0",
|
||||
"Sabbath_Off",
|
||||
"EnergySaving_On"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"rt": [
|
||||
"x.com.samsung.da.oven"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false",
|
||||
"rt": [
|
||||
"x.com.samsung.da.configuration"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.increment": "0",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.increment": "0",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.temperatures"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"href": "/alarms/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On",
|
||||
"href": "/bespoke/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rep": {
|
||||
"light.control.status": "On",
|
||||
"level.brightness.daytime": "100",
|
||||
"level.brightness.nighttime": "33",
|
||||
"night.starttime": "2026-08-07T12:00:00",
|
||||
"night.duration.minute": "540",
|
||||
"timezone.offset": "+09:00",
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.light.enhanced"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.lightLevel": "100",
|
||||
"x.com.samsung.da.lightResolution": "3",
|
||||
"x.com.samsung.da.lightControl.off.include": "Off",
|
||||
"x.com.samsung.da.lightControl": "Off",
|
||||
"x.com.samsung.da.lightControl.hide": "true",
|
||||
"light.dimming.status": "On",
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.cabinetlight"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "",
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"href": "/configuration/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/prediction/vs/0",
|
||||
"rep": {
|
||||
"ai.cooling.care": "Off",
|
||||
"href": "/defrost/prediction/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Normal",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/cooler/0",
|
||||
"rep": {
|
||||
"openState": "Close",
|
||||
"href": "/door/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/freezer/0",
|
||||
"rep": {
|
||||
"openState": "Close",
|
||||
"href": "/door/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
}
|
||||
],
|
||||
"href": "/doors/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1441",
|
||||
"x.com.samsung.da.start": "2026-08-07T00:00:41Z",
|
||||
"x.com.samsung.da.realSaving": "On",
|
||||
"href": "/drlc/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/ailevel/vs/0",
|
||||
"rep": {
|
||||
"aiLevel": "1",
|
||||
"supportedAiLevel": [
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"href": "/energy/ailevel/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "25344",
|
||||
"x.com.samsung.da.instantaneousPower": "48",
|
||||
"x.com.samsung.da.cumulativePower": "413385",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "50694",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1786020360"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1,
|
||||
"href": "/file/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"href": "/filter/airdustfilter/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/nighttime/vs/0",
|
||||
"rep": {
|
||||
"ice.night.status": "On",
|
||||
"ice.night.starttime": "2026-08-07T12:00:00",
|
||||
"ice.night.duration": "540",
|
||||
"ice.night.timezone": "+09:00",
|
||||
"href": "/icemaker/nighttime/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.ice.night"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
|
||||
"x.com.samsung.da.iceMaker.state": "On",
|
||||
"x.com.samsung.da.iceType.desired": "NORMAL",
|
||||
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
|
||||
"x.com.samsung.da.iceMaker.type": "toggle",
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.icemaker"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker": "On",
|
||||
"href": "/icemaker/status/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|70664141|0000033C011913114100000041FB5F00",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "RR7",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "260618",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2605071D, 24120906, 26040701, FFFFFFFF, 25082208, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"href": "/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
],
|
||||
"href": "/mode/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "26040701",
|
||||
"x.com.samsung.da.currentVersionInfo": "10000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnTarget": "inverter",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Refrigerator",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-24-T4-RE1",
|
||||
"versions": [
|
||||
"20260618"
|
||||
],
|
||||
"visVersion": "260618"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "823070664141FFFFFFFF",
|
||||
"versions": [
|
||||
"2605071D",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260507"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "823070664041FFFFFFFF",
|
||||
"versions": [
|
||||
"24120906",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "241209"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02307066414170664041",
|
||||
"versions": [
|
||||
"2605071D",
|
||||
"24120906"
|
||||
],
|
||||
"visVersion": "260507"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "023070680641FFFFFFFF",
|
||||
"versions": [
|
||||
"26040701",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260407"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "023070668841FFFFFFFF",
|
||||
"versions": [
|
||||
"25082208",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250822"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true",
|
||||
"href": "/realtimenotiforclient/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFridge": "Off",
|
||||
"x.com.samsung.da.rapidFreezing": "Off",
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.fridge"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/rm/control/vs/0",
|
||||
"rep": {
|
||||
"minPeriod": "9000",
|
||||
"href": "/rm/control/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runningmode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0,
|
||||
"href": "/runningmode/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
],
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"ErrorCode_None"
|
||||
],
|
||||
"href": "/selfcheck/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rep": {
|
||||
"alert.door": "1",
|
||||
"supportedAlert.door": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.alert.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.device.sound": "On",
|
||||
"x.com.samsung.da.preciseCooling": "On",
|
||||
"x.com.samsung.da.doorAlarmSound": "On",
|
||||
"cleaning.status": "On",
|
||||
"cleaning.type": "SPI_AND_UV",
|
||||
"href": "/status/lock/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.lockstatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rep": {
|
||||
"temperature": 2.0,
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"temperature": -19.0,
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rep": {
|
||||
"temperature": 2.0,
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"temperature": -19.0,
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"temperature.unit.control": "true",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "-19",
|
||||
"x.com.samsung.da.current": "-19",
|
||||
"x.com.samsung.da.maximum": "-15",
|
||||
"x.com.samsung.da.minimum": "-23",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Fridge",
|
||||
"x.com.samsung.da.desired": "2",
|
||||
"x.com.samsung.da.current": "2",
|
||||
"x.com.samsung.da.maximum": "7",
|
||||
"x.com.samsung.da.minimum": "1",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
],
|
||||
"href": "/temperatures/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "eomkim_IoT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -75,6 +75,41 @@ async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
|
||||
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
|
||||
|
||||
|
||||
async def test_setup_normalizes_messy_pasted_pem(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""A PEM with a leading UTF-8 BOM, CRLF line endings, and a stray blank
|
||||
line -- the kind a Windows text editor's copy produces, as opposed to a
|
||||
`type` dump (issue #291) -- must still be accepted and stored in its
|
||||
normalized form, not rejected with an opaque InvalidHeader."""
|
||||
messy_cert = "\ufeff" + MOCK_CA_CERT_PEM.replace("\n", "\r\n") + "\r\n\r\n"
|
||||
messy_key = "\ufeff" + MOCK_CA_KEY_PEM.replace("\n", "\r\n")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_CA_CERT_PEM: messy_cert,
|
||||
CONF_CA_KEY_PEM: messy_key,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
|
||||
assert result["data"][CONF_CA_KEY_PEM] == MOCK_CA_KEY_PEM
|
||||
|
||||
|
||||
def test_normalize_pem_strips_bom_crlf_and_blank_lines() -> None:
|
||||
"""Unit-level check of the helper itself, isolated from the flow."""
|
||||
from custom_components.localthings.config_flow import _normalize_pem
|
||||
|
||||
messy = "\ufeff-----BEGIN CERTIFICATE-----\r\nTEST-CA\r\n\r\n-----END CERTIFICATE-----\r\n"
|
||||
assert _normalize_pem(messy) == (
|
||||
"-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
)
|
||||
# A clean PEM (the `type`-dump case) passes through unchanged.
|
||||
clean = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
assert _normalize_pem(clean) == clean
|
||||
|
||||
|
||||
def test_order_candidates_prefers_known_ports() -> None:
|
||||
"""Live ports are ordered with the historically known DTLS ports first,
|
||||
then the rest ascending."""
|
||||
@@ -1001,9 +1036,10 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
hass: HomeAssistant,
|
||||
mock_coordinator_session,
|
||||
) -> None:
|
||||
"""Picking an href, then submitting a payload, drives
|
||||
coordinator.async_raw_write and lands on the result menu with the
|
||||
device's response."""
|
||||
"""Picking an href, then submitting a payload, calls the write_resource
|
||||
service (issue #300) -- which drives
|
||||
coordinator.async_raw_write_sequence -- and lands on the result menu
|
||||
with the device's response."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
@@ -1021,8 +1057,20 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
assert result["step_id"] == "debug_edit"
|
||||
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
@@ -1088,8 +1136,20 @@ async def test_options_flow_finish_preserves_existing_options(
|
||||
user_input={"href": "/washer/vs/0"},
|
||||
)
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -21,6 +22,7 @@ from custom_components.localthings.const import (
|
||||
SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from custom_components.localthings.coordinator import (
|
||||
_RECOVERY_RETRY_S,
|
||||
LocalThingsCoordinator,
|
||||
_local_source_port,
|
||||
)
|
||||
@@ -30,7 +32,7 @@ from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, FakeObserveSession
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
|
||||
@@ -478,6 +480,57 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
|
||||
assert coordinator._observe.mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_total_poll_failure_downgrades_observe_mode_to_poll(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
|
||||
) -> None:
|
||||
"""A device that drops off the network entirely -- both the poll and its
|
||||
reconnect retry fail -- must not leave the connection-mode sensor
|
||||
reporting 'Push' forever (issue #287). Only the *successful* reconnect
|
||||
branch used to touch observe mode (see
|
||||
test_reconnect_while_observe_mode_downgrades_to_poll); this covers the
|
||||
branch where the device stays unreachable."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
last_notify_ts = coordinator._observe._last_notify_ts
|
||||
assert last_notify_ts is not None
|
||||
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
side_effect=[RuntimeError("connection lost"), RuntimeError("still lost")],
|
||||
),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
await coordinator.async_request_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The update still "succeeds" with the last-known snapshot (issue #254's
|
||||
# degraded-data path) -- but the connection mode must reflect reality
|
||||
# now, not the stale OBSERVE state from before the outage.
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
|
||||
async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -691,6 +744,149 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_discards_stale_commit_after_session_swap(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""A reconnect (the poll path's own, or a command's retry) can swap
|
||||
self._session while this attempt's grace wait is in flight -- it runs
|
||||
without holding _session_lock precisely so a write isn't blocked behind
|
||||
it (issue #294). Committing observe mode against the now-stale local
|
||||
`sess` reference would claim "Push" on a session that's already gone,
|
||||
with nothing left to notice -- the identity re-check under the lock
|
||||
right before committing must catch this and abandon instead.
|
||||
|
||||
The new session is never-tried, though, not just abandoned: it must
|
||||
flag an immediate resubscribe rather than let _last_observe_attempt_ts
|
||||
(stamped for the now-abandoned attempt) throttle it for up to
|
||||
_RECOVERY_RETRY_S.
|
||||
|
||||
Simulates the swap from inside await_observe_notifies itself rather
|
||||
than via real concurrency: subscribe_hrefs (and its lock) has already
|
||||
returned by the time that call runs, so this lands exactly in the
|
||||
window the identity check exists to cover, deterministically."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator._resubscribe_due is False
|
||||
|
||||
other = FakeObserveSession()
|
||||
|
||||
def _swap_session_mid_wait(subscribed, grace_period_s, success_fraction=None):
|
||||
coordinator._session = other # ty: ignore[invalid-assignment]
|
||||
return True
|
||||
|
||||
with patch.object(
|
||||
coordinator._observe, "await_observe_notifies", side_effect=_swap_session_mid_wait
|
||||
):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
assert coordinator._observe.subscribed_hrefs == set()
|
||||
assert coordinator._observe._refresh_thread is None
|
||||
assert coordinator._resubscribe_due is True
|
||||
|
||||
|
||||
async def test_maybe_retry_observe_mode_uses_most_recent_attempt_not_just_mode_change(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""_set_mode only stamps last_mode_change_ts on an actual transition,
|
||||
so a device that never successfully enters observe mode leaves that
|
||||
timestamp stuck at construction time forever -- a failed attempt keeps
|
||||
calling _set_mode(MODE_POLL) while already in MODE_POLL, a no-op.
|
||||
Gating solely on that timestamp would make the 600s throttle open once
|
||||
and then never close again, re-attempting (and paying the subscribe
|
||||
burst) on every single poll cycle instead of every _RECOVERY_RETRY_S."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.observe_mode == MODE_POLL # never notified during setup
|
||||
|
||||
# Simulate exactly the scenario above: mode_change_ts is old (as it
|
||||
# would be forever, for a device that never gets push), but an attempt
|
||||
# really did just run.
|
||||
coordinator._observe.last_mode_change_ts = time.monotonic() - _RECOVERY_RETRY_S - 1
|
||||
coordinator._last_observe_attempt_ts = time.monotonic()
|
||||
|
||||
with patch.object(fake, "subscribe") as mock_subscribe:
|
||||
await coordinator._maybe_retry_observe_mode()
|
||||
|
||||
mock_subscribe.assert_not_called()
|
||||
|
||||
|
||||
async def test_maybe_retry_observe_mode_also_respects_a_mode_change_outside_an_attempt(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The mirror of the case above: last_mode_change_ts can be the more
|
||||
recent of the two as well, e.g. right after the poll or command path's
|
||||
own downgrade (neither goes through _attempt_observe_mode, so neither
|
||||
stamps _last_observe_attempt_ts). Dropping last_mode_change_ts from the
|
||||
max() would let a device that was *just* downgraded get re-attempted
|
||||
immediately instead of respecting _RECOVERY_RETRY_S."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
coordinator._last_observe_attempt_ts = time.monotonic() - _RECOVERY_RETRY_S - 1
|
||||
coordinator._observe.last_mode_change_ts = time.monotonic()
|
||||
|
||||
with patch.object(fake, "subscribe") as mock_subscribe:
|
||||
await coordinator._maybe_retry_observe_mode()
|
||||
|
||||
mock_subscribe.assert_not_called()
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_releases_lock_before_the_grace_wait(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The subscribe burst holds _session_lock (it touches the session);
|
||||
the grace wait after it must not, or a command write could stall
|
||||
behind up to _OBSERVE_GRACE_PERIOD_S of an unrelated observe-mode-entry
|
||||
attempt (issue #294). By construction, subscribe_hrefs's own
|
||||
`async with self._session_lock:` has already exited by the time
|
||||
await_observe_notifies is even called -- checked here rather than
|
||||
inferred from timing."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
locked_during_wait = {"value": None}
|
||||
|
||||
def _check_lock(subscribed, grace_period_s, success_fraction=None):
|
||||
locked_during_wait["value"] = coordinator._session_lock.locked()
|
||||
return True
|
||||
|
||||
with patch.object(coordinator._observe, "await_observe_notifies", side_effect=_check_lock):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert locked_during_wait["value"] is False
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_holds_lock_during_the_subscribe_burst(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The other half of the split above: the subscribe burst does touch
|
||||
the session, so it must hold _session_lock -- that's what actually
|
||||
stops a concurrent close from landing mid-subscribe (issue #294)."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
locked_during_subscribe = {"value": None}
|
||||
real_subscribe_hrefs = coordinator._observe.subscribe_hrefs
|
||||
|
||||
def _check_lock(session, hrefs):
|
||||
locked_during_subscribe["value"] = coordinator._session_lock.locked()
|
||||
return real_subscribe_hrefs(session, hrefs)
|
||||
|
||||
with patch.object(coordinator._observe, "subscribe_hrefs", side_effect=_check_lock):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert locked_during_subscribe["value"] is True
|
||||
|
||||
|
||||
async def test_sweep_mismatch_never_downgrades_a_live_observe_session(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -956,6 +1152,192 @@ async def test_send_command_survives_stale_confirm_poll(
|
||||
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
|
||||
|
||||
|
||||
async def test_send_command_reconnects_and_retries_after_socket_closed(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""A command lost to a session Samsung's firmware closed between polls
|
||||
must not just vanish (issue #294): `_do_put` failing once is now
|
||||
followed by a reconnect and a single retry, mirroring the poll path's
|
||||
own recovery in `_async_update_data`.
|
||||
|
||||
`mock_coordinator_observe_session` patches `_close_session` to a no-op,
|
||||
which would leave `self._session` never actually going `None` -- and
|
||||
with it, `_do_put`'s own `if self._session is None: self._connect_session()`
|
||||
guard never exercised, so a broken reconnect could still pass. Overridden
|
||||
here to actually drop the session, so the retry only succeeds if that
|
||||
guard really rebuilds it."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("socket closed")
|
||||
return (0x44, b"")
|
||||
|
||||
def _drop_session():
|
||||
coordinator._session = None
|
||||
|
||||
reconnects = {"n": 0}
|
||||
|
||||
def _reconnect():
|
||||
reconnects["n"] += 1
|
||||
coordinator._session = fake
|
||||
|
||||
with (
|
||||
patch.object(fake, "subscribe"),
|
||||
patch.object(coordinator, "_close_session", side_effect=_drop_session),
|
||||
patch.object(coordinator, "_connect_session", side_effect=_reconnect),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert reconnects["n"] == 1
|
||||
|
||||
assert calls["n"] == 2
|
||||
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
|
||||
|
||||
|
||||
async def test_send_command_raises_after_reconnect_retry_also_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""If the command still fails on the reconnected session, the user must
|
||||
see it -- previously this was swallowed into a log line with no
|
||||
feedback at all (issue #294).
|
||||
|
||||
Also covers a sibling bug the fix for that same issue introduced: the
|
||||
session is closed the moment the first attempt fails, so any OBSERVE
|
||||
subscriptions on it are already dead regardless of whether the retry
|
||||
that follows succeeds -- a failed retry must still downgrade mode, or
|
||||
it's left claiming "Push" on a session that no longer exists."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
raise ConnectionError("socket closed")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
pytest.raises(HomeAssistantError),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_send_command_reconnect_downgrades_observe_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""A command's own successful reconnect hands back a session with zero
|
||||
OBSERVE registrations too, same as the poll path's reconnect -- must
|
||||
downgrade the same way and flag a resubscribe, or observe mode stays
|
||||
claimed against a session the write just replaced underneath it
|
||||
(issue #294)."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("socket closed")
|
||||
return (0x44, b"")
|
||||
|
||||
with (
|
||||
patch.object(fake, "subscribe") as mock_subscribe,
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
# _resubscribe_due is consumed by this same call's own trailing
|
||||
# refresh (async_request_refresh is awaited, not fire-and-forget),
|
||||
# so the visible effect is a resubscribe attempt, not a lingering
|
||||
# flag value to assert on afterward.
|
||||
assert mock_subscribe.called
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""The particulate sensors must declare a state_class so Home Assistant keeps
|
||||
long-term statistics for them; the graded readings must not.
|
||||
|
||||
Without a state_class a sensor only lives in the short-term recorder history
|
||||
and is dropped at the next purge, so a long-range air-quality graph is not
|
||||
possible -- that is the bug this guards against reappearing.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
from custom_components.localthings.registry.entities import SensorDesc
|
||||
|
||||
PARTICULATE = ("dust", "fine_dust", "super_fine_dust")
|
||||
GRADED = ("odor", "clean_level")
|
||||
|
||||
|
||||
def _desc(key):
|
||||
return next(d for d in air_purifier.AIR_QUALITY.entities if d.key == key)
|
||||
|
||||
|
||||
def test_particulate_sensors_record_long_term_statistics():
|
||||
for key in PARTICULATE:
|
||||
assert _desc(key).state_class == "measurement", key
|
||||
|
||||
|
||||
def test_graded_sensors_are_left_without_a_state_class():
|
||||
"""Odor and CleanLevel read 0-2 on every fixture -- graded indices, not
|
||||
concentrations. Whether averaging a grade is meaningful is a separate
|
||||
call, so they stay unstamped rather than being guessed into statistics."""
|
||||
for key in GRADED:
|
||||
assert _desc(key).state_class is None, key
|
||||
|
||||
|
||||
def test_no_unit_or_device_class_is_asserted():
|
||||
"""state_class alone makes the series recordable. pm1/pm25/pm10 with
|
||||
µg/m³ would additionally assert the reading is a mass concentration,
|
||||
which no dump states."""
|
||||
for key in PARTICULATE + GRADED:
|
||||
desc = _desc(key)
|
||||
assert desc.unit is None, key
|
||||
assert desc.device_class is None, key
|
||||
|
||||
|
||||
def test_state_class_comes_from_the_shared_tuples_fourth_column():
|
||||
"""The rows carry their own state_class rather than a parallel lookup, so
|
||||
a new sensor can't be added here without deciding the question."""
|
||||
for row in air_purifier._AIR_QUALITY_SENSORS:
|
||||
assert len(row) == 4, row
|
||||
assert row[3] in ("measurement", None), row
|
||||
|
||||
|
||||
def test_air_monitor_keeps_stamping_every_shared_sensor():
|
||||
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column
|
||||
on purpose: that board (issue #210) has stamped all five as `measurement`
|
||||
since it was added, and consuming the column would silently drop long-term
|
||||
statistics for Odor/CleanLevel there. Guards the import end to end and the
|
||||
deliberate divergence together."""
|
||||
from custom_components.localthings.registry.capabilities import air_monitor
|
||||
|
||||
assert air_monitor.SENSORS.href == "/sensors/vs/0"
|
||||
for key in PARTICULATE + GRADED:
|
||||
desc = next(
|
||||
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
|
||||
)
|
||||
assert desc.state_class == "measurement", key
|
||||
|
||||
|
||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||
"""A state_class is only honoured for a numeric state, so the value
|
||||
contract this depends on is asserted here too."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("air_purifier")
|
||||
rep = resources["/sensors/vs/0"]
|
||||
for key in PARTICULATE + GRADED:
|
||||
value = _desc(key).value_fn(rep["x.com.samsung.da.items"])
|
||||
assert isinstance(value, int), (key, value)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the AILP_DA-AC-FAC-02011_0000 air conditioner (issue #319).
|
||||
|
||||
This board resolves to the airconditioner registry two independent ways --
|
||||
modelNum's 'FAC' board token (for_device_by_model) and /oic/d's
|
||||
oic.d.airconditioner type (for_device_by_oic_type) agree -- and reports
|
||||
several resources the sibling TP1X_DA-AC-CAC-01001 board (issue #191) left
|
||||
as a documented gap: /display/vs/0 and the /settings/sound/* trio (now
|
||||
shared with air_purifier.py's identical shapes), plus two genuinely new
|
||||
hrefs (/csi/absenceclean/vs/0, /csi/energysaving/vs/0).
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.entity import _is_included
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
for_device_by_model,
|
||||
for_device_by_oic_type,
|
||||
resolve,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
_DEVICE_TYPES = ("oic.wk.d", "oic.d.airconditioner")
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
"""Minimal stand-in for entity._is_included's coordinator dependency --
|
||||
same shape as test_entity.py's own fake, kept local rather than shared
|
||||
across test modules."""
|
||||
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
return self.last_resources
|
||||
|
||||
|
||||
def _resources():
|
||||
return _load_device("airconditioner_ailp_fac")
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _resources()
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_oic_device_type_resolves_to_airconditioner_registry():
|
||||
reg = for_device_by_oic_type(_DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "airconditioner"
|
||||
|
||||
|
||||
def test_board_token_resolves_to_airconditioner_registry():
|
||||
"""'FAC' is a real _BOARD_TOKEN_TO_KEY entry -- for_device_by_model
|
||||
alone (no device_types) already resolves this board correctly, same as
|
||||
for_device_by_oic_type above; resolve()'s device_types-agreement isn't
|
||||
covering an otherwise-unreachable path."""
|
||||
resources = _resources()
|
||||
info = resources["/information/vs/0"]
|
||||
reg = for_device_by_model(
|
||||
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
|
||||
)
|
||||
assert reg is not None and reg.name == "airconditioner"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
resources = _resources()
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_sound_mode_registers_despite_no_live_mode_value():
|
||||
"""This dump has no current `mode` value yet, only supportedModes
|
||||
(mute/tone/voice). entity.py's default field-presence gate would
|
||||
otherwise keep the select from ever being created in HA even though
|
||||
adapter.flatten() (checked below) has no such gate and would look
|
||||
bound regardless -- exercise the real _is_included gate, not just
|
||||
flatten(), so a regression here can't hide behind that gap again."""
|
||||
bound, resources = _bound()
|
||||
entity = next(b for b in bound if b.desc.key == "sound_mode")
|
||||
assert entity.desc.options_field == "supportedModes"
|
||||
coord = cast(LocalThingsCoordinator, _FakeCoordinator(resources))
|
||||
assert _is_included(entity, coord) is True
|
||||
state = flatten(bound, resources)
|
||||
assert "sound_mode" in state
|
||||
assert state["sound_mode"] is None
|
||||
|
||||
|
||||
def test_absence_clean_and_energy_saving_bound():
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["absence_clean"] is True
|
||||
assert state["energy_saving_mode"] == "Off_100"
|
||||
assert state["energy_saving_state"] == "Off"
|
||||
|
||||
|
||||
def test_sound_volume_self_gates_off_without_max_level():
|
||||
"""This board's /settings/sound/volume/vs/0 reports minLevel/resolution
|
||||
but no maxLevel -- air_purifier.SOUND_VOLUME's exists_fn should keep it
|
||||
unbound-but-covered rather than shipping a min=0/max=0 slider."""
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert "sound_volume" not in state
|
||||
@@ -217,6 +217,70 @@ def _desc(resources, key):
|
||||
return None
|
||||
|
||||
|
||||
def test_good_sleep_is_hours_while_the_token_counts_half_hours():
|
||||
"""The appliance's own app pairs its duration picker with the values it sends
|
||||
one to one -- 0:30 -> 1, 1:00 -> 2, 3:00 -> 6, 12:00 -> 24 -- so the token is
|
||||
half hours and the entity, which is in hours, has to halve and double. The
|
||||
fixture's own Sleep_0 is the one value that reads the same either way, hence
|
||||
the injected token here."""
|
||||
resources = _load_device(FIXTURE)
|
||||
mode = resources["/mode/vs/0"]
|
||||
mode["x.com.samsung.da.options"] = [
|
||||
option for option in mode["x.com.samsung.da.options"] if option != "Sleep_0"
|
||||
] + ["Sleep_5"]
|
||||
bound, _ = _discover(resources)
|
||||
assert flatten(bound, resources)["good_sleep"] == 2.5
|
||||
|
||||
desc = _desc(resources, "good_sleep")
|
||||
assert (desc.native_max, desc.step) == (12, 0.5)
|
||||
assert desc.write_fn(2.5, {}) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_5"]},
|
||||
)
|
||||
# 12 hours is the app's maximum and has to be reachable -- it was not while
|
||||
# the token was published as hours.
|
||||
assert desc.write_fn(12, {})[1]["x.com.samsung.da.options"] == ["Comode_Sleep", "Sleep_24"]
|
||||
|
||||
|
||||
def _options(rep_options):
|
||||
return {"x.com.samsung.da.options": list(rep_options)}
|
||||
|
||||
|
||||
def test_good_sleep_write_carries_the_mode_token_the_duration_belongs_to():
|
||||
"""`Sleep_<n>` on its own does nothing. Measured on an ARTIK051_KRAC_18K:
|
||||
writing `["Sleep_4"]` was answered 2.04 Changed and the token still read
|
||||
`Sleep_0` at +8s and +45s, while `["Comode_Sleep", "Sleep_4"]` held. The
|
||||
duration is a parameter of the mode, so both go in one write -- which is
|
||||
also the only form the appliance's own app sends."""
|
||||
desc = _desc(_load_device(FIXTURE), "good_sleep")
|
||||
|
||||
assert desc.write_fn(2, _options(["Comode_Off", "Sleep_0"])) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_4"]},
|
||||
)
|
||||
# Off means leaving the mode as well as zeroing the duration.
|
||||
assert desc.write_fn(0, _options(["Comode_Sleep", "Sleep_4"]))[1] == _options(
|
||||
["Comode_Off", "Sleep_0"]
|
||||
)
|
||||
|
||||
|
||||
def test_good_sleep_and_nano_wind_share_one_token():
|
||||
"""Nano wind and Good Sleep are one Comode_ slot, so running both is
|
||||
Comode_NanoSleep -- the board produces that code by itself when nano is
|
||||
asked for while the timer runs. Turning the timer off then has to leave nano
|
||||
running rather than switching the mode off entirely, which is how the app
|
||||
reads it back."""
|
||||
desc = _desc(_load_device(FIXTURE), "good_sleep")
|
||||
|
||||
for comode in ("Comode_Nano", "Comode_NanoSleep"):
|
||||
assert desc.write_fn(2, _options([comode, "Sleep_0"]))[1] == _options(
|
||||
["Comode_NanoSleep", "Sleep_4"]
|
||||
)
|
||||
assert desc.write_fn(0, _options(["Comode_NanoSleep", "Sleep_4"]))[1] == _options(
|
||||
["Comode_Nano", "Sleep_0"]
|
||||
)
|
||||
|
||||
|
||||
def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
|
||||
"""The interval FilterTime_ is measured against, offered by the app as a
|
||||
180/300/500/700 hour radio. All four were walked on hardware while watching
|
||||
@@ -361,17 +425,22 @@ def test_preset_comes_from_the_comode_token():
|
||||
resources = _load_device(FIXTURE)
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == "none" # Comode_Off in the fixture
|
||||
# Codes learned by driving this unit through its cloud integration and
|
||||
# reading the token back. They go through the same dynamic resolver as a
|
||||
# real convenient resource's supportedModes, so 'Nano' resolves to the
|
||||
# existing 'nano' preset -- already labelled WindFree in the catalog.
|
||||
# The codes go through the same dynamic resolver as a real convenient
|
||||
# resource's supportedModes, so 'Nano' resolves to the existing 'nano' preset
|
||||
# -- already labelled WindFree in the catalog. Which of them are offered
|
||||
# depends on the HVAC mode and on the unit's own capability bits; the fixture
|
||||
# is in Cool, and test_presets_follow_the_hvac_mode_and_the_capability_bits
|
||||
# covers every mode.
|
||||
assert entity.preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"speed",
|
||||
"2step",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
"smart",
|
||||
"sleep",
|
||||
"nanosleep",
|
||||
]
|
||||
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
@@ -391,6 +460,252 @@ async def test_preset_write_uses_the_token_path():
|
||||
assert coordinator.commands[-1][1] == ("preset_legacy", "Nano")
|
||||
|
||||
|
||||
def test_the_sleep_modes_are_presets_too():
|
||||
"""Good Sleep lives in the same Comode_ slot as the presets, so a unit
|
||||
running it reports a code that was not in the list -- and a preset_mode
|
||||
outside preset_modes is not a state HA allows. Verified against a live unit:
|
||||
with the board on Comode_Sleep, the entity reported preset_mode 'sleep'
|
||||
while preset_modes offered only none/nano/quiet/comfort/2step/speed."""
|
||||
resources = _load_device(FIXTURE)
|
||||
assert _climate(resources).preset_modes[-2:] == ["sleep", "nanosleep"]
|
||||
|
||||
for token, preset in (("Comode_Sleep", "sleep"), ("Comode_NanoSleep", "nanosleep")):
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
token if option.startswith("Comode_") else option for option in options
|
||||
]
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == preset
|
||||
assert preset in entity.preset_modes
|
||||
|
||||
|
||||
def test_boards_without_the_sleep_token_do_not_get_the_sleep_presets():
|
||||
"""The codes come with the Sleep_ token; a unit that has no such token has
|
||||
nothing to report them from."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
option for option in options if not option.startswith("Sleep_")
|
||||
]
|
||||
presets = _climate(resources).preset_modes
|
||||
assert "sleep" not in presets and "nanosleep" not in presets
|
||||
|
||||
|
||||
def _in_mode(mode, fixture=FIXTURE):
|
||||
resources = _load_device(fixture)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
return _climate(resources).preset_modes
|
||||
|
||||
|
||||
def test_presets_follow_the_hvac_mode_and_the_capability_bits():
|
||||
"""The fixture's own OptionCode_35882 / ExtendOptionCode_7 say: WindFree yes
|
||||
(eoc[31]), 18K yes (eoc[30]), Quiet yes (oc[10]), Turbo/Comfort in Heat yes
|
||||
(oc[12]), d'light no (oc[2]), Single User no (oc[3], oc[11]).
|
||||
|
||||
Its owner read the same lists off the remote and the appliance's app --
|
||||
2-Step/Fast Turbo/Comfort/Quiet/WindFree in Cool, Fast Turbo/Comfort/Quiet in
|
||||
Heat with WindFree impossible, WindFree alone in Dry and Fan, nothing in Auto
|
||||
beyond WindFree (which the app reaches by switching to Cool) -- and the
|
||||
appliance itself refused Comode_Nano in Auto and accepted it in Cool.
|
||||
"""
|
||||
assert _in_mode("Cool") == [
|
||||
"none",
|
||||
"nano",
|
||||
"speed",
|
||||
"2step",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"smart",
|
||||
"sleep",
|
||||
"nanosleep",
|
||||
]
|
||||
# Heat: no WindFree at all, and no Smart Saver (Cool-only).
|
||||
heat = _in_mode("Heat")
|
||||
assert "nano" not in heat
|
||||
assert "smart" not in heat
|
||||
assert [c for c in ("speed", "comfort", "quiet") if c in heat] == ["speed", "comfort", "quiet"]
|
||||
# Dry and Fan: WindFree is the only one, and it keeps the mode.
|
||||
for mode in ("Dry", "Wind"):
|
||||
assert _in_mode(mode) == ["none", "nano"]
|
||||
# Auto: WindFree only because this is an 18K model.
|
||||
assert _in_mode("Auto") == ["none", "nano"]
|
||||
# d'light Cool is a live rule and this unit's oc[2] denies it everywhere.
|
||||
for mode in ("Cool", "Heat", "Dry", "Wind", "Auto"):
|
||||
assert "dlightcool" not in _in_mode(mode), mode
|
||||
|
||||
|
||||
def test_a_board_with_only_the_old_map_keeps_the_unconditional_list():
|
||||
"""One map is not enough to judge by. `airconditioner_artik051_dongle_fac_18k`
|
||||
is a legacy board that publishes OptionCode and no ExtendOptionCode, so every
|
||||
eoc-gated rule would read None -- and None means "this board does not publish
|
||||
the map", not "the feature is absent". Deriving from it would have cost that
|
||||
unit WindFree in every mode, and left it with ['none'] alone in its own
|
||||
fixture mode.
|
||||
|
||||
Its OptionCode is also 521, three orders of magnitude below the RAC-class
|
||||
values these bit positions were read from, which is the second reason not to
|
||||
interpret it: the FAC and CAC families use the field differently.
|
||||
"""
|
||||
resources = _load_device("airconditioner_artik051_dongle_fac_18k")
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
assert any(o.startswith("OptionCode_") for o in options)
|
||||
assert not any(o.startswith("ExtendOptionCode_") for o in options)
|
||||
|
||||
baseline = ["none", "nano", "quiet", "comfort", "2step", "speed"]
|
||||
for mode in ("Auto", "Cool", "Heat", "Dry", "Wind"):
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
assert _climate(resources).preset_modes == baseline, mode
|
||||
|
||||
|
||||
def test_the_other_board_with_both_maps_still_derives():
|
||||
"""`airconditioner_artik051_krac_energy` is the same model as the fixture
|
||||
above with a different OptionCode (56378), and carries both maps -- so it
|
||||
stays on the derived path rather than the fallback."""
|
||||
presets = _in_mode("Cool", fixture="airconditioner_artik051_krac_energy")
|
||||
assert presets[:1] == ["none"]
|
||||
assert "nano" in presets and "smart" in presets
|
||||
assert presets != ["none", "nano", "quiet", "comfort", "2step", "speed"]
|
||||
|
||||
|
||||
def test_an_unknown_hvac_mode_falls_back_instead_of_deriving():
|
||||
"""An HVAC mode these rules have never seen is the same "cannot judge" case
|
||||
as an absent map, so it gets the same answer rather than a derived-but-wrong
|
||||
one. Reachable with a partial or malformed rep, where `modes` is missing."""
|
||||
resources = _load_device(FIXTURE)
|
||||
for modes in ([], ["CoolClean"]):
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = modes
|
||||
assert _climate(resources).preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
], modes
|
||||
|
||||
|
||||
async def test_aicomfort_neither_offers_nano_nor_switches_the_mode():
|
||||
"""The app disables WindFree in AIComfort, so it is not offered -- and the
|
||||
Cool-first write is therefore Auto-only, with no unreachable branch for a
|
||||
mode that can never ask for it."""
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["AIComfort"]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _climate(resources, coordinator)
|
||||
|
||||
assert "nano" not in entity.preset_modes
|
||||
await entity.async_set_preset_mode("quiet")
|
||||
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Quiet")]
|
||||
|
||||
|
||||
def test_a_bit_that_is_zero_removes_its_preset():
|
||||
"""oc[12] is what puts Fast Turbo and Comfort in Heat; without it the app
|
||||
hides both, and so does this."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
# 35882 with oc[12] cleared, everything else untouched.
|
||||
assert format(35882, "016b")[12] == "1"
|
||||
cleared = int(format(35882, "016b")[:12] + "0" + format(35882, "016b")[13:], 2)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
f"OptionCode_{cleared}" if option.startswith("OptionCode_") else option
|
||||
for option in options
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Heat"]
|
||||
presets = _climate(resources).preset_modes
|
||||
assert "speed" not in presets and "comfort" not in presets
|
||||
assert "quiet" in presets # oc[10], untouched
|
||||
|
||||
|
||||
def test_a_board_without_the_bit_maps_keeps_the_unconditional_list():
|
||||
"""An absent map is not a claim that nothing is supported -- issue #136's unit
|
||||
of this same model publishes a different token set, and a board that omits
|
||||
OptionCode entirely must not lose every preset."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
option for option in options if not option.startswith(("OptionCode_", "ExtendOptionCode_"))
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
assert _climate(resources).preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
]
|
||||
|
||||
|
||||
def test_the_active_code_is_always_listed_even_where_the_rules_deny_it():
|
||||
"""A remote can put the unit in a mode these rules would not offer -- and a
|
||||
preset_mode outside preset_modes is not a state HA allows, so the appliance
|
||||
has the last word. Measured: Comode_2Step was accepted and held while the
|
||||
unit was in Auto, where neither the remote nor the app offers it."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
"Comode_2Step" if option.startswith("Comode_") else option for option in options
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == "2step"
|
||||
assert "2step" in entity.preset_modes
|
||||
|
||||
|
||||
async def test_nano_in_auto_switches_the_board_to_cool_first():
|
||||
"""Comode_Nano written while the unit is in Auto is answered 2.04 Changed and
|
||||
dropped; the same token after a separate mode write holds. Putting modes and
|
||||
options in one POST does not work either, so the mode goes first, on its own.
|
||||
"""
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _climate(resources, coordinator)
|
||||
|
||||
await entity.async_set_preset_mode("nano")
|
||||
|
||||
assert [payload for _, payload in coordinator.commands] == [
|
||||
("mode", "Cool"),
|
||||
("preset_legacy", "Nano"),
|
||||
]
|
||||
|
||||
|
||||
async def test_nano_outside_auto_writes_only_the_preset():
|
||||
"""In Cool the token holds on its own, so nothing else is sent -- and in Dry
|
||||
the mode must be left alone, since WindFree keeps it."""
|
||||
for mode in ("Cool", "Dry"):
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
await _climate(resources, coordinator).async_set_preset_mode("nano")
|
||||
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Nano")], mode
|
||||
|
||||
|
||||
def test_nano_preset_keeps_a_running_good_sleep_at_its_own_duration():
|
||||
"""Writing a bare Comode_Nano over a live Comode_Sleep/Sleep_4 came back as
|
||||
Comode_NanoSleep/Sleep_16 -- the board upgrades the code by itself and then
|
||||
supplies a duration of its own, turning two hours into eight without anyone
|
||||
asking. Sending the pair keeps the user's value."""
|
||||
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
|
||||
|
||||
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Sleep", "Sleep_4"]))[
|
||||
1
|
||||
] == _options(["Comode_NanoSleep", "Sleep_4"])
|
||||
# Idle timer: nano is just nano, exactly as before.
|
||||
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Off", "Sleep_0"]))[
|
||||
1
|
||||
] == _options(["Comode_Nano"])
|
||||
# A sleep preset selected outright has no duration to reuse, so it takes the
|
||||
# one the appliance itself falls back to (Sleep_16, eight hours).
|
||||
assert _climate_write(("preset_legacy", "Sleep"), _options(["Comode_Off", "Sleep_0"]))[
|
||||
1
|
||||
] == _options(["Comode_Sleep", "Sleep_16"])
|
||||
# Any other preset is untouched by all of this.
|
||||
assert _climate_write(("preset_legacy", "Quiet"), _options(["Comode_Sleep", "Sleep_4"]))[
|
||||
1
|
||||
] == _options(["Comode_Quiet"])
|
||||
|
||||
|
||||
async def test_newer_boards_keep_the_resource_paths():
|
||||
"""The legacy fallbacks are gated on this board's resource shape, so a
|
||||
board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
|
||||
|
||||
@@ -6,18 +6,40 @@ token -- this board was the one exception (its oneUiVersion self-reports
|
||||
"7.0 Air conditioner", but 'CAC' had never been added to the board-token
|
||||
table), so it silently fell back to common caps and lost its climate entity.
|
||||
|
||||
This dump is NOT fully covered yet -- eight hrefs remain unbound (edge
|
||||
lighting, a second stateful light resource, absence-clean, four
|
||||
`/settings/sound/*` resources, smart-sensing-cooling), all genuinely new to
|
||||
this board generation. That's a real device-support gap, left documented
|
||||
here rather than guessed at, per the 'don't guess' rule -- fixing the
|
||||
routing regression was the scope of #191.
|
||||
This dump is NOT fully covered yet -- two hrefs remain unbound
|
||||
(`/settings/sound/optimization/vs/0`, smart-sensing-cooling), both
|
||||
genuinely new to this board generation. That's a real device-support gap,
|
||||
left documented here rather than guessed at, per the 'don't guess' rule --
|
||||
fixing the routing regression was the scope of #191.
|
||||
|
||||
/settings/sound/mode/vs/0, /settings/sound/output/vs/0 and
|
||||
/settings/sound/volume/vs/0 used to be on this list too, until issue #319
|
||||
(a sibling TP1X_DA-AC-FAC-class board) supplied a live dump for them --
|
||||
airconditioner.SOUND_MODE and the reused air_purifier.SOUND_OUTPUT/
|
||||
SOUND_VOLUME now cover all three here as well.
|
||||
|
||||
/edgelighting/vs/0 and /light/stateful/vs/0 used to be on this list too,
|
||||
until issue #288 (six System A/C cassette units on this same board) gave
|
||||
real dump evidence for both -- airconditioner.EDGE_LIGHTING and
|
||||
LIGHT_STATEFUL now cover them.
|
||||
|
||||
/mds/absenceclean/vs/0 used to be on this list too -- its {mode,
|
||||
supportedModes: [On, Off]} shape is byte-identical to issue #319's
|
||||
/csi/absenceclean/vs/0, confirmed on that sibling board rather than
|
||||
guessed, so airconditioner.MDS_ABSENCE_CLEAN now covers it too.
|
||||
|
||||
/uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too,
|
||||
until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both --
|
||||
this board's own live filterUsage/filterStatus data on the PM1 filter binds
|
||||
through the same exists_fn-gated entities #270's dump (which has neither
|
||||
field) leaves empty.
|
||||
|
||||
/airlevelcheck/vs/0 was never on this unbound list (this fixture's rep
|
||||
already carried a full set of periodicSensing*/autoExeState fields), but
|
||||
until PR #316 it was globally ignored by airconditioner.py's own
|
||||
_AC_IGNORED as "scheduler plumbing" -- this fixture's own populated values
|
||||
were the proof that description was wrong. air_purifier.AIR_LEVEL_CHECK
|
||||
now covers it (see test_airlevelcheck_binds_real_ai_purify_state below).
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
@@ -27,13 +49,7 @@ from tests.conftest import _load_device
|
||||
|
||||
_STILL_UNBOUND = frozenset(
|
||||
{
|
||||
"/edgelighting/vs/0",
|
||||
"/light/stateful/vs/0",
|
||||
"/mds/absenceclean/vs/0",
|
||||
"/settings/sound/mode/vs/0",
|
||||
"/settings/sound/optimization/vs/0",
|
||||
"/settings/sound/output/vs/0",
|
||||
"/settings/sound/volume/vs/0",
|
||||
"/smartsensingcooling/vs/0",
|
||||
}
|
||||
)
|
||||
@@ -65,6 +81,32 @@ def test_documented_coverage_gap_is_exactly_this_set():
|
||||
assert set(unbound) == _STILL_UNBOUND
|
||||
|
||||
|
||||
def test_mds_absenceclean_shares_csi_absenceclean_key():
|
||||
"""/mds/absenceclean/vs/0's mode=='Off' on this dump -- confirms
|
||||
MDS_ABSENCE_CLEAN actually binds (not just that the href stops
|
||||
reporting as unbound)."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state["absence_clean"] is False
|
||||
|
||||
|
||||
def test_airlevelcheck_binds_real_ai_purify_state():
|
||||
"""This fixture's /airlevelcheck/vs/0 has real, populated values --
|
||||
periodic_air_sensing on, sensing_mode 'Alarm' -- confirming
|
||||
air_purifier.AIR_LEVEL_CHECK binds real AI-Purify state here rather
|
||||
than the inert plumbing _AC_IGNORED used to describe."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state["periodic_air_sensing"] is True
|
||||
assert state["sensing_mode"] == "Alarm"
|
||||
assert state["sensing_interval"] == 30 # 1800s
|
||||
assert state["air_sensing_state"] == "NonProcessing"
|
||||
|
||||
|
||||
def test_non_legacy_board_uses_the_generic_energy_scale():
|
||||
"""This board reports /wind/strength/vs/0 (not /airflow/vs/0), so
|
||||
is_legacy_board() is False and it must use the plain wh_to_kwh scale,
|
||||
|
||||
@@ -221,6 +221,19 @@ def test_tp1x_climate_entity_is_bound():
|
||||
assert len(climate) == 1 and climate[0].href == "/mode/vs/0"
|
||||
|
||||
|
||||
def test_tp1x_airlevelcheck_binds_real_ai_purify_state():
|
||||
"""This fixture's /airlevelcheck/vs/0 has real, populated
|
||||
periodicSensing*/autoExeState values too (PR #316's finding that
|
||||
_AC_IGNORED's old "scheduler plumbing" description was wrong wasn't
|
||||
specific to one board) -- air_purifier.AIR_LEVEL_CHECK now covers it."""
|
||||
reg, resources = _ac_tp1x()
|
||||
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state["periodic_air_sensing"] is True
|
||||
assert state["sensing_mode"] == "Alarm"
|
||||
assert state["sensing_interval"] == 30 # 1800s
|
||||
assert state["air_sensing_state"] == "NonProcessing"
|
||||
|
||||
|
||||
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
|
||||
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
|
||||
-- resolved via the '_RAC_' modelNum fallback added for this device."""
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for the ventilation-mode/Wind-Free/Wind-Sleep additions extracted
|
||||
from PR #316 (Samsung "System Fresh Air Ventilator", model
|
||||
ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001).
|
||||
|
||||
No raw diagnostics dump for this model was available -- PR #316 never
|
||||
attached one, only Korean code comments describing field shapes the
|
||||
contributor said they observed. Per this project's fixture-integrity rule
|
||||
(a fixture must record what hardware actually did, not a third party's
|
||||
prose about it), there's no `airconditioner_*_device.json` fixture for
|
||||
this model here. These tests instead exercise the gating logic directly
|
||||
against hand-built reps matching those quoted shapes, clearly distinct
|
||||
from this suite's fixture-backed tests, and check the new gate doesn't
|
||||
false-positive against every real AC fixture already in the corpus.
|
||||
|
||||
If a real diagnostics dump for this model ever surfaces (tracked as a
|
||||
follow-up device-support issue), replace this file with a proper
|
||||
fixture + golden + capability test per the usual workflow, and drop the
|
||||
disclaimer above.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities.airconditioner import (
|
||||
WINDFREE,
|
||||
WINDSLEEP,
|
||||
_is_ventilation_mode_device,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
|
||||
|
||||
def _all_airconditioner_fixture_names():
|
||||
names = []
|
||||
for path in glob.glob(os.path.join(_FIXTURES_DIR, "airconditioner*_device.json")):
|
||||
name = os.path.basename(path)[: -len("_device.json")]
|
||||
with open(path) as f:
|
||||
info = json.load(f)
|
||||
rep = next(
|
||||
(e["rep"] for e in info.get("device0", []) if e.get("href") == "/information/vs/0"),
|
||||
None,
|
||||
)
|
||||
if rep is not None: # for_device_by_model needs /information/vs/0
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def test_ventilation_mode_gate_never_false_positives_on_real_ac_fixtures():
|
||||
"""None of the real air-conditioner fixtures in this corpus use the
|
||||
Purification/Ventilation/SmartVentilation vocabulary -- confirms
|
||||
_is_ventilation_mode_device can't turn a real AC's climate card into
|
||||
this select."""
|
||||
for name in _all_airconditioner_fixture_names():
|
||||
resources = _load_device(name)
|
||||
reg = for_device_by_model(
|
||||
resources["/information/vs/0"]["x.com.samsung.da.modelNum"],
|
||||
resources["/information/vs/0"]["x.com.samsung.da.description"],
|
||||
)
|
||||
if reg is None or reg.name != "airconditioner":
|
||||
continue
|
||||
mode_rep = resources.get("/mode/vs/0")
|
||||
if not mode_rep:
|
||||
continue
|
||||
assert _is_ventilation_mode_device(mode_rep, resources) is False, name
|
||||
|
||||
|
||||
def test_ventilation_mode_gate_matches_diffuser_shape():
|
||||
"""PR #316: supportedModes exactly {Purification, Ventilation,
|
||||
SmartVentilation} -- the vocabulary that makes climate.py's hvac_mode
|
||||
collapse to one stuck value with no way to tell the three apart."""
|
||||
rep = {
|
||||
"x.com.samsung.da.modes": ["Purification"],
|
||||
"x.com.samsung.da.supportedModes": ["Purification", "Ventilation", "SmartVentilation"],
|
||||
}
|
||||
assert _is_ventilation_mode_device(rep, {}) is True
|
||||
|
||||
|
||||
def test_ventilation_mode_gate_rejects_partial_overlap():
|
||||
"""A real AC reporting an unrelated mode alongside one of these three
|
||||
words (coincidence, not this device) must not gate in -- the check is
|
||||
'subset of', not 'intersects'."""
|
||||
rep = {"x.com.samsung.da.supportedModes": ["Ventilation", "Cool", "Heat"]}
|
||||
assert _is_ventilation_mode_device(rep, {}) is False
|
||||
|
||||
|
||||
def _bind(capability, href, rep):
|
||||
resources = {href: rep}
|
||||
bound = discover(resources, {href: [capability]}, [])
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_windfree_and_windsleep_read_their_own_hrefs():
|
||||
"""PR #316's quoted rep shape: {'x.com.samsung.da.windfree': 'On'/'Off',
|
||||
'x.com.samsung.da.displaycondition': 'normal'} (displaycondition is a
|
||||
read-only UI-visibility flag, deliberately not modeled)."""
|
||||
windfree_state = _bind(
|
||||
WINDFREE,
|
||||
"/modeoption/windfree/vs/0",
|
||||
{"x.com.samsung.da.windfree": "On", "x.com.samsung.da.displaycondition": "normal"},
|
||||
)
|
||||
assert windfree_state["windfree"] is True
|
||||
|
||||
windsleep_state = _bind(
|
||||
WINDSLEEP,
|
||||
"/modeoption/windsleep/vs/0",
|
||||
{"x.com.samsung.da.windsleep": "Off", "x.com.samsung.da.displaycondition": "normal"},
|
||||
)
|
||||
assert windsleep_state["windsleep"] is False
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for the TP2X_DA-KS-COOKTOP-000001 gas cooktop (issue #314).
|
||||
|
||||
The reporter's actual complaint (six advertised burner slots when only
|
||||
three are physically present) is expected: the registry declares a
|
||||
generous static superset of slots and gates each on the live options array
|
||||
per cooktop.py's own comment; there's no per-device signal that
|
||||
distinguishes an advertised-but-nonexistent slot from a real one, so the
|
||||
extra entities are disabled by the user, not filtered by the integration.
|
||||
This file locks in the actual gap from that issue -- /alarms/vs/0 and
|
||||
/kidslock/vs/0 -- now bound via common.ALARMS/KIDS_LOCK_VS_FALLBACK.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _load_device("gas_cooktop_tp2x_ks")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_resolves_to_gas_cooktop_registry():
|
||||
resources = _load_device("gas_cooktop_tp2x_ks")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None and reg.name == "gas_cooktop"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
_, resources = _bound()
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_alarm_code_and_child_lock_bound():
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["alarm_code"] == "CT_E"
|
||||
# x.com.samsung.da.kidsLock == "Ready" reads as unlocked, same polarity
|
||||
# as every other board KIDS_LOCK_VS_FALLBACK covers.
|
||||
assert state["child_lock"] is True
|
||||
|
||||
|
||||
# The reporter's original complaint -- six advertised burner slots when
|
||||
# only three are physically present -- isn't tested here beyond what the
|
||||
# golden regression test already locks in (burner_0_state..burner_5_state
|
||||
# all present). It's expected, not a bug: the registry declares a generous
|
||||
# static superset of slots per cooktop.py's own comment, and this board's
|
||||
# /mode/vs/0 rep carries no field distinguishing a real slot from an
|
||||
# advertised-but-nonexistent one for a test to assert against -- the user
|
||||
# disabling the three extra entities is the correct fix, not something the
|
||||
# integration can filter automatically.
|
||||
@@ -6,12 +6,12 @@ import pytest
|
||||
GOLDEN = Path(__file__).parent / "fixtures" / "golden"
|
||||
|
||||
|
||||
def _new_state_keys(name, resources):
|
||||
def _new_state_keys(name, resources, device_types=()):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
reg = resolve(resources)
|
||||
reg = resolve(resources, device_types=device_types)
|
||||
if reg is None:
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
|
||||
@@ -134,6 +134,49 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_ailp_fac():
|
||||
"""AILP_DA-AC-FAC-02011_0000 (issue #319) resolves both ways --
|
||||
modelNum's 'FAC' board token and /oic/d's oic.d.airconditioner type
|
||||
independently agree on the airconditioner registry. Passes
|
||||
device_types through resolve() to exercise that agreement, not because
|
||||
the board token is absent."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("airconditioner_ailp_fac")
|
||||
golden = json.loads((GOLDEN / "airconditioner_ailp_fac.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"airconditioner_ailp_fac", resources, device_types=("oic.wk.d", "oic.d.airconditioner")
|
||||
)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_oven_tp2x_ks_walloven():
|
||||
"""TP2X_DA-KS-WALLOVEN-000002 (issue #300), a steam-oven-class board
|
||||
(water tank, descale, pyro-free) quite unlike the NV7000BS-class board
|
||||
oven.py's write comments were written against. /diagnosis/vs/0 was the
|
||||
dump's only unbound href, now covered via dishwasher.DIAGNOSIS. The
|
||||
board's options[] also has no UpperLamp_ token at all -- confirms
|
||||
LAMP's new exists_fn keeps it from registering a phantom switch here,
|
||||
same fastpreheat/NaturalSteam-class gap issue #183 fixed on its
|
||||
siblings."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("oven_tp2x_ks_walloven")
|
||||
golden = json.loads((GOLDEN / "oven_tp2x_ks_walloven.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"oven_tp2x_ks_walloven", resources, device_types=("oic.wk.d", "oic.d.oven")
|
||||
)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_dehumidifier():
|
||||
"""TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_
|
||||
board family with the room-AC models but carries the '_DHM_' token;
|
||||
@@ -213,6 +256,25 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_gas_cooktop_tp2x_ks():
|
||||
"""TP2X_DA-KS-COOKTOP-000001 (issue #314) -- routes via
|
||||
for_device_by_resources' DeviceType_/OperationState signature, same as
|
||||
the original cooktop fixture. /alarms/vs/0 and /kidslock/vs/0 were the
|
||||
dump's only unbound hrefs; both are common.UNIVERSAL shapes this
|
||||
registry now picks up individually (common.ALARMS,
|
||||
common.KIDS_LOCK_VS_FALLBACK)."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("gas_cooktop_tp2x_ks")
|
||||
golden = json.loads((GOLDEN / "gas_cooktop_tp2x_ks.json").read_text())
|
||||
state_keys = _new_state_keys("gas_cooktop_tp2x_ks", resources)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_cooktop():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -259,6 +321,24 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_airfilter():
|
||||
"""TP1X_REF_21K, air-filter-equipped variant (issue #318) -- reports
|
||||
/filter/airdustfilter/vs/0 (internal deodorizing filter), the one
|
||||
unbound href on this dump. Unlike airconditioner.AIR_FILTER's
|
||||
filterUsage/filterCapacity pair, this board's filterUsage is already a
|
||||
0-100 percentage with no filterCapacity to divide by."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("refrigerator_tp1x_ref_21k_airfilter")
|
||||
golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_airfilter.json").read_text())
|
||||
state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_airfilter", resources)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_range_hood():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -1095,10 +1175,9 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
|
||||
0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never
|
||||
been added to the modelNum board-token table. Resolved via the new 'CAC'
|
||||
token onto the existing airconditioner registry. Not fully covered yet --
|
||||
ten hrefs remain unbound (edge lighting, PM1 filter, stateful light,
|
||||
absence-clean, four sound-settings resources, smart-sensing-cooling, UV
|
||||
LED), all genuinely new to this board generation and out of scope for
|
||||
the routing fix; see test_airconditioner_cac.py for the documented gap."""
|
||||
two hrefs remain unbound (sound-optimization, smart-sensing-cooling),
|
||||
both genuinely new to this board generation and out of scope for the
|
||||
routing fix; see test_airconditioner_cac.py for the documented gap."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("airconditioner_cac")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for the TP2X_DA-KS-WALLOVEN-000002 steam oven (issue #300).
|
||||
|
||||
The reporter's "not fully supported" notification was /diagnosis/vs/0,
|
||||
now covered via dishwasher.DIAGNOSIS. Their "doesn't respond to commands"
|
||||
complaint is broader: oven.py's own module docstring already documents
|
||||
setpoint/cook-time/mode writes as unproven on this local-OCF firmware, and
|
||||
this board's options[] carries no UpperLamp_ token at all -- confirming
|
||||
`lamp` was previously a phantom, write-does-nothing switch here (same gap
|
||||
issue #183 fixed for fast_preheat/natural_steam on a different board).
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
_DEVICE_TYPES = ("oic.wk.d", "oic.d.oven")
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _load_device("oven_tp2x_ks_walloven")
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_oic_device_type_resolves_to_oven_registry():
|
||||
reg = for_device_by_oic_type(_DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "oven"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
resources = _load_device("oven_tp2x_ks_walloven")
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_lamp_not_bound_without_an_upperlamp_token():
|
||||
"""This board's options[] has no UpperLamp_ entry -- confirms the new
|
||||
exists_fn keeps `lamp` from registering a switch that would always
|
||||
read Off and never actually do anything on write."""
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert "lamp" not in state
|
||||
|
||||
|
||||
def test_sound_and_energy_saving_bound_from_real_options_tokens():
|
||||
"""Unlike lamp, this board's options[] does carry Sound_On and
|
||||
EnergySaving_On -- these switches should reflect real state."""
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["sound"] is True
|
||||
assert state["energy_saving"] is True
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for the TP1X_REF_21K air-filter-equipped variant (issue #318).
|
||||
|
||||
The dump's only unbound href was /filter/airdustfilter/vs/0, this board's
|
||||
internal deodorizing filter -- same filterUsage/filterStatus field pair as
|
||||
common.WATER_FILTER, but filterUsage here is already a 0-100 percentage
|
||||
(no filterCapacity to divide by, unlike airconditioner.AIR_FILTER).
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _load_device("refrigerator_tp1x_ref_21k_airfilter")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
_, resources = _bound()
|
||||
unbound = []
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_air_filter_usage_and_status():
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["air_filter_usage"] == 100
|
||||
assert state["air_filter_status"] == "wash"
|
||||
@@ -0,0 +1,636 @@
|
||||
"""Tests for the write_resource/read_resource services (issue #300) and the
|
||||
options-flow debug panel now that it goes through write_resource instead of
|
||||
calling coordinator.async_raw_write directly (config_flow.py). See
|
||||
tests/test_coordinator_raw_write.py for the underlying single-write
|
||||
primitive's own session/validation tests -- these focus on the service
|
||||
layer: device target resolution, subdevice href translation, ordered
|
||||
multi-write sequencing, settle/verify_after, and the options-flow rewiring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.config_flow import LocalThingsOptionsFlow
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
from custom_components.localthings.services import async_setup_services
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: "10.0.0.199",
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
|
||||
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
_SLEEP_TARGET = "custom_components.localthings.coordinator.asyncio.sleep"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Stand-in for DtlsCoapSession: records every POST verbatim and answers
|
||||
GET from a per-href queue of canned representations, so a test can model
|
||||
a value that changes across successive reads of the same href (the
|
||||
write's own follow-up GET vs. a later verify_after re-read) -- no real
|
||||
DTLS/network involved. Modeled on test_coordinator_raw_write.py's
|
||||
_FakeRawWriteSession, extended for multi-step sequences."""
|
||||
|
||||
def __init__(self, post_code: int = 0x44):
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
self.get_calls: list[list[str]] = []
|
||||
self._post_code = post_code
|
||||
self._get_reps: dict[str, list[dict]] = {}
|
||||
|
||||
def queue_get(self, href: str, rep: dict) -> None:
|
||||
"""Queue one more canned rep for `href`'s next GET. Once an href's
|
||||
queue is down to one entry, that entry keeps answering every
|
||||
further GET -- a test only needs to queue the values that
|
||||
actually change across calls."""
|
||||
self._get_reps.setdefault(href.strip("/"), []).append(rep)
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
self.post_calls.append((list(path_segs), payload))
|
||||
return self._post_code, b""
|
||||
|
||||
def get(self, path_segs, timeout=None):
|
||||
self.get_calls.append(list(path_segs))
|
||||
key = "/".join(path_segs)
|
||||
queue = self._get_reps.get(key)
|
||||
if not queue:
|
||||
return 0x45, cbor2.dumps({})
|
||||
rep = queue.pop(0) if len(queue) > 1 else queue[0]
|
||||
return 0x45, cbor2.dumps(rep)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id="localthings_SERVICES-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
# async_raw_write_sequence kicks a refresh after the write; a real
|
||||
# refresh would try to poll a session that doesn't exist for this unit
|
||||
# test, so replace it with a no-op the same way
|
||||
# test_coordinator_raw_write.py does.
|
||||
coord.async_request_refresh = AsyncMock()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coord
|
||||
async_setup_services(hass)
|
||||
return coord
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_id(hass: HomeAssistant, coordinator: LocalThingsCoordinator) -> str:
|
||||
"""Register MAIN's HA device under the same identifiers a real config
|
||||
entry setup would have used (coordinator.device_info)."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info["identifiers"],
|
||||
)
|
||||
return device.id
|
||||
|
||||
|
||||
async def _call_write(hass: HomeAssistant, device: str, **data):
|
||||
return await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
data,
|
||||
target={"device_id": device},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def _call_read(hass: HomeAssistant, device: str, **data):
|
||||
return await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
data,
|
||||
target={"device_id": device},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# write_resource: ordered multi-write sequencing
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_posts_in_order_with_exact_bodies(hass, coordinator, device_id):
|
||||
fake = _FakeSession(post_code=0x44)
|
||||
coordinator._session = fake
|
||||
|
||||
writes = [
|
||||
{"href": "/mode/vs/0", "payload": {"a": 1}},
|
||||
{"href": "/washer/vs/0", "payload": {"b": 2}},
|
||||
{"href": "/mode/vs/0", "payload": {"a": 3}},
|
||||
]
|
||||
response = await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
assert [path for path, _ in fake.post_calls] == [
|
||||
["mode", "vs", "0"],
|
||||
["washer", "vs", "0"],
|
||||
["mode", "vs", "0"],
|
||||
]
|
||||
assert [cbor2.loads(body) for _, body in fake.post_calls] == [
|
||||
{"a": 1},
|
||||
{"b": 2},
|
||||
{"a": 3},
|
||||
]
|
||||
assert response is not None
|
||||
assert response["device_id"] == device_id
|
||||
assert len(response["results"]) == 3
|
||||
for result, write in zip(response["results"], writes, strict=True):
|
||||
assert result["href"] == write["href"]
|
||||
assert result["actual_href"] == write["href"] # MAIN -> identity transform
|
||||
assert result["raw_code"] == 0x44
|
||||
assert result["code"] == "2.04"
|
||||
assert result["accepted"] is True
|
||||
coordinator.async_request_refresh.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_write_resource_settle_honored_between_writes(hass, coordinator, device_id):
|
||||
"""settle waits *before the next write*, not after the last one --
|
||||
asserted on the recorded durations rather than a real sleep."""
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
writes = [
|
||||
{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 2},
|
||||
{"href": "/b/vs/0", "payload": {"x": 2}, "settle": 5},
|
||||
{"href": "/c/vs/0", "payload": {"x": 3}, "settle": 9},
|
||||
]
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock) as mock_sleep:
|
||||
await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
assert [call.args[0] for call in mock_sleep.call_args_list] == [2.0, 5.0]
|
||||
|
||||
|
||||
async def test_write_resource_holds_the_session_across_settle_by_default(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""The default keeps the session for the whole sequence so nothing lands
|
||||
between two writes -- asserted on the lock's real state during the wait,
|
||||
not merely on the flag being accepted."""
|
||||
coordinator._session = _FakeSession()
|
||||
locked_during_settle = []
|
||||
|
||||
async def _record(_delay):
|
||||
locked_during_settle.append(coordinator._session_lock.locked())
|
||||
|
||||
with patch(_SLEEP_TARGET, new=_record):
|
||||
await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[
|
||||
{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 2},
|
||||
{"href": "/b/vs/0", "payload": {"x": 2}},
|
||||
],
|
||||
)
|
||||
|
||||
assert locked_during_settle == [True]
|
||||
# ...and it's handed back afterward, rather than leaked to the next call.
|
||||
assert not coordinator._session_lock.locked()
|
||||
|
||||
|
||||
async def test_write_resource_releases_the_session_across_settle_when_asked(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""hold_session_lock=False frees the session across the waits, so polls
|
||||
and entity writes keep working through a long sequence."""
|
||||
coordinator._session = _FakeSession()
|
||||
locked_during_settle = []
|
||||
|
||||
async def _record(_delay):
|
||||
locked_during_settle.append(coordinator._session_lock.locked())
|
||||
|
||||
with patch(_SLEEP_TARGET, new=_record):
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[
|
||||
{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 2},
|
||||
{"href": "/b/vs/0", "payload": {"x": 2}},
|
||||
],
|
||||
hold_session_lock=False,
|
||||
)
|
||||
|
||||
assert locked_during_settle == [False]
|
||||
assert not coordinator._session_lock.locked()
|
||||
# Both writes still go out, in order -- only the locking differs.
|
||||
assert [r["href"] for r in response["results"]] == ["/a/vs/0", "/b/vs/0"]
|
||||
|
||||
|
||||
async def test_write_resource_changed_true_when_readback_matches_payload(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}]
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is True
|
||||
|
||||
|
||||
async def test_write_resource_changed_false_when_readback_differs(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "unchanged"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}]
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# verify_after: the delayed re-read this feature exists for (issue #300)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_reports_held(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # write's own follow-up read
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # verify_after re-read: held
|
||||
coordinator._session = fake
|
||||
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock) as mock_sleep:
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
|
||||
verify_after=30,
|
||||
)
|
||||
|
||||
assert 30.0 in [call.args[0] for call in mock_sleep.call_args_list]
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is True
|
||||
assert verified["rep"] == {"x.field": "target"}
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_reports_reverted(hass, coordinator, device_id):
|
||||
"""Issue #300's own symptom: a Samsung wall oven board answers 2.04
|
||||
Changed to a settings write while idle and then silently reverts it
|
||||
once the follow-up read has already come back clean. verify_after's
|
||||
whole purpose is catching exactly this -- `changed` (immediate) and
|
||||
`held` (delayed) must be able to disagree."""
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # looks accepted right after the write
|
||||
fake.queue_get("mode/vs/0", {"x.field": "original"}) # reverted by the time verify_after fires
|
||||
coordinator._session = fake
|
||||
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock):
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
|
||||
verify_after=30,
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is True
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is False
|
||||
assert verified["rep"] == {"x.field": "original"}
|
||||
|
||||
|
||||
async def test_write_resource_no_verified_key_when_verify_after_is_zero(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert "verified" not in response
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Device target resolution
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_zero_devices_rejected(hass, coordinator):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": "/a/vs/0", "payload": {"x": 1}}]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_two_devices_rejected(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": "/a/vs/0", "payload": {"x": 1}}]},
|
||||
target={"device_id": [device_id, "some-other-unrelated-device-id"]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_unresolvable_device_rejected(hass, coordinator):
|
||||
"""The device exists in the registry but no loaded coordinator claims
|
||||
it -- e.g. a device belonging to a different integration entirely."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
other_entry = MockConfigEntry(domain="other_domain")
|
||||
other_entry.add_to_hass(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=other_entry.entry_id,
|
||||
identifiers={("other_domain", "unrelated")},
|
||||
)
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device.id, writes=[{"href": "/a/vs/0", "payload": {"x": 1}}])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Subdevice href translation (issue #177)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_translates_href_for_indexed_subdevice(hass, coordinator):
|
||||
sub = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator.subdevices = [sub]
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info_for(sub)["identifiers"],
|
||||
)
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device.id, writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert fake.post_calls[0][0] == ["mode", "vs", "1"]
|
||||
result = response["results"][0]
|
||||
assert result["href"] == "/mode/vs/0"
|
||||
assert result["actual_href"] == "/mode/vs/1"
|
||||
|
||||
|
||||
async def test_write_resource_normalizes_href_before_subdevice_translation(hass, coordinator):
|
||||
"""A trailing slash must not silently retarget the write at the master.
|
||||
|
||||
`Subdevice.to_actual` is a textual transform that rewrites only a
|
||||
trailing '0' segment, so '/mode/vs/0/' passes through it unchanged and
|
||||
then normalizes downstream to the master's '/mode/vs/0' -- landing on
|
||||
the wrong cavity while still answering 2.04. Nothing in the response
|
||||
would have given that away.
|
||||
"""
|
||||
sub = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator.subdevices = [sub]
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info_for(sub)["identifiers"],
|
||||
)
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device.id, writes=[{"href": "/mode/vs/0/", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert fake.post_calls[0][0] == ["mode", "vs", "1"]
|
||||
result = response["results"][0]
|
||||
assert result["href"] == "/mode/vs/0"
|
||||
assert result["actual_href"] == "/mode/vs/1"
|
||||
|
||||
|
||||
async def test_write_resource_verified_is_keyed_by_canonical_href_for_subdevice(hass, coordinator):
|
||||
"""`verified` promises canonical keys; the coordinator reports actual
|
||||
ones, so the translation back has to line up with what was sent."""
|
||||
sub = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator.subdevices = [sub]
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info_for(sub)["identifiers"],
|
||||
)
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("/mode/vs/1", {"x": 1})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device.id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}],
|
||||
verify_after=1,
|
||||
)
|
||||
|
||||
assert list(response["verified"]) == ["/mode/vs/0"]
|
||||
assert response["verified"]["/mode/vs/0"]["held"] is True
|
||||
|
||||
|
||||
async def test_write_resource_failure_midway_names_the_writes_that_landed(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""A drop partway through leaves the appliance holding a partial
|
||||
sequence; the error has to say how far it got, or the operator can't
|
||||
tell what state the device is in without starting over blind."""
|
||||
|
||||
class _DropsOnSecondWrite(_FakeSession):
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
if len(self.post_calls) == 1:
|
||||
raise OSError("session dropped")
|
||||
return super().post(path_segs, payload, timeout)
|
||||
|
||||
coordinator._session = _DropsOnSecondWrite()
|
||||
|
||||
with pytest.raises(HomeAssistantError) as excinfo:
|
||||
await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[
|
||||
{"href": "/mode/vs/0", "payload": {"a": 1}},
|
||||
{"href": "/temperatures/vs/0", "payload": {"b": 2}},
|
||||
{"href": "/operational/state/vs/0", "payload": {"c": 3}},
|
||||
],
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "1 of 3" in message
|
||||
assert "/mode/vs/0" in message
|
||||
# Entities still get pulled back in line with whatever did land.
|
||||
coordinator.async_request_refresh.assert_awaited()
|
||||
|
||||
|
||||
async def test_write_resource_held_is_none_when_the_verify_read_fails(hass, coordinator, device_id):
|
||||
"""A re-read that doesn't come back is not evidence of a revert.
|
||||
|
||||
_raw_read_blocking answers a non-2.05 with an empty rep, and every
|
||||
payload comparison against {} is False -- so without this, a 4.04 or a
|
||||
dropped verify read is reported as `held: false`, indistinguishable
|
||||
from the board quietly putting the old value back. That distinction is
|
||||
the whole reason verify_after exists (issue #300).
|
||||
"""
|
||||
|
||||
class _FailingVerifyRead(_FakeSession):
|
||||
def get(self, path_segs, timeout=None):
|
||||
self.get_calls.append(list(path_segs))
|
||||
# The write's own follow-up GET succeeds; the later verify
|
||||
# re-read of the same href is the one that fails.
|
||||
if len(self.get_calls) == 1:
|
||||
return 0x45, cbor2.dumps({"x": 1})
|
||||
return 0x84, b""
|
||||
|
||||
coordinator._session = _FailingVerifyRead()
|
||||
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}],
|
||||
verify_after=1,
|
||||
)
|
||||
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is None
|
||||
assert verified["code"] == "4.04"
|
||||
# The immediate readback still saw the value land -- only the delayed
|
||||
# confirmation is unknown, and the two must not be conflated.
|
||||
assert response["results"][0]["changed"] is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Validation caps
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_rejects_more_than_ten_writes(hass, coordinator, device_id):
|
||||
writes = [{"href": f"/x/vs/{i}", "payload": {"a": i}} for i in range(11)]
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_settle_above_thirty_seconds(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 31}],
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_empty_payload(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=[{"href": "/a/vs/0", "payload": {}}])
|
||||
|
||||
|
||||
async def test_write_resource_rejects_non_dict_payload(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(
|
||||
hass, device_id, writes=[{"href": "/a/vs/0", "payload": ["not", "a", "dict"]}]
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_empty_href(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=[{"href": "", "payload": {"x": 1}}])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# read_resource
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_read_resource_with_href_does_live_get(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "live"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_read(hass, device_id, href="/mode/vs/0")
|
||||
|
||||
assert fake.get_calls == [["mode", "vs", "0"]]
|
||||
assert response["href"] == "/mode/vs/0"
|
||||
assert response["actual_href"] == "/mode/vs/0"
|
||||
assert response["rep"] == {"x.field": "live"}
|
||||
|
||||
|
||||
async def test_read_resource_without_href_returns_cached_snapshot_and_does_not_get(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
# Seed the cache the way a poll would -- the fake session's `get` is
|
||||
# never touched by this test.
|
||||
coordinator._observe.apply("/mode/vs/0", {"x.field": "cached"}, source="poll")
|
||||
|
||||
response = await _call_read(hass, device_id)
|
||||
|
||||
assert fake.get_calls == []
|
||||
assert response["resources"]["/mode/vs/0"] == {"x.field": "cached"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Options-flow debug panel, rewired onto write_resource (issue #300)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_options_flow_debug_write_goes_through_write_resource_service(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""Drives LocalThingsOptionsFlow's real step methods directly (rather
|
||||
than through hass.config_entries.options.async_init/async_configure,
|
||||
which needs the integration resolvable through HA's loader -- not
|
||||
exercised by any test in this repo and orthogonal to what this test is
|
||||
actually checking): the panel's async_step_debug_edit must call the
|
||||
write_resource service and get back a result the rest of the flow can
|
||||
render, not coord.async_raw_write directly."""
|
||||
fake = _FakeSession(post_code=0x44)
|
||||
fake.queue_get("course/vs/0", {"x.field": "after"})
|
||||
coordinator._session = fake
|
||||
|
||||
flow = LocalThingsOptionsFlow()
|
||||
flow.hass = hass
|
||||
flow.handler = coordinator._entry.entry_id
|
||||
|
||||
write_result = await flow.async_step_debug_write({"href": "/course/vs/0"})
|
||||
assert write_result["step_id"] == "debug_edit"
|
||||
|
||||
edit_result = await flow.async_step_debug_edit({"payload": {"x.com.samsung.da.field": "value"}})
|
||||
assert edit_result["step_id"] == "debug_result"
|
||||
placeholders = edit_result["description_placeholders"]
|
||||
assert placeholders is not None
|
||||
assert placeholders["code"] == "2.04 (0x44)"
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_body = fake.post_calls[0]
|
||||
assert posted_path == ["course", "vs", "0"]
|
||||
assert cbor2.loads(posted_body) == {"x.com.samsung.da.field": "value"}
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_body = fake.post_calls[0]
|
||||
assert posted_path == ["course", "vs", "0"]
|
||||
assert cbor2.loads(posted_body) == {"x.com.samsung.da.field": "value"}
|
||||
Reference in New Issue
Block a user