Capability report: PUT /devices/{id}/report, a Capabilities section, offline devices degraded

A device reports its board, screen, camera and ports from its own
credential (16 KB, schema 1, replacing the last report), and the flows
are validated against it again. The node panel turns a reported port's
config schema into a form, a reported screen is a canvas preset and a
touch screen's first report turns Touch friendly on. An offline device
node is degraded: amber on the canvas, a warning on Home, never a fault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
This commit is contained in:
Melvin Strobl
2026-10-16 17:37:49 +02:00
co-authored by Claude Opus 5.5
parent a9d453c9d9
commit ad76200d3d
28 changed files with 820 additions and 129 deletions
+92
View File
@@ -803,6 +803,98 @@ def test_a_port_wired_twice_or_an_unknown_device_is_an_issue(
client.delete(f"{FLOWS}/{flow}", headers=superuser_token_headers)
#: What a P4 panel reports: a GPIO pin either way, its backlight and touch.
REPORT = {
"schema": 1,
"platform": "esp32p4",
"board": "p4",
"firmware": "0.1.2",
"display": {"w": 1024, "h": 600, "shape": "rect", "touch": True},
"ports": [
{"port": "backlight", "direction": "out", "dtype": "int"},
{"port": "touch", "direction": "in", "dtype": "bool"},
{
"port": "gpio.4",
"direction": "in",
"modes": ["in", "out"],
"dtype": "bool",
"config": {"type": "object", "properties": {"invert": {"type": "boolean"}}},
},
],
"features": {"display": True},
# A newer firmware's key, which this version drops.
"battery": {"level": 80},
}
def test_a_device_reports_what_it_has(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Only from its own credential, at most 16 KB, the whole report replaced.
The device node's ports are checked against it from then on.
"""
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "porch_io"}, {"id": "other"}]},
)
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
device = _pair(client, superuser_token_headers, "porch_io")
other = _pair(client, superuser_token_headers, "other")
url = f"{PREFIX}/porch_io/report"
def issues() -> set[tuple[str | None, bool]]:
found = client.get(f"{FLOWS}/porch", headers=superuser_token_headers).json()
return {
(i["port"], i["advisory"])
for i in found["issues"]
if i["code"] == "device_port"
}
# Without a report the ports are the device's business.
assert issues() == set()
assert (
client.put(url, headers=superuser_token_headers, json=REPORT).status_code == 403
)
assert client.put(url, headers=other, json=REPORT).status_code == 403
padded = {**REPORT, "firmware": "x" * 20_000}
assert client.put(url, headers=device, json=padded).status_code == 413
assert (
client.put(url, headers=device, json={**REPORT, "schema": 2}).status_code == 422
)
put = client.put(url, headers=device, json=REPORT)
assert put.status_code == 200, put.text
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
stored = next(d for d in listed["data"] if d["id"] == "porch_io")
assert stored["reported_at"]
assert "battery" not in stored["report"]
assert stored["report"]["ports"][2]["modes"] == ["in", "out"]
# A touch screen's first report makes the device touch friendly.
assert stored["touch"] is True
# What the device reads stays what it was: no report, no presence.
read = client.get(f"{PREFIX}/porch_io", headers=device).json()
assert "report" not in read and read["touch"] is True
# gpio.4 is wired as in, which its modes allow, and the backlight as int.
assert issues() == set()
# A report replaces the one before: the backlight gone, gpio.4 out only.
gpio = {"port": "gpio.4", "direction": "out", "dtype": "bool"}
assert (
client.put(url, headers=device, json={**REPORT, "ports": [gpio]}).status_code
== 200
)
assert issues() == {("gpio.4", False), ("backlight", True)}
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
# The other tests wire porch_io without a report.
store = app.state.device_store
with store._state_lock:
states = store._read_states()
states["porch_io"].report = None
store._write_states(states)
def test_publishing_a_flow_with_device_nodes_tells_the_devices(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None: