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 committed 2026-08-07 22:08:28 +00:00
1 parent 9228da1d9c
commit 5dbe990c1d
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.
+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."
}
}
}