From 4286acadfd6f55228fe1b3c3179f83059ec31e71 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Fri, 16 Oct 2026 17:37:00 +0200 Subject: [PATCH] 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. --- backend/fluksio/api/deps.py | 11 +- backend/fluksio/api/routes/devices.py | 88 +++++++-- backend/fluksio/api/routes/observability.py | 18 ++ backend/fluksio/flow/controller.py | 40 +++- backend/fluksio/flow/devices.py | 77 +++++++- backend/fluksio/flow/nodes/device.py | 6 +- backend/fluksio/flow/pipeline.py | 3 +- backend/fluksio/mcp/server.py | 4 +- backend/tests/api/routes/test_devices.py | 92 +++++++++ backend/tests/flow/test_device_node.py | 33 +++- docs/code/api.md | 3 +- docs/concepts/flows.md | 9 +- docs/interface/dashboards.md | 54 +++++- docs/interface/flow-editor.md | 8 +- docs/interface/index.md | 3 +- docs/reference/node-types.md | 5 +- frontend/src/client/schemas.gen.ts | 22 ++- frontend/src/client/sdk.gen.ts | 31 ++- frontend/src/client/types.gen.ts | 12 +- frontend/src/components/Dashboard/panels.tsx | 18 +- .../src/components/Devices/DeviceDetail.tsx | 177 +++++++++++++++--- frontend/src/components/Devices/ports.ts | 64 +++++-- frontend/src/components/Flow/FlowEditor.tsx | 25 ++- frontend/src/components/Flow/FlowNode.tsx | 28 ++- frontend/src/components/Flow/NodePanel.tsx | 91 +++++++-- frontend/src/components/Flow/useFlowSocket.ts | 12 +- .../src/components/Health/HealthOverview.tsx | 12 ++ frontend/src/index.css | 3 + 28 files changed, 820 insertions(+), 129 deletions(-) diff --git a/backend/fluksio/api/deps.py b/backend/fluksio/api/deps.py index 796dde4..29c690d 100644 --- a/backend/fluksio/api/deps.py +++ b/backend/fluksio/api/deps.py @@ -189,10 +189,10 @@ def _device_may(payload: dict[str, Any], request: Request) -> None: A panel credential names the account that approved the pairing, so without this it would be that person's session hanging on a wall. What a panel genuinely needs is small and worth writing out: the dashboards it was - assigned, its own definition, the messages its own widgets and ports bind - to, and the bytes those messages currently point at, for a tile drawing a - camera frame — plus an upload named after one of its ``in`` ports, for a - microphone whose recording is the value it publishes. + assigned, its own definition and report, the messages its own widgets and + ports bind to, and the bytes those messages currently point at, for a tile + drawing a camera frame — plus an upload named after one of its ``in`` + ports, for a microphone whose recording is the value it publishes. Publishing is in the list because a panel cannot be strictly read-only — a control on a panel is the point of putting one there, and a querying chart @@ -239,6 +239,9 @@ def _device_may(payload: dict[str, Any], request: Request) -> None: f"{api}/panels/{device.id}", ): allowed = True + elif method == "PUT" and path == f"{api}/devices/{device.id}/report": + # What it has, said about itself and nobody else. + allowed = True elif method == "POST" and path == f"{api}/flows/ws-ticket": # How its live socket opens; the socket applies the panel's own scope. allowed = True diff --git a/backend/fluksio/api/routes/devices.py b/backend/fluksio/api/routes/devices.py index ba68de1..b233a1d 100644 --- a/backend/fluksio/api/routes/devices.py +++ b/backend/fluksio/api/routes/devices.py @@ -18,8 +18,13 @@ device nodes in published flows wire its own ports to, and nothing else. Deleting the device revokes it, and so does unpairing, which bumps the device's nonce and leaves what it shows as it was. -``/panels/pair``, ``/panels/pair/{code}`` and ``GET /panels/{id}`` are what the -openpanel firmware calls, and stay as aliases until every device has updated. +Once paired, a device says what it has with ``PUT /devices/{id}/report``: its +board, its screen and the ports it serves, which the device node's ports are +checked against. + +``/panels/pair``, ``/panels/pair/{code}`` and ``GET /panels/{id}`` are what +openpanel firmware from before devices calls, and stay as aliases until every +device has updated. """ from __future__ import annotations @@ -34,7 +39,7 @@ import httpx import redis from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.concurrency import run_in_threadpool -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, ValidationError, field_validator from fluksio.api.deps import ( CurrentUser, @@ -52,6 +57,7 @@ from fluksio.flow.devices import ( DeviceExists, DeviceIo, DeviceNotFound, + DeviceReport, DeviceStore, is_lan, presence, @@ -88,6 +94,10 @@ RESERVED = {"pair", "pending"} #: How much of what a device says about itself is kept. HELLO_LENGTH = 64 +#: The most a capability report may weigh. A panel with every header pin +#: reported is a few kB. +REPORT_MAX = 16 * 1024 + class _Pending(BaseModel): """A device waiting to be told what it is.""" @@ -314,6 +324,7 @@ class DevicePublic(DeviceDocument): last_seen: datetime | None = None #: What the device says it has, as it last said so. None until it reports. report: dict[str, Any] | None = None + reported_at: datetime | None = None class DevicesPublic(BaseModel): @@ -499,15 +510,19 @@ async def read_devices(store: DeviceStoreDep) -> Any: """Every device, what each one shows, and whether it is connected.""" def gather() -> list[DevicePublic]: - return [ - DevicePublic( - **_document(device, store).model_dump(), - online=presence.online(device.id), - last_seen=presence.last_seen(device.id), - report=store.state(device.id).report, + found = [] + for device in store.list(): + state = store.state(device.id) + found.append( + DevicePublic( + **_document(device, store).model_dump(), + online=presence.online(device.id), + last_seen=presence.last_seen(device.id), + report=state.report, + reported_at=state.reported_at, + ) ) - for device in store.list() - ] + return found data = await run_in_threadpool(gather) return DevicesPublic( @@ -596,6 +611,57 @@ panels_router.add_api_route( ) +@router.put( + "/{device_id}/report", + response_model=Message, + dependencies=[Depends(get_current_user)], +) +async def put_report(device_id: str, request: Request, store: DeviceStoreDep) -> Any: + """A device says what it has: its board, its screen, its ports. + + Only the device itself, through its own credential, which ``_device_may`` + holds to its own id. The report replaces the one before it, and the flows + are validated again, since a device node's ports are checked against it. + + The first report of a touch screen makes the device touch friendly, as + the switch would have been set by hand. Later ones leave the setting to + whoever changed it since. + """ + device: DeviceDef | None = getattr(request.state, "device", None) + if device is None or device.id != device_id: + raise HTTPException( + status_code=403, detail="Only the device itself reports what it has" + ) + raw = bytearray() + async for chunk in request.stream(): + raw += chunk + if len(raw) > REPORT_MAX: + raise HTTPException( + status_code=413, + detail=f"A report is at most {REPORT_MAX // 1024} KB", + ) + try: + report = DeviceReport.model_validate_json(bytes(raw)) + except ValidationError as exc: + raise HTTPException( + status_code=422, + detail=exc.errors( + include_url=False, include_input=False, include_context=False + ), + ) + first = await run_in_threadpool(store.report, device_id, report) + if first and report.display and report.display.touch and not device.touch: + with suppress(StaleVersion, DeviceNotFound): + await run_in_threadpool( + store.write, + device.model_copy(update={"touch": True}), + device.version, + ) + _changed() + await _recheck(request) + return Message(message=f"{len(report.ports)} ports reported") + + @router.put( "/{device_id}", response_model=DeviceDef, diff --git a/backend/fluksio/api/routes/observability.py b/backend/fluksio/api/routes/observability.py index 5003831..3af0e52 100644 --- a/backend/fluksio/api/routes/observability.py +++ b/backend/fluksio/api/routes/observability.py @@ -53,6 +53,8 @@ def _epoch(column: Any) -> ColumnElement[int]: class HealthSummary(BaseModel): status: str problems: list[str] + #: Worth a look, but not faults: they leave ``status`` as it is. + warnings: list[str] = [] flows: dict[str, int] nodes: dict[str, int] queue: dict[str, Any] @@ -241,9 +243,24 @@ async def read_summary(request: Request, controller: FlowControllerDep) -> Any: if invalid: problems.append(f"{len(invalid)} flow(s) cannot run: {', '.join(invalid)}") + # Working, if not as well as they should: a device switched off, a send + # queue full. A warning, held back by the same floor as `unhealthy`. + degraded = controller.unhealthy_nodes(health="degraded") + warnings = ( + [ + f"{len(degraded)} node(s) degraded: " + + ", ".join( + sorted(f"{e.id} ({e.health_detail or 'no detail'})" for e in degraded) + ) + ] + if degraded + else [] + ) + return HealthSummary( status="degraded" if problems else "ok", problems=problems, + warnings=warnings, flows={ "total": len(names), # What the engine will actually act on. Enabled is not enough: @@ -267,6 +284,7 @@ async def read_summary(request: Request, controller: FlowControllerDep) -> Any: "total": len(entries), "error": len(errored), "unhealthy": len(unhealthy), + "degraded": len(degraded), "failing": len(failing), }, queue=queue, diff --git a/backend/fluksio/flow/controller.py b/backend/fluksio/flow/controller.py index cbe2039..3a75f49 100644 --- a/backend/fluksio/flow/controller.py +++ b/backend/fluksio/flow/controller.py @@ -1363,9 +1363,14 @@ class FlowController: } ) - def unhealthy_nodes(self, flow: str | None = None) -> list[LoadedNode]: + def unhealthy_nodes( + self, flow: str | None = None, health: Health = "down" + ) -> list[LoadedNode]: """Nodes that have been down long enough to be worth telling anyone. + Or degraded, with ``health``: working, but not as well as they should, + which is a warning rather than a fault. + The floor is on the surfacing, not on the transition: `entry.health` is the truth the moment the node reports it, so a flow reading a node's health is never told a stale story — only the screens wait. And only @@ -1376,7 +1381,7 @@ class FlowController: return [ entry for entry in self.loaded.values() - if entry.health == "down" + if entry.health == health and now - entry.health_since >= HEALTH_FLOOR and (flow is None or entry.flow == flow) ] @@ -1384,6 +1389,9 @@ class FlowController: def _health_issues(self, flow: str | None = None) -> list[ValidationIssue]: """Nodes that are running but not working, as issues on their flow. + A degraded node is an advisory one: it works, if not as it should, such + as a device node whose device is switched off. + Not part of `self.issues`: that list is what a build found, and this is what is happening now. Derived on read, so a node reporting itself well again clears it with nothing to remember. @@ -1399,6 +1407,17 @@ class FlowController: node=entry.id, ) for entry in self.unhealthy_nodes(flow) + ] + [ + ValidationIssue( + code="node_degraded", + message=( + f"Node '{entry.id.rpartition('.')[2]}' is degraded: " + f"{entry.health_detail or 'no detail given'}" + ), + flow=entry.flow, + node=entry.id, + ) + for entry in self.unhealthy_nodes(flow, "degraded") ] def flow_issues(self, flow: str) -> list[ValidationIssue]: @@ -2101,6 +2120,10 @@ def _collect_issues( return issues +#: The dtypes a device port reads as a plain number. +_NUMBERS = frozenset({"int", "float"}) + + def _device_issues( loaded: dict[str, LoadedNode], devices: Any ) -> list[ValidationIssue]: @@ -2111,6 +2134,10 @@ def _device_issues( direction or of another dtype is an error too, and one the device does not list is a warning: the device may know it all the same. Without a report a port is the device's business and nothing is said about it. + + A port reporting ``modes`` may be wired in any of them, as a GPIO pin is + either way. ``int`` and ``float`` pass for each other: the device reads a + number either way. """ if devices is None: return [] @@ -2163,12 +2190,17 @@ def _device_issues( elif said is None: problem = f"'{device}' does not report a port '{spec.port}'." advisory = True - elif said.get("direction") and said["direction"] != direction: + elif said.get("direction") and direction not in ( + said.get("modes") or [said["direction"]] + ): problem = ( f"'{spec.port}' of '{device}' is an {said['direction']} " f"port, wired here as {direction}." ) - elif said.get("dtype") and said["dtype"] != spec.dtype.value: + elif said.get("dtype") and not ( + said["dtype"] == spec.dtype.value + or {said["dtype"], spec.dtype.value} <= _NUMBERS + ): problem = ( f"'{spec.port}' of '{device}' carries {said['dtype']}, " f"wired here to a {spec.dtype.value} message." diff --git a/backend/fluksio/flow/devices.py b/backend/fluksio/flow/devices.py index 874b2ed..b39cdf4 100644 --- a/backend/fluksio/flow/devices.py +++ b/backend/fluksio/flow/devices.py @@ -38,7 +38,13 @@ from pydantic import BaseModel, Field, TypeAdapter, ValidationError, field_valid from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore from fluksio.flow.events import event_bus -from fluksio.flow.messages import MessageSpec, flow_of, qualify, validate_qualified +from fluksio.flow.messages import ( + DType, + MessageSpec, + flow_of, + qualify, + validate_qualified, +) from fluksio.flow.schemas import FlowDef, NodeDef, _validate_name from fluksio.flow.store import FlowStore, InvalidName, StaleVersion, checked @@ -158,6 +164,61 @@ class DeviceState(BaseModel): _STATES = TypeAdapter(dict[str, DeviceState]) +class ReportDisplay(BaseModel): + """The device's screen, in pixels.""" + + w: int = Field(ge=0) + h: int = Field(ge=0) + shape: Literal["rect", "round"] = "rect" + touch: bool = False + + +class ReportPort(BaseModel): + """One port the device has, and how it may be wired.""" + + port: str = Field(pattern=PORT_PATTERN.pattern) + #: Its default direction: ``in`` publishes, ``out`` follows. + direction: Literal["in", "out"] + dtype: DType + #: A JSON schema of the ``config`` the device reads for this port. + config: dict[str, Any] | None = None + #: Sends only while somebody wants it, such as a camera. + on_demand: bool | None = None + #: The directions it may be wired in, when it may be either: a GPIO pin. + modes: list[Literal["in", "out"]] | None = None + + +class ReportCamera(BaseModel): + """The camera sensor the device found, and what it delivers.""" + + sensor: str = "" + w: int = 0 + h: int = 0 + fps: int = 0 + + +class DeviceReport(BaseModel): + """What a device says it has, version 1. + + Keys this version does not know are dropped, so a newer firmware still + reports to an older instance. The whole report replaces the one before. + """ + + version: Literal[1] = Field(alias="schema") + platform: str = "" + board: str = "" + firmware: str = "" + display: ReportDisplay | None = None + ports: list[ReportPort] = Field(default_factory=list) + camera: ReportCamera | None = None + #: The build's features, true when there: ``{"camera": true}``. + features: dict[str, bool] = Field(default_factory=dict) + + def stored(self) -> dict[str, Any]: + """As ``devices.json`` keeps it: the device's own keys, nothing unset.""" + return self.model_dump(mode="json", by_alias=True, exclude_none=True) + + class DeviceNotFound(KeyError): def __init__(self, device_id: str) -> None: super().__init__(device_id) @@ -372,6 +433,20 @@ class DeviceStore: self._write_states(states) return state.nonce + def report(self, device_id: str, report: DeviceReport) -> bool: + """Keep what the device says it has, in place of what it said before. + + Returns whether it is the first report this device made. + """ + with self._state_lock: + states = self._read_states() + state = states.setdefault(device_id, DeviceState()) + first = state.report is None + state.report = report.stored() + state.reported_at = datetime.now(UTC) + self._write_states(states) + return first + # -- the panels file of an older instance ------------------------------- def migrate(self, panels_file: Path) -> None: diff --git a/backend/fluksio/flow/nodes/device.py b/backend/fluksio/flow/nodes/device.py index 9db020e..a1cab8a 100644 --- a/backend/fluksio/flow/nodes/device.py +++ b/backend/fluksio/flow/nodes/device.py @@ -31,7 +31,7 @@ def _never(**_: Any) -> None: class DeviceNode(Node): - """A device's ports, wired to messages. Down while the device is away.""" + """A device's ports, wired to messages. Degraded while the device is away.""" passive = True @@ -81,7 +81,9 @@ class DeviceNode(Node): presence.unwatch(self.cfg.device, self._presence) def _presence(self, online: bool) -> None: + # Degraded rather than down: a panel switched off or asleep is a thing + # to notice, not a fault of the flow wiring it. if online: self.report_health("ok") else: - self.report_health("down", "Offline") + self.report_health("degraded", "Offline") diff --git a/backend/fluksio/flow/pipeline.py b/backend/fluksio/flow/pipeline.py index c0e8504..0c074c4 100644 --- a/backend/fluksio/flow/pipeline.py +++ b/backend/fluksio/flow/pipeline.py @@ -47,7 +47,7 @@ MANUAL_RUN_PREFIX = "manual-" # neither the brain graph nor the health summary treats them as a fault. # Lives here rather than beside the controller so `ValidationIssue` can carry # the distinction itself, and every reader gets it for free. -ADVISORY_ISSUES = frozenset({"unauthenticated_hook", "ignored_worker"}) +ADVISORY_ISSUES = frozenset({"unauthenticated_hook", "ignored_worker", "node_degraded"}) class ValidationIssue(BaseModel): @@ -62,6 +62,7 @@ class ValidationIssue(BaseModel): "self_loop_needs_initial", "missing_source", "node_unhealthy", + "node_degraded", "ignored_worker", "unknown_device", "device_port", diff --git a/backend/fluksio/mcp/server.py b/backend/fluksio/mcp/server.py index 23c46f7..5022c53 100644 --- a/backend/fluksio/mcp/server.py +++ b/backend/fluksio/mcp/server.py @@ -182,8 +182,8 @@ async def list_shared_nodes() -> Any: @mcp.tool() async def list_devices() -> Any: - """Every paired screen or board, the dashboards it shows and whether it is - connected.""" + """Every paired screen or board, the dashboards it shows, whether it is + connected and what it reports having.""" return await _call("GET", "/devices/") diff --git a/backend/tests/api/routes/test_devices.py b/backend/tests/api/routes/test_devices.py index 0ada30a..4c97d72 100644 --- a/backend/tests/api/routes/test_devices.py +++ b/backend/tests/api/routes/test_devices.py @@ -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: diff --git a/backend/tests/flow/test_device_node.py b/backend/tests/flow/test_device_node.py index 534803a..5d8ca19 100644 --- a/backend/tests/flow/test_device_node.py +++ b/backend/tests/flow/test_device_node.py @@ -1,4 +1,4 @@ -"""A device node: never run by the engine, down while its device is away.""" +"""A device node: never run by the engine, degraded while its device is away.""" import asyncio from pathlib import Path @@ -67,7 +67,7 @@ def test_the_engine_never_runs_a_device_node(tmp_path: Path) -> None: asyncio.run(controller._teardown()) -def test_a_device_node_is_down_while_its_device_is_away(tmp_path: Path) -> None: +def test_a_device_node_is_degraded_while_its_device_is_away(tmp_path: Path) -> None: controller = _controller(tmp_path) async def scenario() -> list[tuple[str, str | None]]: @@ -85,10 +85,33 @@ def test_a_device_node_is_down_while_its_device_is_away(tmp_path: Path) -> None: presence.closed("wall") return seen + # Degraded, not down: a switched-off panel is a warning, never a fault. assert asyncio.run(scenario()) == [ - ("down", "Offline"), + ("degraded", "Offline"), ("ok", None), - ("down", "Offline"), - ("down", "Offline"), + ("degraded", "Offline"), + ("degraded", "Offline"), ] presence.forget("wall") + + +def test_an_offline_device_is_a_warning_not_a_fault(tmp_path: Path) -> None: + controller = _controller(tmp_path) + + async def scenario() -> list: + await controller.reload() + # Past the floor that holds back a device rebooting. + controller.loaded["hall.panel"].health_since -= 3600 + found = ( + controller.unhealthy_nodes(), + [e.id for e in controller.unhealthy_nodes(health="degraded")], + [(i.code, i.advisory) for i in controller.flow_issues("hall")], + ) + await controller._teardown() + return list(found) + + assert asyncio.run(scenario()) == [ + [], + ["hall.panel"], + [("node_degraded", True)], + ] diff --git a/docs/code/api.md b/docs/code/api.md index 0486087..9fb3ec3 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -190,7 +190,7 @@ on its own, so this is for reclaiming space sooner. | `PUT` | `/dashboards/{name}` | save a draft | | `POST` | `/dashboards/{name}/publish`, `/dashboards/{name}/discard`, `/dashboards/{name}/rename` | publish, discard, or rename | | `POST` | `/dashboards/{name}/duplicate` | copy into an unpublished `{name}_copy` | -| `GET` | `/devices/` | list devices, with `online` and `last_seen` | +| `GET` | `/devices/` | list devices, with `online`, `last_seen`, `report` and `reported_at` | | `POST`, `GET`, `PUT`, `DELETE` | `/devices/{id}` | create, read, save or delete one; a save sends the `version` it read and gets 409 when someone saved first. A read adds `io` and `bindings`, its ports as the device nodes of published flows wire them | | `POST` | `/devices/pair` | start device pairing, optionally with `{name, board, firmware}`; unauthenticated | | `GET` | `/devices/pair/{code}` | poll pairing with its `secret`; answers `device` (and `panel` for older firmware); unauthenticated | @@ -198,6 +198,7 @@ on its own, so this is for reclaiming space sooner. | `GET` | `/devices/pair/{code}/device` | what is waiting on a code, before it is approved | | `POST` | `/devices/{id}/pair` | adopt by `{code}`, or by `{request}` from the pending list for a request from your network | | `POST` | `/devices/{id}/unpair` | revoke the device credential | +| `PUT` | `/devices/{id}/report` | what the device has, from its own credential only; at most 16 KB, replaces the last report | `/panels/pair`, `/panels/pair/{code}` and `GET /panels/{id}` answer as their `/devices/` counterparts, for firmware that has not updated yet. diff --git a/docs/concepts/flows.md b/docs/concepts/flows.md index 8305248..04b374b 100644 --- a/docs/concepts/flows.md +++ b/docs/concepts/flows.md @@ -140,11 +140,12 @@ The canvas reports issues on the affected nodes: | `ignored_worker` | a node other than a Function node names a worker, which nothing reads; advisory | | `unknown_device` | a Device node names no device, or one that does not exist | | `device_port` | a device port is wired in two places, or against the device's report the wrong way or type; a port the report does not list is advisory | -| `node_unhealthy` | a loaded node reports a runtime problem, such as a lost connection, or a device being offline | +| `node_unhealthy` | a loaded node reports a runtime problem, such as a lost connection | +| `node_degraded` | a loaded node works but not as it should, such as a device being offline; advisory, amber on the canvas | -Build issues prevent the affected flow from running. The last three are runtime -or advisory issues: other work continues, and node health clears when the -connection recovers. +Build issues prevent the affected flow from running. `node_unhealthy` and +`node_degraded` are runtime issues: other work continues, and they clear when +the node recovers. A degraded node is a warning on Home rather than a fault. You can **stop** a live flow to remove its subscriptions and schedules, or **pause** it to hold pending work while values still arrive. **Step** releases diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index fe3cac3..bb16a8b 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -496,9 +496,9 @@ play MJPEG streams, pushed frames, images, and H.264 Baseline or MJPEG clips up to 640×360. Re-encode other clips with the [clip recipe](../code/nodes.md#making-clips-playable-on-an-lvgl-panel). The device decides what its [port](#the-devices-own-ports) names -mean. openpanel serves `gpio.`, `backlight`, `touch`, `mic` and `speaker`, -lists each one's `config` in its README and ignores any other name. A browser -screen serves none of them. The [speech recipe](../code/nodes.md#speech-to-and-from-a-panel) +mean. openpanel [reports](#what-a-device-reports) the ones it serves, such as +`gpio.`, `backlight`, `touch`, `mic` and `speaker`, with the settings each +one reads, and ignores any other name. A browser screen serves none of them. The [speech recipe](../code/nodes.md#speech-to-and-from-a-panel) turns `mic` into text and text into `speaker`. ### The device's own ports @@ -507,8 +507,8 @@ A device can also act as a node, with a button on its GPIO, its backlight or its microphone. Its ports are wired in a flow with a **Device** node: pick the device, then add each port under **Publishes** (`in`, the device sends to the message) or **Follows** (`out`, the device reads it). **Use in flow** on the -device's page puts its node in a flow you pick, and **Ports** lists what the -published flows wire, each linked to its node. +device's page puts its node in a flow you pick, and **Capabilities** lists the +ports it reports and what the published flows wire, each linked to its node. | Field | Meaning | |---|---| @@ -520,7 +520,8 @@ published flows wire, each linked to its node. Only published flows count. A port wired in two of them, or a device node naming a device that does not exist, is an error on the canvas. Until a device reports what it has, a port name is free text; once it has, a port it does not -list is a warning and one wired the wrong way round an error. +list is a warning and one wired the wrong way round, or to another type, an +error. `int` and `float` pass for each other. The device reads its ports as `io` on `GET /devices/{id}`, in a fixed order, and is told to read them again when publishing or deleting a flow changes them. @@ -528,8 +529,45 @@ The credential may publish to its `in` messages even when a dashboard is locked, and may upload an artifact named after one (`PUT /artifacts?name=`), which is how a microphone sends a recording. A value it publishes there is credited to its device node, so the node's edge -lights up on the canvas. `out` messages are read only. The node is shown down, -*Offline*, while the device is not connected. +lights up on the canvas. `out` messages are read only. The node is shown +degraded, *Offline*, in amber while the device is not connected: a warning on +Home, not a fault, since a panel switched off or asleep breaks nothing. + +### What a device reports + +A paired device says what it has with `PUT /devices/{id}/report`, from its own +credential only. openpanel sends it after pairing and once per boot. The report +replaces the one before, is at most 16 KB and keys this version does not know +are dropped: + +```json +{ + "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", + "config": {"type": "object", "properties": {"wake_s": {"type": "integer", "default": 30}}}}, + {"port": "gpio.4", "direction": "in", "modes": ["in", "out"], "dtype": "bool"} + ], + "camera": {"sensor": "IMX415", "w": 1920, "h": 1080, "fps": 15}, + "features": {"display": true} +} +``` + +`config` is a JSON schema of the settings the device reads for that port, which +the Device node turns into a form. `modes` lists the directions a port may be +wired in when it may be either, as a GPIO pin. `on_demand` marks a port that +sends only while somebody wants it. + +The device's page shows the report under **Capabilities**, with each port's +binding or **Use in flow** for it. A reported screen becomes a canvas preset in +the dashboard settings, named after the device, and the first report of a +touch screen turns **Touch friendly** on. + +### Before device nodes Before device nodes a device carried its ports itself. On its first start an instance moves each one into the flow that declares its message, one device diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 64975b2..b556d54 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -40,8 +40,9 @@ streaming toggle. A **Device** node stands for a paired device instead: pick the device, then its ports under **Publishes** and **Follows** (see [the device's own ports](dashboards.md#the-devices-own-ports)). A device that -reports its ports offers them in a list with their type filled in, and the -command palette offers each one as a shortcut, such as *Hallway camera*. +reports its ports offers them in a list with their type filled in and a form +for the settings each one reads, and the command palette offers each one as a +shortcut, such as *Hallway camera*. Message fields suggest existing names. Matching an output's message is how you connect an input. @@ -156,7 +157,8 @@ conflict. Reload and review their changes before retrying. The canvas reports unconnected inputs, missing starting values, cycles and code-load errors. These prevent the affected flow from running. Unauthenticated webhooks are advisory; runtime node-health problems clear when the node -recovers. See [Flow issues](../concepts/flows.md#when-a-flow-needs-attention). +recovers. A degraded node, such as a device node whose device is offline, is +marked amber: it is a warning, and the flow keeps running. See [Flow issues](../concepts/flows.md#when-a-flow-needs-attention). ## See also diff --git a/docs/interface/index.md b/docs/interface/index.md index 74b65cf..7482f83 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -57,7 +57,8 @@ a small sketch of each layout; open one for its live readings. Health shows flow status, node load errors, queue depth and event-loop lag. After you have used batch runs, it also shows running and queued runs. -A degraded instance lists the problems that need attention. +A degraded instance lists the problems that need attention. Warnings, such as +a device that is offline, are listed in amber and leave the status as it is. ### Execution time per node type diff --git a/docs/reference/node-types.md b/docs/reference/node-types.md index 40b9627..400d08e 100644 --- a/docs/reference/node-types.md +++ b/docs/reference/node-types.md @@ -400,9 +400,10 @@ never wake anything. Each port is named by the device, such as `gpio.4` or | Setting | Default | Notes | |---|---|---| | `device` | | the device's id | -| `config` | `{}` | per port, a JSON object handed to the device unread | +| `config` | `{}` | per port, a JSON object handed to the device unread; a reported port's settings get a form | -The node is down, *Offline*, while the device is not connected. +The node is degraded, *Offline*, while the device is not connected: amber on +the canvas and a warning on Home, never a fault. ## Connectors diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index f1767a3..5a3196c 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1137,6 +1137,18 @@ export const DevicePublicSchema = { } ], title: 'Report' + }, + reported_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Reported At' } }, type: 'object', @@ -1932,6 +1944,14 @@ export const HealthSummarySchema = { type: 'array', title: 'Problems' }, + warnings: { + items: { + type: 'string' + }, + type: 'array', + title: 'Warnings', + default: [] + }, flows: { additionalProperties: { type: 'integer' @@ -4727,7 +4747,7 @@ export const ValidationIssueSchema = { properties: { code: { type: 'string', - enum: ['cycle', 'unconnected_input', 'missing_initial_value', 'node_error', 'unauthenticated_hook', 'self_loop_needs_initial', 'missing_source', 'node_unhealthy', 'ignored_worker', 'unknown_device', 'device_port'], + enum: ['cycle', 'unconnected_input', 'missing_initial_value', 'node_error', 'unauthenticated_hook', 'self_loop_needs_initial', 'missing_source', 'node_unhealthy', 'node_degraded', 'ignored_worker', 'unknown_device', 'device_port'], title: 'Code' }, message: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index de0ce37..6caf50a 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsDuplicateDashboardData, DashboardsDuplicateDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, DevicesStartPairingData, DevicesStartPairingResponse, DevicesPollPairingData, DevicesPollPairingResponse, DevicesReadDevicesResponse, DevicesReadPendingResponse, DevicesPendingDeviceData, DevicesPendingDeviceResponse, DevicesCreateDeviceData, DevicesCreateDeviceResponse, DevicesReadDeviceData, DevicesReadDeviceResponse, DevicesSaveDeviceData, DevicesSaveDeviceResponse, DevicesDeleteDeviceData, DevicesDeleteDeviceResponse, DevicesApprovePairingData, DevicesApprovePairingResponse, DevicesUnpairDeviceData, DevicesUnpairDeviceResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsDuplicateFlowData, FlowsDuplicateFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadNodeTypeTimingsData, ObservabilityReadNodeTypeTimingsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsDuplicateDashboardData, DashboardsDuplicateDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, DevicesStartPairingData, DevicesStartPairingResponse, DevicesPollPairingData, DevicesPollPairingResponse, DevicesReadDevicesResponse, DevicesReadPendingResponse, DevicesPendingDeviceData, DevicesPendingDeviceResponse, DevicesCreateDeviceData, DevicesCreateDeviceResponse, DevicesReadDeviceData, DevicesReadDeviceResponse, DevicesSaveDeviceData, DevicesSaveDeviceResponse, DevicesDeleteDeviceData, DevicesDeleteDeviceResponse, DevicesPutReportData, DevicesPutReportResponse, DevicesApprovePairingData, DevicesApprovePairingResponse, DevicesUnpairDeviceData, DevicesUnpairDeviceResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsDuplicateFlowData, FlowsDuplicateFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadNodeTypeTimingsData, ObservabilityReadNodeTypeTimingsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AiService { /** @@ -1006,6 +1006,35 @@ export class DevicesService { }); } + /** + * Put Report + * A device says what it has: its board, its screen, its ports. + * + * Only the device itself, through its own credential, which ``_device_may`` + * holds to its own id. The report replaces the one before it, and the flows + * are validated again, since a device node's ports are checked against it. + * + * The first report of a touch screen makes the device touch friendly, as + * the switch would have been set by hand. Later ones leave the setting to + * whoever changed it since. + * @param data The data for the request. + * @param data.deviceId + * @returns Message Successful Response + * @throws ApiError + */ + public static putReport(data: DevicesPutReportData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/devices/{device_id}/report', + path: { + device_id: data.deviceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Approve Pairing * Say which device the one showing this code, or making this request, is. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 526e95c..aaae749 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -301,6 +301,7 @@ export type DevicePublic = { report?: ({ [key: string]: unknown; } | null); + reported_at?: (string | null); }; /** @@ -657,6 +658,7 @@ export type fluksio__flow__schemas__MessageValue = { export type HealthSummary = { status: string; problems: Array<(string)>; + warnings?: Array<(string)>; flows: { [key: string]: (number); }; @@ -1514,7 +1516,7 @@ export type ValidationError = { * Something wrong with a flow — a fault, or merely advisory. */ export type ValidationIssue = { - code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'ignored_worker' | 'unknown_device' | 'device_port'; + code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'node_degraded' | 'ignored_worker' | 'unknown_device' | 'device_port'; message: string; flow?: string; nodes?: Array<(string)>; @@ -1524,7 +1526,7 @@ export type ValidationIssue = { advisory?: boolean; }; -export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'ignored_worker' | 'unknown_device' | 'device_port'; +export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'node_degraded' | 'ignored_worker' | 'unknown_device' | 'device_port'; export type ValidationResult = { issues?: Array; @@ -1875,6 +1877,12 @@ export type DevicesDeleteDeviceData = { export type DevicesDeleteDeviceResponse = (Message); +export type DevicesPutReportData = { + deviceId: string; +}; + +export type DevicesPutReportResponse = (Message); + export type DevicesApprovePairingData = { deviceId: string; requestBody: PairRequest; diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 5b55568..bbc07f3 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -4,6 +4,8 @@ import { useState } from "react" import type { MessageInfo, SettingDef, WidgetDef } from "@/client" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" +import { screensOf } from "@/components/Devices/ports" +import { devicesQueryOptions } from "@/components/Devices/queries" import { FieldLabel, InfoTip, @@ -1934,6 +1936,16 @@ export function DashboardPanel({ const background = settingOf(dashboard, "background") const paletteSetting = settingOf(dashboard, "palette") const palette = parsePalette(paletteSetting.value) + const { data: devices } = useQuery({ + ...devicesQueryOptions(), + enabled: open, + }) + // The screens devices reported come first and take the place of a named + // preset of the same size: that is the screen this is drawn for. + const presets = [...screensOf(devices?.data), ...CANVAS_PRESETS].filter( + (preset, index, all) => + all.findIndex((one) => sizeKey(one) === sizeKey(preset)) === index, + ) /** Settings are a map, so one of them changing rewrites the whole of it. */ const setSetting = (name: SettingName, setting: SettingDef) => @@ -2012,7 +2024,7 @@ export function DashboardPanel({ > place.mutate(flowName)}> diff --git a/frontend/src/components/Devices/ports.ts b/frontend/src/components/Devices/ports.ts index 71a7743..fbac94b 100644 --- a/frontend/src/components/Devices/ports.ts +++ b/frontend/src/components/Devices/ports.ts @@ -3,23 +3,64 @@ import type { DevicePublic, DType, MessageSpec, NodeDef_Input } from "@/client" /** The node type a device is in a flow. */ export const DEVICE_TYPE = "device" -/** Port names the panel firmware knows; `gpio.` takes a pin number. */ -// ponytail: free-text ports until a device reports what it has (D2, PUT /devices/{id}/report) -export const PORT_SUGGESTIONS = [ - "gpio.", - "backlight", - "touch", - "mic", - "speaker", -] - /** One port as a device reports it. */ export type ReportedPort = { port: string + /** Its default direction. */ direction: "in" | "out" dtype?: DType + /** A JSON schema of the config the device reads for it. */ + config?: Record | null + on_demand?: boolean + /** The directions it may be wired in, when it may be either: a GPIO pin. */ + modes?: ("in" | "out")[] | null } +/** What a device says it has, `PUT /devices/{id}/report` (schema 1). */ +export type DeviceReport = { + schema: number + platform?: string + board?: string + firmware?: string + display?: { + w: number + h: number + shape?: "rect" | "round" + touch?: boolean + } | null + camera?: { sensor?: string; w?: number; h?: number; fps?: number } | null + features?: Record +} + +/** The device's report, or null before it has made one. */ +export function reportOf(device: DevicePublic | undefined) { + const report = device?.report as DeviceReport | null | undefined + return report && typeof report === "object" ? report : null +} + +/** + * Each screen a device reported, as a canvas preset named after the device: + * a dashboard drawn for it then shows at 1:1 there. + */ +export function screensOf(devices: DevicePublic[] = []) { + return devices.flatMap((device) => { + const display = reportOf(device)?.display + if (!display?.w || !display.h) return [] + return [ + { + label: device.title || device.id, + width: display.w, + height: display.h, + ...(display.shape === "round" ? { shape: "round" as const } : {}), + }, + ] + }) +} + +/** Whether a reported port may be wired in `direction`. */ +export const wiresAs = (port: ReportedPort, direction: "in" | "out") => + (port.modes?.length ? port.modes : [port.direction]).includes(direction) + /** What the server accepts as a port name. */ export const portOf = (value: string) => value.toLowerCase().replace(/[^a-z0-9_.]/g, "") @@ -31,8 +72,7 @@ export const portOf = (value: string) => * are free text. */ export function reportedPorts(device: DevicePublic | undefined) { - const ports = (device?.report as { ports?: unknown } | null | undefined) - ?.ports + const ports = (reportOf(device) as { ports?: unknown } | null)?.ports if (!Array.isArray(ports)) return null return ports.filter( (port): port is ReportedPort => diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index c67c0c4..c2e435a 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -424,18 +424,26 @@ function FlowEditorInner({ [nodeTypeInfo], ) - const issuesByNode = useMemo(() => { - const map = new Map() + const [issuesByNode, warningsByNode] = useMemo(() => { + const faults = new Map() + const warnings = new Map() for (const issue of issues) { - // A node's dot and border are its status channel, and status has no - // warning tier — an advisory is not a fault, so it says its piece in the - // dock rather than painting the node red. - if (!issue.node || issue.advisory) continue + // A node's dot and border are its status channel. An advisory is not a + // fault, so it says its piece in the dock rather than painting the node + // red. A degraded node, a device that is offline, is the one warning + // the node itself wears, in amber. + if (!issue.node) continue + const map = !issue.advisory + ? faults + : issue.code === "node_degraded" + ? warnings + : null + if (!map) continue const list = map.get(issue.node) ?? [] list.push(issue.message) map.set(issue.node, list) } - return map + return [faults, warnings] }, [issues]) const showLogs = useCallback((nodeId: string) => { @@ -449,6 +457,7 @@ function FlowEditorInner({ canvasNodes.map((node) => { const definition = definitions.find((entry) => entry.id === node.id) const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? [] + const nodeWarnings = warningsByNode.get(`${flowName}.${node.id}`) ?? [] return { ...node, selected: node.selected || node.id === selectedId, @@ -459,6 +468,7 @@ function FlowEditorInner({ typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", isPlugin: pluginTypes.has(definition?.type ?? ""), issueText: nodeIssues.join("\n"), + warningText: nodeWarnings.join("\n"), onShowLogs: showLogs, } satisfies FlowNodeData, } @@ -468,6 +478,7 @@ function FlowEditorInner({ definitions, flowName, issuesByNode, + warningsByNode, pluginTypes, selectedId, showLogs, diff --git a/frontend/src/components/Flow/FlowNode.tsx b/frontend/src/components/Flow/FlowNode.tsx index 8d414f6..7dd0c71 100644 --- a/frontend/src/components/Flow/FlowNode.tsx +++ b/frontend/src/components/Flow/FlowNode.tsx @@ -77,6 +77,8 @@ const STATUS_STYLES = { }, running: { dot: "bg-primary animate-pulse", label: "Running" }, success: { dot: "bg-status-success", label: "Last run succeeded" }, + // Works, if not as it should: a device that is offline. Not a fault. + warning: { dot: "bg-status-warning", label: "Degraded" }, error: { dot: "bg-destructive", label: "Something went wrong" }, } as const @@ -86,6 +88,8 @@ export type FlowNodeData = { typeLabel: string isPlugin?: boolean issueText: string + /** Why the node is degraded, if it is: amber, never a fault. */ + warningText?: string /** Open the logs at this node's own lines. */ onShowLogs?: (nodeId: string) => void [key: string]: unknown @@ -126,8 +130,15 @@ function PortHandles({ } function FlowNodeComponent({ data, selected }: NodeProps) { - const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } = - data as FlowNodeData + const { + definition, + flow, + typeLabel, + isPlugin, + issueText, + warningText, + onShowLogs, + } = data as FlowNodeData const nodeId = `${flow}.${definition.id}` const live = useNodeStatus(nodeId) const emits = useNodeEmits(nodeId) @@ -174,7 +185,12 @@ function FlowNodeComponent({ data, selected }: NodeProps) { .join("\n") .split("\n") const problem = [...new Set(lines)].join("\n") - const status = problem ? "error" : live?.status + const warning = problem ? "" : (warningText ?? "") + const status = problem + ? "error" + : warning && live?.status !== "running" + ? "warning" + : live?.status const running = status === "running" const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES] // Failed at some point, fine as of the last run. It gets no dot and no @@ -209,6 +225,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) { // Selection wins the border when it is both — the dot is the status // channel, and it is still red underneath. problem && "border-destructive", + status === "warning" && "border-status-warning", selected && "border-primary shadow-e2", )} > @@ -318,7 +335,10 @@ function FlowNodeComponent({ data, selected }: NodeProps) { /> - {problem || live?.detail || style.label} + {problem || + (status === "warning" ? warning : "") || + live?.detail || + style.label} ) : null} diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index de6e7c6..5eb533b 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -27,10 +27,10 @@ import { } from "@/client" import { DEVICE_TYPE, - PORT_SUGGESTIONS, portOf, type ReportedPort, reportedPorts, + wiresAs, } from "@/components/Devices/ports" import { devicesQueryOptions } from "@/components/Devices/queries" import { Button } from "@/components/ui/button" @@ -637,8 +637,7 @@ function DevicePortList({ onChange: (next: MessageSpec[], renamed?: [string, string | null]) => void onConfig: (port: string, next: Record) => void }) { - const offered = reported?.filter((port) => port.direction === direction) - const listId = `device-ports-${direction}` + const offered = reported?.filter((port) => wiresAs(port, direction)) // An out port is read when the device asks for it, never a reason to run. const fresh = (port: ReportedPort | null): MessageSpec => ({ name: port ? port.port.replace(/\./g, "_") : "", @@ -670,17 +669,19 @@ function DevicePortList({ Add - - {PORT_SUGGESTIONS.map((port) => ( - {specs.length === 0 ? (

No ports yet.

) : null} {specs.map((spec, index) => { const port = spec.port ?? "" const name = port || "new port" + const said = reported?.find((one) => one.port === port) + // The device says which settings it reads, so they get a form. + const schema = + said?.config && + Object.keys((said.config.properties as object) ?? {}).length + ? said.config + : undefined return (
@@ -730,7 +731,6 @@ function DevicePortList({ placeholder="Port, e.g. gpio.4" aria-label={`Port of ${name}`} autoComplete="off" - list={listId} className="h-8 min-w-0 flex-1 font-mono text-sm" onBlur={(event) => { const next = portOf(event.currentTarget.value) @@ -792,16 +792,35 @@ function DevicePortList({ autoFocus={false} onChange={(next) => update(index, { name: next })} /> - { - if (port) onConfig(port, next as Record) - }} - /> + {schema ? null : ( + { + if (port) onConfig(port, next as Record) + }} + /> + )}
+ {reported && port && !said ? ( +

+ The device does not report this port. +

+ ) : null} + {schema ? ( +
+ } + provides={[]} + onChange={(next) => onConfig(port, next)} + /> +
+ ) : null}
) })} @@ -1120,12 +1139,14 @@ function InjectPayloads({ /** A small form built from the node type's declared parameters. */ function ParamsForm({ type, + title = "Settings", schema, params, provides, onChange, }: { type: string | undefined + title?: string schema: Record | undefined params: Record /** The ports an inject's payload fields follow; unused by every other type. */ @@ -1139,6 +1160,7 @@ function ParamsForm({ title?: string description?: string default?: unknown + enum?: unknown[] "x-secret"?: boolean } > @@ -1161,7 +1183,7 @@ function ParamsForm({ return (
- Settings + {title} {entries.map(([key, property]) => { // In the schema's own place, so the payload keeps its position in the // form even though it is now one field per port. @@ -1261,6 +1283,37 @@ function ParamsForm({ ) } + // A closed set of choices, such as a pin's pull. + if (property.enum?.length) { + return ( +
+ + {label} + + +
+ ) + } + const numeric = property.type === "integer" || property.type === "number" return ( diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index 6d913cf..e80325d 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -85,7 +85,7 @@ type FlowEvent = type: "node_health" flow?: string node: string - health: "ok" | "down" | "unknown" + health: "ok" | "degraded" | "down" | "unknown" detail?: string | null ts?: number } @@ -335,7 +335,9 @@ function open(url: string) { // or a rebuild, never when health actually flipped. The store had a // health map of its own and nothing ever read it. invalidateHealth(message.flow) - if (message.health === "down") { + // Degraded is held back by the same floor, as a warning rather than + // a fault. + if (message.health === "down" || message.health === "degraded") { // This fires at the transition, while the floor is still hiding the // node, and the engine publishes nothing when the floor expires — so // read again ourselves at that moment, or the issue waits for an @@ -355,7 +357,11 @@ function open(url: string) { type: message.type, flow: message.flow, node: message.node, - detail: message.detail ?? "Reported itself down.", + detail: + message.detail ?? + (message.health === "down" + ? "Reported itself down." + : "Reported itself degraded."), ts: message.ts ?? Date.now() / 1000, }) } else { diff --git a/frontend/src/components/Health/HealthOverview.tsx b/frontend/src/components/Health/HealthOverview.tsx index 81c5571..d856053 100644 --- a/frontend/src/components/Health/HealthOverview.tsx +++ b/frontend/src/components/Health/HealthOverview.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" +import { TriangleAlert } from "lucide-react" import { type Range, RangePicker } from "@/components/Common/RangePicker" import { PANEL_SECTION } from "@/components/Flow/SidePanel" @@ -96,6 +97,17 @@ export function HealthOverview({ {summary.problems.join(" · ")}

) : null} + {/* Worth a look and not a fault, such as a device that is offline: the + badge above stays as it is. */} + {summary?.warnings?.length ? ( +

+ + {summary.warnings.join(" · ")} +

+ ) : null}