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
+149 -28
View File
@@ -1,6 +1,13 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, X } from "lucide-react"
import {
ArrowDown,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
ArrowUp,
X,
} from "lucide-react"
import { useState } from "react"
import {
@@ -33,9 +40,13 @@ import { ago, cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
DEVICE_TYPE,
type DeviceReport,
deviceNodeOf,
freeNodeId,
type ReportedPort,
reportedPorts,
reportOf,
wiresAs,
withPort,
} from "./ports"
import {
@@ -141,7 +152,7 @@ export function DeviceDetail({ id }: { id: string }) {
/>
</section>
<section className={CARD}>
<DevicePorts device={device} canEdit={canEdit} />
<Capabilities device={device} canEdit={canEdit} />
</section>
</div>
</div>
@@ -545,57 +556,160 @@ function Pairing({
)
}
/** "1024×600, touch", or "360×360, round, touch". */
function displayText(display: NonNullable<DeviceReport["display"]>) {
return [
`${display.w}×${display.h}`,
display.shape === "round" ? "round" : null,
display.touch ? "touch" : null,
]
.filter(Boolean)
.join(", ")
}
/**
* The device's own ports as the published flows wire them: each one, the
* message it publishes or follows, and a link to the node holding it.
* Wiring is done in a flow, so this only reads.
* What the device says it has, and its ports as the published flows wire
* them: each port it reports, with the message it publishes or follows or a
* way to wire it, and any port wired that it does not report. Wiring is done
* in a flow, so this only reads.
*/
function DevicePorts({
function Capabilities({
device,
canEdit,
}: {
device: DevicePublic
canEdit: boolean
}) {
const report = reportOf(device)
const reported = reportedPorts(device)
const bindings = device.bindings ?? []
const camera = report?.camera
const features = Object.keys(report?.features ?? {}).filter(
(name) => report?.features?.[name],
)
const facts = report
? [
["Board", [report.board, report.platform].filter(Boolean).join(" · ")],
["Firmware", report.firmware],
["Display", report.display ? displayText(report.display) : "None"],
camera?.sensor
? [
"Camera",
[
camera.sensor,
camera.w ? `${camera.w}×${camera.h} at ${camera.fps} fps` : "",
]
.filter(Boolean)
.join(", "),
]
: null,
features.length ? ["Features", features.join(", ")] : null,
].filter((fact): fact is [string, string] => Boolean(fact?.[1]))
: []
// A port the device lists, then one wired that it does not, so a typo in a
// flow shows up here as well as on the canvas.
const rows = [
...(reported ?? []).map((port) => ({ port: port.port, said: port })),
...[...new Set(bindings.map((binding) => binding.port))]
.filter((port) => !reported?.some((one) => one.port === port))
.map((port) => ({ port, said: undefined })),
]
return (
<PanelSection
title="Ports"
help="Hardware on the device itself, such as a GPIO pin or its backlight. A device node in a flow wires each port to a message."
title="Capabilities"
help="What the device reported about itself when it last connected, and its ports. A device node in a flow wires each port to a message."
>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground">No ports wired yet.</p>
{report ? (
<dl
className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm"
data-testid="device-capabilities"
>
{facts.map(([term, value]) => (
<div key={term} className="contents">
<dt className="text-muted-foreground">{term}</dt>
<dd className="min-w-0 truncate">{value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-muted-foreground">
Not reported yet. A device says what it has once it is paired and
connected.
</p>
)}
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{report ? "It reports no ports." : "No ports wired yet."}
</p>
) : (
<ul className="grid gap-1" data-testid="device-bindings">
{bindings.map((binding) => {
const Way = binding.direction === "in" ? ArrowRight : ArrowLeft
const way = binding.direction === "in" ? "Publishes" : "Follows"
{rows.map(({ port, said }) => {
const wired = bindings.filter((binding) => binding.port === port)
const either = (said?.modes?.length ?? 0) > 1
const direction = said?.direction ?? wired[0]?.direction
const Way = either
? ArrowLeftRight
: direction === "in"
? ArrowRight
: ArrowLeft
const way = either
? "Publishes or follows"
: direction === "in"
? "Publishes"
: "Follows"
return (
<li
key={`${binding.flow}.${binding.node}.${binding.port}.${binding.message}`}
key={port}
className="flex min-w-0 items-center gap-2 text-sm"
data-testid="device-port"
>
<span className="w-24 shrink-0 truncate font-mono">
{binding.port}
</span>
<span className="w-24 shrink-0 truncate font-mono">{port}</span>
<Way
role="img"
aria-label={way}
className="size-4 shrink-0 text-muted-foreground"
/>
<Link
to="/flows/$flowName"
params={{ flowName: binding.flow }}
search={{ node: binding.node }}
className="min-w-0 flex-1 truncate font-mono hover:underline"
>
{binding.message}
</Link>
<span className="w-12 shrink-0 truncate text-xs text-muted-foreground">
{said?.dtype ?? ""}
</span>
<span className="flex min-w-0 flex-1 flex-col">
{wired.map((binding) => (
<Link
key={`${binding.flow}.${binding.node}.${binding.message}`}
to="/flows/$flowName"
params={{ flowName: binding.flow }}
search={{ node: binding.node }}
className="min-w-0 truncate font-mono hover:underline"
>
{binding.message}
</Link>
))}
{report && !said ? (
<span className="text-xs text-muted-foreground">
Not reported by the device
</span>
) : null}
{said &&
wired.some((binding) => !wiresAs(said, binding.direction)) ? (
<span className="text-xs text-destructive">
Wired the other way round
</span>
) : null}
</span>
{canEdit && said && wired.length === 0 ? (
<UseInFlow device={device} port={said} size="sm" />
) : null}
</li>
)
})}
</ul>
)}
{device.reported_at ? (
<p className="text-xs text-muted-foreground">
Reported {ago(device.reported_at)}
</p>
) : null}
{canEdit ? <UseInFlow device={device} /> : null}
</PanelSection>
)
@@ -609,9 +723,11 @@ function DevicePorts({
export function UseInFlow({
device,
port,
size,
}: {
device: DevicePublic
port?: ReportedPort
size?: "sm"
}) {
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
@@ -657,10 +773,15 @@ export function UseInFlow({
return (
<Select value="" onValueChange={(flowName) => place.mutate(flowName)}>
<SelectTrigger
className="justify-self-start"
aria-label={`Use ${device.title || device.id} in a flow`}
size={size}
className="shrink-0 justify-self-start"
aria-label={
port
? `Use ${port.port} of ${device.title || device.id} in a flow`
: `Use ${device.title || device.id} in a flow`
}
disabled={place.isPending}
data-testid="use-in-flow"
data-testid={port ? "use-port-in-flow" : "use-in-flow"}
>
<SelectValue placeholder="Use in flow" />
</SelectTrigger>
+52 -12
View File
@@ -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<string, unknown> | 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<string, boolean>
}
/** 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 =>