diff --git a/README.md b/README.md index 8d7b1e1..54ea6be 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index c893622..0fa67f8 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -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. diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index ae7faa7..e88f1a5 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index b2b876a..a23b047 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -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" diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 45832de..9c1a09e 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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"] diff --git a/custom_components/localthings/services.py b/custom_components/localthings/services.py new file mode 100644 index 0000000..968324f --- /dev/null +++ b/custom_components/localthings/services.py @@ -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, + ) diff --git a/custom_components/localthings/services.yaml b/custom_components/localthings/services.yaml new file mode 100644 index 0000000..5fe0cb5 --- /dev/null +++ b/custom_components/localthings/services.yaml @@ -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: diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 3aa10d4..6c059c5 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -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." } } } diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index a935f0f..490746a 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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." } } } diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 86afd5f..f9129c6 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -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": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 28289a1..7a13971 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -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." } } } diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 2fdc93f..ae2ab7b 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -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": "이 기기는 아직 연결되지 않았습니다. 로드된 후 다시 시도하세요." } } } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index ed23a30..d335ab3 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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." } } } diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 55ddad2..ad265d4 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -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"], diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..4f63d9c --- /dev/null +++ b/tests/test_services.py @@ -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"}