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:
co-authored by
Claude Opus 5.5
parent
a9d453c9d9
commit
ad76200d3d
@@ -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>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user