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:
15 files changed
+1180
-27
No files matched your search
@@ -10,14 +10,23 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.identity import resolve_serial
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# Services are process-global, registered once here rather than per
|
||||
# config entry (issue #300) -- see services.async_setup_services.
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
def _serial_from_unique_id(entry: ConfigEntry) -> str:
|
||||
"""The device identity a pre-v2 entry was created with.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
@@ -55,6 +56,7 @@ from .const import (
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
PROBE_MAX_WORKERS,
|
||||
PROBE_PORT_RANGE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
@@ -925,8 +927,33 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"payload": "empty_payload"}, payload
|
||||
)
|
||||
# Goes through the write_resource service (issue #300), not
|
||||
# coord.async_raw_write directly, so there is exactly one code
|
||||
# path that performs a raw write. MAIN's own device -- the
|
||||
# panel's href dropdown already lists actual hrefs off
|
||||
# coord.last_resources, and MAIN.to_actual is identity, so
|
||||
# this preserves the panel's existing behavior byte for byte.
|
||||
dev = dr.async_get(self.hass).async_get_device(
|
||||
identifiers=coord.device_info["identifiers"]
|
||||
)
|
||||
if dev is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
try:
|
||||
code, new_rep = await coord.async_raw_write(href, payload)
|
||||
response = await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": href, "payload": payload}]},
|
||||
target={"device_id": dev.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
results = (response or {}).get("results")
|
||||
first = results[0] if isinstance(results, list) and results else None
|
||||
raw_code = first.get("raw_code") if isinstance(first, dict) else None
|
||||
after = first.get("after") if isinstance(first, dict) else None
|
||||
if not isinstance(raw_code, int) or not isinstance(after, dict):
|
||||
raise RuntimeError("write_resource service returned an unexpected shape")
|
||||
code, new_rep = raw_code, after
|
||||
except Exception:
|
||||
_LOGGER.exception("debug raw write failed for %s", href)
|
||||
return self._show_debug_edit_form(href, current, {"base": "write_failed"}, payload)
|
||||
|
||||
@@ -96,3 +96,9 @@ SUMMARY_INTERVAL_S = 30.0
|
||||
DEVICE_SUPPORT_ISSUE_URL = (
|
||||
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
|
||||
)
|
||||
|
||||
# Service names (services.py), shared with config_flow.py so the
|
||||
# options-flow debug panel calls the exact same service a user could call
|
||||
# from an automation (issue #300) -- one code path performs a raw write.
|
||||
SERVICE_WRITE_RESOURCE = "write_resource"
|
||||
SERVICE_READ_RESOURCE = "read_resource"
|
||||
@@ -100,6 +100,60 @@ def _local_source_port(host: str) -> int:
|
||||
return DTLS_LOCAL_PORT_BASE + offset
|
||||
|
||||
|
||||
# Debug raw write/read caps (issue #300) -- generous enough for a real
|
||||
# probing session (the wall-oven reporter's own sequences run well under
|
||||
# 10 steps) while bounding how long one service call can hold up polling.
|
||||
_DEBUG_MAX_WRITES = 10
|
||||
_DEBUG_MAX_SETTLE_S = 30.0
|
||||
_DEBUG_MAX_VERIFY_AFTER_S = 60.0
|
||||
|
||||
|
||||
def _href_to_path_segs(href: str) -> list[str]:
|
||||
"""'/mode/vs/0' -> ['mode', 'vs', '0'], the shape `sess.get`/`sess.post`
|
||||
take. Shared by every raw debug read/write path."""
|
||||
return [s for s in str(href).strip("/").split("/") if s]
|
||||
|
||||
|
||||
def _coap_code_str(code: int) -> str:
|
||||
"""Raw CoAP response code -> its 'C.DD' rendering (e.g. 0x44 -> '2.04'),
|
||||
the class/detail split RFC 7252 §12.1.2 defines. `raw_code` is kept
|
||||
alongside it in every debug response so a caller can format it
|
||||
differently."""
|
||||
return f"{code >> 5}.{code & 0x1F:02d}"
|
||||
|
||||
|
||||
def _coap_accepted(code: int) -> bool:
|
||||
"""True for a 2.xx class CoAP response."""
|
||||
return (code >> 5) == 2
|
||||
|
||||
|
||||
def _validate_debug_write_item(item: dict) -> tuple[list[str], str, dict, float]:
|
||||
"""The checks `async_raw_write` has always applied to a single write
|
||||
(issue #54), reused per-item by `async_raw_write_sequence` (issue
|
||||
#300). Payload is checked before href, matching the original
|
||||
single-write order -- not load-bearing for any test, just avoiding a
|
||||
silent behavior change in the refactor."""
|
||||
payload = item.get("payload")
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_payload_empty",
|
||||
)
|
||||
path_segs = _href_to_path_segs(item.get("href", ""))
|
||||
if not path_segs:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="resource_href_required",
|
||||
)
|
||||
settle = item.get("settle") or 0.0
|
||||
if not 0 <= settle <= _DEBUG_MAX_SETTLE_S:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_settle_out_of_range",
|
||||
)
|
||||
return path_segs, "/" + "/".join(path_segs), payload, settle
|
||||
|
||||
|
||||
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Manages one Samsung appliance: session, discovery, polling."""
|
||||
|
||||
@@ -1119,10 +1173,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
await self.async_request_refresh()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Debug raw write (issue #54): a power-user escape hatch for the
|
||||
# options-flow debug panel to POST an arbitrary partial body without a
|
||||
# new release. Deliberately bypasses the remote-control block and all
|
||||
# write_fn/validate_fn above -- use with care.
|
||||
# Debug raw write/read (issue #54, extended for issue #300): a
|
||||
# power-user escape hatch shared by the options-flow debug panel and
|
||||
# the write_resource/read_resource services (services.py) for probing
|
||||
# a device's write contract directly. Deliberately bypasses the
|
||||
# remote-control block and all write_fn/validate_fn above -- use with
|
||||
# care.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
||||
@@ -1148,17 +1204,34 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._log.debug("raw write follow-up read failed: %s", e)
|
||||
return code, new_rep
|
||||
|
||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||
"""Debug-only arbitrary write (issue #54). Bypasses remote-control
|
||||
and write_fn/validate_fn; sends `body` verbatim as a partial-rep
|
||||
PATCH to `href`. Returns (coap_code, new_rep) read back right
|
||||
after."""
|
||||
if not isinstance(body, dict) or not body:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_payload_empty",
|
||||
)
|
||||
path_segs = [s for s in href.strip("/").split("/") if s]
|
||||
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict]:
|
||||
"""Debug primitive: a live GET, deliberately bypassing the cache
|
||||
(issue #300) -- the cache can be up to a poll interval stale,
|
||||
exactly the staleness that makes testing whether a write held or
|
||||
got silently reverted by the board unreliable. Blocking -- runs in
|
||||
executor."""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
code, payload = sess.get(path_segs, timeout=10.0)
|
||||
rep: dict = {}
|
||||
if code == 0x45 and payload:
|
||||
try:
|
||||
body = cbor2.loads(payload)
|
||||
except Exception as e:
|
||||
self._log.debug("raw read decode failed for %s: %s", href, e)
|
||||
body = None
|
||||
if isinstance(body, dict):
|
||||
self._observe.apply(href, body, source="poll")
|
||||
rep = body
|
||||
return code, rep
|
||||
|
||||
async def async_raw_read(self, href: str) -> tuple[int, dict]:
|
||||
"""Debug-only live GET (issue #300, backs the read_resource
|
||||
service). Same href validation as async_raw_write."""
|
||||
path_segs = _href_to_path_segs(href)
|
||||
if not path_segs:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -1166,10 +1239,110 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
norm_href = "/" + "/".join(path_segs)
|
||||
async with self._session_lock:
|
||||
code, new_rep = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, body, norm_href
|
||||
return await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, path_segs, norm_href
|
||||
)
|
||||
|
||||
async def async_raw_write_sequence(
|
||||
self, writes: list[dict], *, verify_after: float = 0.0
|
||||
) -> dict[str, Any]:
|
||||
"""Debug-only ordered multi-write (issue #300): a Samsung wall oven
|
||||
board discards settings writes while idle and only keeps them once
|
||||
a cycle is already running, which no single-write debug pass can
|
||||
probe for. This owns the whole sequence -- every write shares one
|
||||
`_session_lock` hold, so a poll can never interleave mid-sequence
|
||||
and blur which write is responsible for what the device does next.
|
||||
|
||||
`writes` are already on-the-wire hrefs: subdevice translation
|
||||
(canonical -> actual) is services.py's job, not this method's --
|
||||
this primitive has no notion of subdevices, same as the original
|
||||
single-write async_raw_write never did.
|
||||
|
||||
`async_raw_write` below delegates here with a one-item sequence, so
|
||||
its signature/return and tests/test_coordinator_raw_write.py stay
|
||||
unchanged.
|
||||
"""
|
||||
if not writes or len(writes) > _DEBUG_MAX_WRITES:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_too_many_writes",
|
||||
)
|
||||
if not 0 <= verify_after <= _DEBUG_MAX_VERIFY_AFTER_S:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_verify_after_out_of_range",
|
||||
)
|
||||
# Validate every item before touching the session -- a rejected
|
||||
# call must fail before any write goes out (same posture the
|
||||
# single-write path has always had; see
|
||||
# test_raw_write_validation_errors_do_not_touch_the_session).
|
||||
parsed = [_validate_debug_write_item(w) for w in writes]
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
last_payload_by_href: dict[str, dict] = {}
|
||||
async with self._session_lock:
|
||||
for i, (path_segs, href, payload, settle) in enumerate(parsed):
|
||||
before = self.resource(href)
|
||||
code, after = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, payload, href
|
||||
)
|
||||
last_payload_by_href[href] = payload
|
||||
results.append(
|
||||
{
|
||||
"href": href,
|
||||
"code": _coap_code_str(code),
|
||||
"raw_code": code,
|
||||
"accepted": _coap_accepted(code),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"changed": all(after.get(k) == v for k, v in payload.items()),
|
||||
}
|
||||
)
|
||||
# settle is "how long to wait before the next write" -- the
|
||||
# last item has no next write, so it gets no wait here;
|
||||
# verify_after (below) is the equivalent wait after the
|
||||
# sequence as a whole.
|
||||
if settle and i < len(parsed) - 1:
|
||||
await asyncio.sleep(settle)
|
||||
|
||||
response: dict[str, Any] = {"results": results}
|
||||
if verify_after > 0:
|
||||
# Released, not held, across this wait: holding _session_lock
|
||||
# through up to 60s would stall the summary poll for that whole
|
||||
# window (same reasoning as _attempt_observe_mode's grace wait,
|
||||
# issue #294). A poll interleaving here is harmless -- just
|
||||
# another read of the same hrefs.
|
||||
await asyncio.sleep(verify_after)
|
||||
verified: dict[str, Any] = {}
|
||||
async with self._session_lock:
|
||||
for href in dict.fromkeys(r["href"] for r in results):
|
||||
vcode, vrep = await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, _href_to_path_segs(href), href
|
||||
)
|
||||
verified[href] = {
|
||||
"code": _coap_code_str(vcode),
|
||||
"raw_code": vcode,
|
||||
"rep": vrep,
|
||||
"held": all(
|
||||
vrep.get(k) == v for k, v in last_payload_by_href[href].items()
|
||||
),
|
||||
}
|
||||
response["verified"] = verified
|
||||
|
||||
# Hasten a summary poll so entities on other resources catch up
|
||||
# too -- a debug write can affect siblings, not just its href.
|
||||
# too -- a debug write can affect siblings, not just its href. Once
|
||||
# per sequence, not per write: the whole point of ordering writes
|
||||
# under one lock hold is to control exactly what the device sees
|
||||
# and when, which a refresh racing in mid-sequence would undermine.
|
||||
await self.async_request_refresh()
|
||||
return code, new_rep
|
||||
return response
|
||||
|
||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||
"""Debug-only arbitrary write (issue #54). Bypasses remote-control
|
||||
and write_fn/validate_fn; sends `body` verbatim as a partial-rep
|
||||
PATCH to `href`. Returns (coap_code, new_rep) read back right
|
||||
after -- a thin single-write wrapper over async_raw_write_sequence
|
||||
(issue #300)."""
|
||||
sequence = await self.async_raw_write_sequence([{"href": href, "payload": body}])
|
||||
only = sequence["results"][0]
|
||||
return only["raw_code"], only["after"]
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Home Assistant services for direct OCF resource read/write access
|
||||
(issue #300): a raw-transport escape hatch for reverse-engineering a
|
||||
device's write contract -- an ordered multi-write sequence with settle
|
||||
delays and a delayed re-read, which the single-write options-flow debug
|
||||
panel can't express. Both sit on the same coordinator primitives the panel
|
||||
now calls too (config_flow.py), so there is exactly one code path that
|
||||
performs a raw write.
|
||||
|
||||
Kept thin on purpose: session/lock ownership lives on the coordinator
|
||||
(coordinator.py). This module only resolves the service call's device
|
||||
target to a `(coordinator, subdevice)` pair, translates canonical hrefs
|
||||
through that subdevice, and shapes the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.subdevices import MAIN, Subdevice
|
||||
|
||||
ATTR_HREF = "href"
|
||||
ATTR_PAYLOAD = "payload"
|
||||
ATTR_SETTLE = "settle"
|
||||
ATTR_WRITES = "writes"
|
||||
ATTR_VERIFY_AFTER = "verify_after"
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
|
||||
_WRITE_ITEM_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_HREF): str,
|
||||
# Not `dict` here: a non-dict payload must fail the same way an
|
||||
# empty one does -- coordinator.async_raw_write_sequence's
|
||||
# ServiceValidationError -- not a raw schema vol.Invalid, so every
|
||||
# caller sees one consistent error shape regardless of which rule
|
||||
# a bad payload tripped.
|
||||
vol.Required(ATTR_PAYLOAD): object,
|
||||
vol.Optional(ATTR_SETTLE): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
# Structural validation only (types, and unwrapping a bare dict into a
|
||||
# one-item list) -- the semantic checks (non-empty payload, non-root href,
|
||||
# the 1..10/settle/verify_after ranges) live on
|
||||
# LocalThingsCoordinator.async_raw_write_sequence, so every caller gets the
|
||||
# same ServiceValidationError + translation key regardless of whether it
|
||||
# reached the primitive through this service, the options-flow panel, or a
|
||||
# future caller.
|
||||
_WRITE_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Required(ATTR_WRITES): vol.All(cv.ensure_list, [_WRITE_ITEM_SCHEMA]),
|
||||
vol.Optional(ATTR_VERIFY_AFTER): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
_READ_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Optional(ATTR_HREF): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_target(
|
||||
hass: HomeAssistant, call: ServiceCall
|
||||
) -> tuple[LocalThingsCoordinator, Subdevice, str]:
|
||||
"""The one `(coordinator, subdevice, device_id)` a service call's
|
||||
device target names.
|
||||
|
||||
Deliberately strict about count, not just presence: the `target:
|
||||
device:` selector in services.yaml still lets a user pick an area or
|
||||
label in the picker, and the frontend expands that into a `device_id`
|
||||
list before the call reaches here -- more than one entry means an
|
||||
area/label fanned this out across several appliances, which a raw
|
||||
debug write must never do silently (issue #300).
|
||||
"""
|
||||
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID) or [])
|
||||
if len(device_ids) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_target_invalid",
|
||||
)
|
||||
device_id = device_ids[0]
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get(device_id)
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_found",
|
||||
)
|
||||
|
||||
for coordinator in hass.data.get(DOMAIN, {}).values():
|
||||
if device.identifiers & coordinator.device_info.get("identifiers", set()):
|
||||
return coordinator, MAIN, device_id
|
||||
for sub in coordinator.subdevices:
|
||||
if device.identifiers & coordinator.device_info_for(sub).get("identifiers", set()):
|
||||
return coordinator, sub, device_id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_loaded",
|
||||
)
|
||||
|
||||
|
||||
async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, device_id = _resolve_target(hass, call)
|
||||
writes_in: list[dict[str, Any]] = call.data[ATTR_WRITES]
|
||||
|
||||
# Canonical -> actual translation happens here, not in the coordinator
|
||||
# (issue #177): the coordinator's raw-write primitive has no notion of
|
||||
# subdevices, only the hrefs it's told to hit -- identity transform for
|
||||
# MAIN, so a device targeting MAIN behaves exactly as it always has.
|
||||
raw_writes = [
|
||||
{
|
||||
"href": subdevice.to_actual(w[ATTR_HREF]),
|
||||
"payload": w.get(ATTR_PAYLOAD),
|
||||
"settle": w.get(ATTR_SETTLE, 0.0),
|
||||
}
|
||||
for w in writes_in
|
||||
]
|
||||
sequence = await coordinator.async_raw_write_sequence(
|
||||
raw_writes, verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0)
|
||||
)
|
||||
|
||||
results = [
|
||||
{
|
||||
"href": original[ATTR_HREF],
|
||||
"actual_href": result["href"],
|
||||
"code": result["code"],
|
||||
"raw_code": result["raw_code"],
|
||||
"accepted": result["accepted"],
|
||||
"before": result["before"],
|
||||
"after": result["after"],
|
||||
"changed": result["changed"],
|
||||
}
|
||||
for original, result in zip(writes_in, sequence["results"], strict=True)
|
||||
]
|
||||
response: dict[str, Any] = {"device_id": device_id, "results": results}
|
||||
if "verified" in sequence:
|
||||
canonical_by_actual = {subdevice.to_actual(w[ATTR_HREF]): w[ATTR_HREF] for w in writes_in}
|
||||
response["verified"] = {
|
||||
canonical_by_actual.get(actual_href, actual_href): verified
|
||||
for actual_href, verified in sequence["verified"].items()
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, _device_id = _resolve_target(hass, call)
|
||||
href = call.data.get(ATTR_HREF)
|
||||
if not href:
|
||||
# No href -> the cached snapshot, not a live sweep of every known
|
||||
# href: lets a user enumerate what exists without hammering the
|
||||
# device (see this module's docstring and the coordinator's
|
||||
# canonical_resources).
|
||||
snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)}
|
||||
return cast(ServiceResponse, snapshot)
|
||||
|
||||
actual_href = subdevice.to_actual(href)
|
||||
code, rep = await coordinator.async_raw_read(actual_href)
|
||||
read_result: dict[str, Any] = {
|
||||
"href": href,
|
||||
"actual_href": actual_href,
|
||||
"code": f"{code >> 5}.{code & 0x1F:02d}",
|
||||
"raw_code": code,
|
||||
"rep": rep,
|
||||
}
|
||||
return cast(ServiceResponse, read_result)
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the write_resource/read_resource services (issue #300).
|
||||
|
||||
Called once from `async_setup`, not per config entry: services are
|
||||
process-global, and `hass.services.async_register` on an
|
||||
already-registered name just replaces the handler, so re-registering
|
||||
on every entry setup would silently rebind to whichever entry loaded
|
||||
last. `async_unload_entry` must never call the inverse of this.
|
||||
"""
|
||||
|
||||
async def _handle_write(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_write_resource(hass, call)
|
||||
|
||||
async def _handle_read(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_read_resource(hass, call)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
_handle_write,
|
||||
schema=_WRITE_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
_handle_read,
|
||||
schema=_READ_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
write_resource:
|
||||
name: Write resource
|
||||
description: >-
|
||||
Send one or more raw partial-rep writes straight to a device's OCF
|
||||
resources, in order, with an optional settle delay between steps and a
|
||||
delayed re-read at the end -- for reverse-engineering a device's write
|
||||
contract (issue #300), not day-to-day control. This bypasses the
|
||||
remote-control-off block and every write_fn/validate_fn a normal entity
|
||||
write goes through, and sends exactly the fields you give it verbatim:
|
||||
it can misconfigure your appliance. Prefer a real entity, or the Debug
|
||||
write panel in the integration's Configure menu, for anything this
|
||||
integration already models.
|
||||
target:
|
||||
device:
|
||||
integration: localthings
|
||||
fields:
|
||||
writes:
|
||||
name: Writes
|
||||
description: >-
|
||||
1-10 writes to perform in order. Each item needs href (the
|
||||
canonical resource, e.g. /mode/vs/0) and payload (a non-empty
|
||||
object sent verbatim as a partial-rep POST); settle is how many
|
||||
seconds to wait after that write before starting the next one
|
||||
(0-30, default 0).
|
||||
required: true
|
||||
example: >-
|
||||
[{"href": "/mode/vs/0", "payload": {"x.com.samsung.da.mode":
|
||||
"Bake"}, "settle": 3}]
|
||||
selector:
|
||||
object:
|
||||
verify_after:
|
||||
name: Verify after
|
||||
description: >-
|
||||
Seconds to wait after the whole sequence finishes before
|
||||
re-reading every href touched, to see whether the values held or
|
||||
were reverted by the board. 0 (default) skips verification.
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 60
|
||||
step: 0.5
|
||||
unit_of_measurement: seconds
|
||||
mode: box
|
||||
|
||||
read_resource:
|
||||
name: Read resource
|
||||
description: >-
|
||||
Read a device's OCF resources directly, bypassing this integration's
|
||||
entity model. Give an href for a live GET straight from the device --
|
||||
deliberately not the cache, which can be up to a poll interval stale --
|
||||
or omit it to get the cached snapshot of every resource this
|
||||
integration currently tracks on that device.
|
||||
target:
|
||||
device:
|
||||
integration: localthings
|
||||
fields:
|
||||
href:
|
||||
name: Resource href
|
||||
description: >-
|
||||
Canonical resource href to read (e.g. /mode/vs/0). Omit to get the
|
||||
cached snapshot of every tracked resource instead of a live GET.
|
||||
required: false
|
||||
example: /mode/vs/0
|
||||
selector:
|
||||
text:
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Příkaz pro {href} selhal i po opětovném připojení: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Zadejte 1 až 10 zápisů."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "Hodnota settle musí být mezi 0 a 30 sekundami."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "Hodnota verify_after musí být mezi 0 a 60 sekundami."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Tato služba vyžaduje přesně jedno cílové zařízení."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Pro tento cíl nebylo nalezeno žádné odpovídající zařízení."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "The command to {href} failed even after reconnecting: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Provide between 1 and 10 writes."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle must be between 0 and 30 seconds."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after must be between 0 and 60 seconds."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "This service requires exactly one target device."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No matching device was found for that target."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "This device isn't connected yet. Try again once it has loaded."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,24 @@
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "El comando para {href} falló incluso después de reconectar: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Proporciona entre 1 y 10 escrituras."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle debe estar entre 0 y 30 segundos."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after debe estar entre 0 y 60 segundos."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Este servicio requiere exactamente un dispositivo de destino."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No se encontró ningún dispositivo coincidente para ese destino."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Este dispositivo aún no está conectado. Vuelve a intentarlo cuando se haya cargado."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Il comando per {href} è fallito anche dopo la riconnessione: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Specificare da 1 a 10 scritture."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle deve essere compreso tra 0 e 30 secondi."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after deve essere compreso tra 0 e 60 secondi."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Questo servizio richiede esattamente un dispositivo di destinazione."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Nessun dispositivo corrispondente trovato per quella destinazione."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Questo dispositivo non è ancora connesso. Riprova una volta caricato."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "재연결 후에도 {href} 명령이 실패했습니다: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "1~10개의 쓰기를 지정하세요."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle 값은 0에서 30초 사이여야 합니다."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after 값은 0에서 60초 사이여야 합니다."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "이 서비스는 정확히 하나의 대상 기기가 필요합니다."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "해당 대상에 일치하는 기기를 찾을 수 없습니다."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "이 기기는 아직 연결되지 않았습니다. 로드된 후 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1417,6 +1417,24 @@
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Geef tussen de 1 en 10 schrijfacties op."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle moet tussen 0 en 30 seconden liggen."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after moet tussen 0 en 60 seconden liggen."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Deze service vereist precies één doelapparaat."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Er is geen bijpassend apparaat gevonden voor dat doel."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user