feat: add debug options panel for arbitrary resource-href writes (#54)

Power users can now pick a resource href from a live dropdown, view its
current value, and POST a minimal patch straight to the device -- to pin
down device-specific write behavior without waiting on a new release.
Bypasses the remote-control block and all write_fn/validate_fn logic by
design; the existing remote-control settings toggle moves behind the same
options-flow menu.
This commit is contained in:
Marc Billow committed 2026-07-24 14:02:15 +00:00
1 parent 77cc875608
commit c731ecefe5
6 files changed
+545 -6

No files matched your search

+125 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import datetime
import json
import logging
import re
import selectors
@@ -15,6 +16,10 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
ObjectSelector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
@@ -396,22 +401,44 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"""Per-device override of the remote-control-off write block (issue
#54). The block exists because most devices reject writes outright
while remote control is off and a clear error beats a silent
"""Per-device options: the remote-control-off write-block override
(issue #54) plus a debug panel for writing an arbitrary body to an
arbitrary resource href, so a user can pin down device-specific write
behavior without waiting on a new release.
The remote-control override exists because most devices reject writes
outright while remote control is off and a clear error beats a silent
device-side rejection -- but not every model actually enforces that,
so this lets a user who's confirmed their device accepts writes anyway
turn the block off for just that device rather than it being
hardcoded on for everyone."""
hardcoded on for everyone. The debug panel goes further: it bypasses
that block (and every write_fn/validate_fn) entirely, sending exactly
the body the user types to whatever href they pick.
"""
def __init__(self) -> None:
self._debug_href: str = ""
self._debug_result: tuple[int, dict] | None = None
def _coordinator(self):
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
return self.async_show_menu(
step_id="init",
menu_options=["settings", "debug_write"],
)
async def async_step_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
step_id="settings",
data_schema=vol.Schema({
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
@@ -421,3 +448,96 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
): bool,
}),
)
async def async_step_debug_write(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
if user_input is not None:
self._debug_href = user_input["href"]
return await self.async_step_debug_edit()
hrefs = sorted(coord.last_resources.keys())
return self.async_show_form(
step_id="debug_write",
data_schema=vol.Schema({
vol.Required("href"): SelectSelector(SelectSelectorConfig(
options=hrefs,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)),
}),
)
def _show_debug_edit_form(
self, href: str, current: dict, errors: dict[str, str], payload,
) -> FlowResult:
return self.async_show_form(
step_id="debug_edit",
data_schema=vol.Schema({
vol.Required(
"payload", default=(payload if payload is not None else {}),
): ObjectSelector(),
}),
errors=errors,
description_placeholders={
"href": href,
"current_value": (
json.dumps(current, indent=2, ensure_ascii=False)
if current else "(no cached value)"
),
},
)
async def async_step_debug_edit(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
href = self._debug_href
current = coord.resource(href)
if user_input is not None:
payload = user_input.get("payload") or {}
if not isinstance(payload, dict) or not payload:
return self._show_debug_edit_form(
href, current, {"payload": "empty_payload"}, payload
)
try:
code, new_rep = await coord.async_raw_write(href, payload)
except Exception: # noqa: BLE001 - surfaced to the user below
_LOGGER.exception("debug raw write failed for %s", href)
return self._show_debug_edit_form(
href, current, {"base": "write_failed"}, payload
)
self._debug_result = (code, new_rep)
return await self.async_step_debug_result()
return self._show_debug_edit_form(href, current, {}, None)
async def async_step_debug_result(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
code, new_rep = self._debug_result or (0, {})
return self.async_show_menu(
step_id="debug_result",
menu_options=["debug_write", "finish"],
description_placeholders={
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
"new_value": (
json.dumps(new_rep, indent=2, ensure_ascii=False)
if new_rep else "(no value returned)"
),
},
)
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
# Close the flow without altering saved options.
return self.async_create_entry(data=dict(self.config_entry.options))
@@ -630,3 +630,58 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.error("command failed for %s: %s", write_href, e)
else:
await self.async_request_refresh()
# ------------------------------------------------------------------
# Debug raw write (issue #54): a power-user escape hatch for the
# options-flow debug panel, letting a user POST an arbitrary partial
# body to an arbitrary href to pin down device-specific write behavior
# without waiting on a new release. Deliberately bypasses the
# remote-control block and every write_fn/validate_fn above -- that's
# the whole point, so use with care.
# ------------------------------------------------------------------
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
"""Debug primitive: POST an arbitrary patch, then read the href
back for ground truth. Blocking -- runs in executor."""
if self._session is None:
self._connect_session()
sess = self._session
if sess is None:
raise RuntimeError("no session")
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
self._log.warning("DEBUG raw write POST %s %r → code %#04x", href, body, code)
new_rep: dict = {}
try:
sess.pace()
rcode, payload = sess.get(path_segs, timeout=10.0)
if rcode == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
self._observe.apply(href, rep, source='poll')
new_rep = rep
except Exception as e:
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 the
remote-control block and all write_fn/validate_fn logic; sends
`body` verbatim as a partial-rep PATCH to `href`. Returns
(coap_code, new_rep) where new_rep is the href's value read back
right after the write. Used by the options-flow debug panel to
help users pin down device-specific write behavior without a new
release."""
if not isinstance(body, dict) or not body:
raise ServiceValidationError("Debug write payload must be a non-empty object.")
path_segs = [s for s in href.strip('/').split('/') if s]
if not path_segs:
raise ServiceValidationError("A resource href is required.")
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
)
# Hasten a full summary poll so entities on other resources catch
# up too -- a debug write can affect siblings, not just its href.
await self.async_request_refresh()
return code, new_rep
@@ -250,11 +250,47 @@
"step": {
"init": {
"title": "Local Things Options",
"menu_options": {
"settings": "Remote control write settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
},
"debug_write": {
"title": "Debug: write to a resource",
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
"data": {
"href": "Resource href"
}
},
"debug_edit": {
"title": "Debug write: {href}",
"description": "Current value of `{href}`:\n```\n{current_value}\n```\nEnter ONLY the field(s) you want to change below. What you enter is sent to the device as-is (a partial update); it is NOT merged with the fields shown above, so don't paste the whole value back. Mind the types: a numeric string like \"1\" must stay quoted -- a bare 1 is sent as an integer, which some devices reject.",
"data": {
"payload": "Payload to write"
}
},
"debug_result": {
"title": "Debug write result",
"description": "The device returned CoAP code {code}. A 2.xx class means the write was accepted; 4.xx/5.xx means it was rejected. The resource now reads:\n```\n{new_value}\n```\nIf the value is unchanged, the device likely dropped the write. Pick what to do next:",
"menu_options": {
"debug_write": "Write another resource",
"finish": "Finish"
}
}
},
"error": {
"empty_payload": "Enter at least one field to write.",
"write_failed": "The write failed. Check the Home Assistant logs for details."
},
"abort": {
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
}
},
"issues": {
@@ -250,11 +250,47 @@
"step": {
"init": {
"title": "Local Things Options",
"menu_options": {
"settings": "Remote control write settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
},
"debug_write": {
"title": "Debug: write to a resource",
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
"data": {
"href": "Resource href"
}
},
"debug_edit": {
"title": "Debug write: {href}",
"description": "Current value of `{href}`:\n```\n{current_value}\n```\nEnter ONLY the field(s) you want to change below. What you enter is sent to the device as-is (a partial update); it is NOT merged with the fields shown above, so don't paste the whole value back. Mind the types: a numeric string like \"1\" must stay quoted -- a bare 1 is sent as an integer, which some devices reject.",
"data": {
"payload": "Payload to write"
}
},
"debug_result": {
"title": "Debug write result",
"description": "The device returned CoAP code {code}. A 2.xx class means the write was accepted; 4.xx/5.xx means it was rejected. The resource now reads:\n```\n{new_value}\n```\nIf the value is unchanged, the device likely dropped the write. Pick what to do next:",
"menu_options": {
"debug_write": "Write another resource",
"finish": "Finish"
}
}
},
"error": {
"empty_payload": "Enter at least one field to write.",
"write_failed": "The write failed. Check the Home Assistant logs for details."
},
"abort": {
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
}
},
"issues": {