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:
Marc Billow
2026-08-07 22:08:28 +00:00
parent 9228da1d9c
commit 5dbe990c1d
15 changed files with 1180 additions and 27 deletions
+71
View File
@@ -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.
+28 -1
View File
@@ -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)
+6
View File
@@ -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"
+192 -19
View File
@@ -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"]
+209
View File
@@ -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."
}
}
}
+32 -7
View File
@@ -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"],
+458
View File
@@ -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"}