Add write_resource/read_resource services for probing write contracts (issue #300)
The options-flow "Debug write" panel could only ever do one write to one href per pass -- not enough for the issue #300 wall oven, whose board discards settings writes while idle and only keeps them once a cycle is already running. Finding what starts a cycle needs an ordered sequence of writes across resources, with real settle delays between them, and a way to check afterward whether anything actually held. - coordinator.py: async_raw_write_sequence owns a whole ordered sequence under one _session_lock hold (so a poll can't interleave mid-sequence), with per-step settle and an optional delayed verify_after re-read done outside the lock. async_raw_write is now a one-item wrapper over it, so tests/test_coordinator_raw_write.py keeps passing unmodified. Also adds async_raw_read, a live GET bypassing the cache -- staleness is exactly what makes revert-testing unreliable. - services.py (new): the two HA services. Device-target resolution scans loaded coordinators' MAIN/subdevice identifiers and requires exactly one match, so an area/label target can't silently fan a raw write out across several appliances. Canonical->actual href translation happens here, not in the coordinator, which stays subdevice-agnostic. - services.yaml (new): selectors/descriptions for both services, inline per HA's custom-integration support -- keeps translations/en.json's mirror test (test_translations.py) green without touching all 6 languages for a services block. New exception keys (write caps, device target resolution) still went into translations/*.json's existing exceptions section, mirrored across all 6 languages. - __init__.py: adds async_setup to register the services once, process-wide. - config_flow.py: the debug panel's async_step_debug_edit now calls write_resource instead of coord.async_raw_write directly, so there is exactly one code path that performs a raw write. - README.md: new Part 5 documenting both services, with a worked write_resource example; points the capability-gap section at them. tests/test_services.py (new): sequencing/ordering, settle timing, changed vs. held (the reverted case is issue #300's own symptom), exactly-one- device resolution, subdevice href translation, validation caps, and the options-flow panel end to end through the service.
This commit is contained in:
@@ -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 target a device (`target: device:`, filtered to `integration: localthings` in the picker) and resolve to exactly one appliance — targeting an area or label 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
|
||||
target:
|
||||
device_id: abc123...
|
||||
data:
|
||||
writes:
|
||||
- href: /course/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.course: "01"
|
||||
settle: 3
|
||||
- href: /mode/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.mode: Bake
|
||||
settle: 5
|
||||
- href: /power/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.power: "On"
|
||||
verify_after: 30
|
||||
```
|
||||
|
||||
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. The whole sequence runs under a single lock, so a routine poll can't land in the middle of it and blur which write is responsible for what the device does next.
|
||||
|
||||
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": "/course/vs/0", "actual_href": "/course/vs/0", "code": "2.04", "raw_code": 68,
|
||||
"accepted": true, "before": {...}, "after": {...}, "changed": true},
|
||||
...
|
||||
],
|
||||
"verified": {
|
||||
"/power/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.
|
||||
|
||||
`read_resource` is the read half, and it's deliberately not just a cache lookup:
|
||||
|
||||
```yaml
|
||||
action: localthings.read_resource
|
||||
target:
|
||||
device_id: abc123...
|
||||
data:
|
||||
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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -925,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"
|
||||
|
||||
@@ -100,6 +100,60 @@ 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 _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."""
|
||||
|
||||
@@ -1119,10 +1173,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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]:
|
||||
@@ -1148,17 +1204,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,
|
||||
@@ -1166,10 +1239,110 @@ 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
|
||||
) -> 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. This owns the whole sequence -- every write shares one
|
||||
`_session_lock` hold, so a poll can never interleave mid-sequence
|
||||
and blur which write is responsible for what the device does next.
|
||||
|
||||
`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] = {}
|
||||
async with self._session_lock:
|
||||
for i, (path_segs, href, payload, settle) in enumerate(parsed):
|
||||
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()),
|
||||
}
|
||||
)
|
||||
# settle is "how long to wait before the next write" -- the
|
||||
# last item has no next write, so it gets no wait here;
|
||||
# verify_after (below) is the equivalent wait after the
|
||||
# sequence as a whole.
|
||||
if settle and i < len(parsed) - 1:
|
||||
await asyncio.sleep(settle)
|
||||
|
||||
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
|
||||
)
|
||||
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()
|
||||
),
|
||||
}
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""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
|
||||
from .registry.subdevices import MAIN, Subdevice
|
||||
|
||||
ATTR_HREF = "href"
|
||||
ATTR_PAYLOAD = "payload"
|
||||
ATTR_SETTLE = "settle"
|
||||
ATTR_WRITES = "writes"
|
||||
ATTR_VERIFY_AFTER = "verify_after"
|
||||
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),
|
||||
}
|
||||
)
|
||||
|
||||
_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): the coordinator's raw-write primitive has no notion of
|
||||
# subdevices, only the hrefs it's told to hit -- identity transform for
|
||||
# MAIN, so a device targeting MAIN behaves exactly as it always has.
|
||||
raw_writes = [
|
||||
{
|
||||
"href": subdevice.to_actual(w[ATTR_HREF]),
|
||||
"payload": w.get(ATTR_PAYLOAD),
|
||||
"settle": w.get(ATTR_SETTLE, 0.0),
|
||||
}
|
||||
for w in writes_in
|
||||
]
|
||||
sequence = await coordinator.async_raw_write_sequence(
|
||||
raw_writes, verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0)
|
||||
)
|
||||
|
||||
results = [
|
||||
{
|
||||
"href": original[ATTR_HREF],
|
||||
"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 original, result in zip(writes_in, sequence["results"], strict=True)
|
||||
]
|
||||
response: dict[str, Any] = {"device_id": device_id, "results": results}
|
||||
if "verified" in sequence:
|
||||
canonical_by_actual = {subdevice.to_actual(w[ATTR_HREF]): w[ATTR_HREF] for w in writes_in}
|
||||
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)
|
||||
|
||||
actual_href = subdevice.to_actual(href)
|
||||
code, rep = await coordinator.async_raw_read(actual_href)
|
||||
read_result: dict[str, Any] = {
|
||||
"href": href,
|
||||
"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,67 @@
|
||||
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.
|
||||
target:
|
||||
device:
|
||||
integration: localthings
|
||||
fields:
|
||||
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.mode":
|
||||
"Bake"}, "settle": 3}]
|
||||
selector:
|
||||
object:
|
||||
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.
|
||||
target:
|
||||
device:
|
||||
integration: localthings
|
||||
fields:
|
||||
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:
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,24 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"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": "이 기기는 아직 연결되지 않았습니다. 로드된 후 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,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)
|
||||
@@ -1056,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"],
|
||||
@@ -1123,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"],
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""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 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_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"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 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