Device node: a device's ports are wired in flows

- flow/nodes/device.py: `device` node with params `device` and `config`;
  provides the device's `in` ports, requires its `out` ports without
  waking anything, never run (Node.passive), down "Offline" from presence
- DeviceDef.io and DevicePort go; GET /devices/{id} computes `io` and
  `bindings` from the device nodes of published flows, in a fixed order,
  cached by FlowStore.revision; the credential's scope comes from them
- issues `unknown_device` and `device_port` (wired twice; against a report
  the wrong direction or dtype, or unlisted as advisory), rechecked when a
  device is created or deleted; ValidationIssue.advisory is a field
- a device publishing on a bound port is credited to its node by the
  server; the panel endpoint kind is gone from `_endpoints` and the canvas
- a rebuild that moves a device's bindings sends `dashboard_changed` with
  an empty name, which the firmware refetches on
- startup moves each device's `io` rows into device nodes in the flows
  declaring their messages, draft and published, one commit; rows no
  flow declares are logged and left out
- frontend: Device node settings (device, Publishes, Follows, a port
  picker from a report), palette shortcuts per reported port, DeviceDetail
  lists bindings with "Use in flow" in place of the I/O editor
- docs: devices, flow editor, node types, issues, API
This commit is contained in:
Melvin Strobl
2026-10-16 16:40:31 +02:00
parent 91e3b703f9
commit 29d3ec467f
35 changed files with 2195 additions and 494 deletions
@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import type { FlowSummary, NodeTypeInfo } from "@/client"
import type { DevicePublic, FlowSummary, NodeTypeInfo } from "@/client"
import { type ReportedPort, reportedPorts } from "@/components/Devices/ports"
import { devicesQueryOptions } from "@/components/Devices/queries"
import {
CommandDialog,
CommandEmpty,
@@ -23,6 +25,7 @@ export function CommandPalette({
flows,
onAddNode,
onAddSharedNode,
onAddDevicePort,
onRun,
}: {
open: boolean
@@ -32,6 +35,8 @@ export function CommandPalette({
onAddNode: (type: string) => void
/** Absent for an account that may not add code, which a shared node is. */
onAddSharedNode?: (libName: string) => void
/** One port a device reports, onto this flow's node for that device. */
onAddDevicePort: (device: DevicePublic, port: ReportedPort) => void
onRun: () => void
}) {
const navigate = useNavigate()
@@ -39,6 +44,10 @@ export function CommandPalette({
...libraryQueryOptions(),
enabled: open && Boolean(onAddSharedNode),
})
const { data: devicesData } = useQuery({
...devicesQueryOptions(),
enabled: open,
})
// Picking an item re-renders the canvas, which can interrupt the dialog's
// exit animation and leave its overlay swallowing clicks. Unmounting the
@@ -46,6 +55,11 @@ export function CommandPalette({
if (!open) return null
const library = onAddSharedNode ? (libraryData ?? []) : []
// A shortcut per port a device says it has, "Hallway camera". A device that
// has not said is added as a Device node and its ports typed in.
const capabilities = (devicesData?.data ?? []).flatMap((device) =>
(reportedPorts(device) ?? []).map((port) => ({ device, port })),
)
const close = (action: () => void) => {
onOpenChange(false)
@@ -102,6 +116,29 @@ export function CommandPalette({
</CommandGroup>
) : null}
{capabilities.length > 0 ? (
<CommandGroup heading="Devices">
{capabilities.map(({ device, port }) => {
const title = `${device.title || device.id} ${port.port}`
return (
<CommandItem
key={`${device.id}:${port.port}`}
value={`device ${title}`}
onSelect={() => close(() => onAddDevicePort(device, port))}
>
<span className="flex flex-col">
<span>{title}</span>
<span className="text-xs text-muted-foreground">
{port.direction === "in" ? "Publishes" : "Follows"}{" "}
{port.dtype ?? "a value"}
</span>
</span>
</CommandItem>
)
})}
</CommandGroup>
) : null}
{flows.length > 0 ? (
<CommandGroup heading="Flows">
{flows.map((flow) => (
@@ -1,11 +1,5 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import {
LayoutDashboard,
LogIn,
LogOut,
MonitorSmartphone,
Workflow,
} from "lucide-react"
import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useIsMobile } from "@/hooks/useMobile"
@@ -16,8 +10,6 @@ import { useLiveValues } from "./liveStore"
const KIND_ICONS = {
dashboard: LayoutDashboard,
// The same glyph the dashboards screen opens its panels with.
panel: MonitorSmartphone,
flow: Workflow,
input: LogIn,
output: LogOut,
+42 -6
View File
@@ -25,6 +25,7 @@ import { motion } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
type DevicePublic,
type FlowDef_Input,
type FlowDetail,
FlowsService,
@@ -33,6 +34,12 @@ import {
RunsService,
} from "@/client"
import { DiscardDraftDialog } from "@/components/Common/Dock"
import {
DEVICE_TYPE,
deviceNodeOf,
type ReportedPort,
withPort,
} from "@/components/Devices/ports"
import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import {
@@ -511,12 +518,6 @@ function FlowEditorInner({
to: "/dashboards/$name",
params: { name: (rest ?? "").split(":")[0] },
})
} else if (kind === "panel") {
// A device's own port: its page in the Devices tab.
navigate({
to: "/devices/$id",
params: { id: (rest ?? "").split(":")[0] },
})
} else if (kind === "flow") {
navigate({
to: "/flows/$flowName",
@@ -940,6 +941,40 @@ function FlowEditorInner({
[commit, definitions],
)
/**
* A port a device reports, on this flow's node for that device. One node per
* device keeps its ports together, so it is made only when there is none.
*/
const addDevicePort = useCallback(
(device: DevicePublic, port: ReportedPort) => {
const existing = deviceNodeOf(definitions, device.id)
if (existing) {
updateNode(withPort(existing, port))
setSelectedId(existing.id)
return
}
const id = uniqueNodeId(definitions, device.id)
const node = withPort(
{
id,
type: DEVICE_TYPE,
title: device.title || device.id,
params: { device: device.id, config: {} },
requires: [],
provides: [],
},
port,
)
setCanvasNodes([
...canvasNodes,
{ id, type: "flow", position: { x: 0, y: 0 }, data: {} },
])
commit([...definitions, node])
setSelectedId(id)
},
[canvasNodes, commit, definitions, setCanvasNodes, updateNode],
)
const deleteNodes = useCallback(
(ids: string[]) => {
const remaining = definitions.filter((node) => !ids.includes(node.id))
@@ -1510,6 +1545,7 @@ function FlowEditorInner({
onAddSharedNode={
mayEditCode ? (libName) => addNode("python", libName) : undefined
}
onAddDevicePort={addDevicePort}
onRun={startRun}
/>
+4 -2
View File
@@ -12,6 +12,7 @@ import {
Filter,
Globe,
Merge,
MonitorSmartphone,
Play,
Plug,
Radio,
@@ -60,6 +61,8 @@ const NODE_ICONS = {
file: FileText,
ntfy: Bell,
wren: Bird,
// The glyph the Devices tab goes by.
device: MonitorSmartphone,
} as const
// One dot says everything about a node's state. Idle nodes carry no dot at all,
@@ -159,8 +162,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
const Icon =
NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ??
// A connector's own type cannot be in the map above, and a device is
// not a piece of code.
// A connector's own type cannot be in the map above.
(isPlugin ? Plug : Code2)
// Whatever is wrong — it failed to load, it failed to run, or the graph
+366 -31
View File
@@ -25,6 +25,14 @@ import {
type NodeDef_Input,
type NodeTypeInfo,
} from "@/client"
import {
DEVICE_TYPE,
PORT_SUGGESTIONS,
portOf,
type ReportedPort,
reportedPorts,
} from "@/components/Devices/ports"
import { devicesQueryOptions } from "@/components/Devices/queries"
import { Button } from "@/components/ui/button"
import {
Command,
@@ -485,6 +493,322 @@ function PortList({
)
}
const objectOnly = (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? null
: "Config is a JSON object"
/**
* A device node: the device it stands for, and the message each of its ports
* carries. A port the device publishes is an output here, one it follows an
* input that wakes nothing. With a report the ports are picked from what the
* device has, without one they are typed in.
*/
function DeviceSettings({
node,
flow,
suggestions,
onChange,
}: {
node: NodeDef_Input
flow: string
suggestions: PortSuggestions
onChange: (next: NodeDef_Input) => void
}) {
const { data } = useQuery(devicesQueryOptions())
const devices = data?.data ?? []
const id = String(node.params?.device ?? "")
const device = devices.find((one) => one.id === id)
const reported = reportedPorts(device)
const config = (node.params?.config ?? {}) as Record<string, unknown>
const titleOf = (one?: { id: string; title?: string }) =>
one ? one.title || one.id : ""
/** The ports of one side, with the config following a renamed port. */
const ports =
(side: "provides" | "requires") =>
(specs: MessageSpec[], renamed?: [string, string | null]) => {
const next = { ...config }
if (renamed) {
const [from, to] = renamed
if (to && from in next) next[to] = next[from]
delete next[from]
}
onChange({
...node,
params: { ...node.params, config: next },
[side]: specs,
})
}
/** One port's config. An empty one is no config, and is not kept. */
const setConfig = (port: string, value: Record<string, unknown>) => {
const next = { ...config, [port]: value }
if (Object.keys(value).length === 0) delete next[port]
onChange({ ...node, params: { ...node.params, config: next } })
}
return (
<>
<div className="grid gap-1.5">
<FieldLabel htmlFor="device-node-device">Device</FieldLabel>
<Select
value={id || undefined}
onValueChange={(next) => {
const picked = devices.find((one) => one.id === next)
// A title that only ever named the device follows it.
const title =
!node.title || node.title === (titleOf(device) || id)
? titleOf(picked)
: node.title
onChange({
...node,
title,
params: { ...node.params, device: next },
})
}}
>
<SelectTrigger
id="device-node-device"
className="!h-8 w-full text-sm"
data-testid="device-node-device"
>
<SelectValue placeholder="Pick a device" />
</SelectTrigger>
<SelectContent>
{devices.map((one) => (
<SelectItem key={one.id} value={one.id}>
{titleOf(one)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DevicePortList
title="Publishes"
help="Ports the device sends from, such as a button. Each publishes the message beside it."
direction="in"
specs={node.provides ?? []}
flow={flow}
config={config}
reported={reported}
suggestions={suggestions.provides}
onChange={ports("provides")}
onConfig={setConfig}
/>
<DevicePortList
title="Follows"
help="Ports the device drives, such as its backlight. Each follows the message beside it."
direction="out"
specs={node.requires ?? []}
flow={flow}
config={config}
reported={reported}
suggestions={suggestions.consumes}
onChange={ports("requires")}
onConfig={setConfig}
/>
</>
)
}
function DevicePortList({
title,
help,
direction,
specs,
flow,
config,
reported,
suggestions,
onChange,
onConfig,
}: {
title: string
help: string
direction: "in" | "out"
specs: MessageSpec[]
flow: string
/** Per port, what the device is handed as it is. */
config: Record<string, unknown>
/** What the device says it has, or null when it has not said. */
reported: ReportedPort[] | null
suggestions: string[]
/** `renamed` is a port taking another name, or dropped (`to` of null). */
onChange: (next: MessageSpec[], renamed?: [string, string | null]) => void
onConfig: (port: string, next: Record<string, unknown>) => void
}) {
const offered = reported?.filter((port) => port.direction === direction)
const listId = `device-ports-${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, "_") : "",
port: port?.port ?? "",
dtype: port?.dtype ?? "float",
...(direction === "out" ? { trigger: false } : {}),
})
const update = (index: number, patch: Partial<MessageSpec>) =>
onChange(
specs.map((spec, at) => (at === index ? { ...spec, ...patch } : spec)),
)
return (
<div
className="grid min-w-0 gap-3"
data-testid={`device-ports-${direction}`}
>
<div className="flex items-center justify-between">
<span className="flex items-center gap-1">
<span className={SECTION}>{title}</span>
<InfoTip label={title}>{help}</InfoTip>
</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => onChange([...specs, fresh(null)])}
>
Add
</Button>
</div>
<datalist id={listId}>
{PORT_SUGGESTIONS.map((port) => (
<option key={port} value={port} />
))}
</datalist>
{specs.length === 0 ? (
<p className="text-sm text-muted-foreground">No ports yet.</p>
) : null}
{specs.map((spec, index) => {
const port = spec.port ?? ""
const name = port || "new port"
return (
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
{offered ? (
<Select
value={port || undefined}
onValueChange={(next) => {
// The device says what it carries, so the type follows.
const picked = offered.find((one) => one.port === next)
onChange(
specs.map((one, at) =>
at === index
? {
...one,
port: next,
dtype: picked?.dtype ?? one.dtype,
}
: one,
),
port ? [port, next] : undefined,
)
}}
>
<SelectTrigger
className="!h-8 min-w-0 flex-1 font-mono text-sm"
aria-label={`Port of ${name}`}
>
<SelectValue placeholder="Pick a port" />
</SelectTrigger>
<SelectContent>
{/* One the report has dropped stays pickable as it is. */}
{port && !offered.some((one) => one.port === port) ? (
<SelectItem value={port}>{port}</SelectItem>
) : null}
{offered.map((one) => (
<SelectItem key={one.port} value={one.port}>
{one.port}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
// Remounted with whatever was saved, so a refused name snaps back.
key={port}
defaultValue={port}
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)
event.currentTarget.value = next || port
if (next && next !== port)
onChange(
specs.map((one, at) =>
at === index ? { ...one, port: next } : one,
),
port ? [port, next] : undefined,
)
}}
/>
)}
<Select
value={spec.dtype ?? "float"}
onValueChange={(value) =>
update(index, { dtype: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[104px] text-sm"
aria-label={`Type of ${name}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label={`Remove ${name}`}
onClick={() =>
onChange(
specs.filter((_, at) => at !== index),
port ? [port, null] : undefined,
)
}
>
<X />
</Button>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<CornerDownRight
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`message in ${flow}`}
autoFocus={false}
onChange={(next) => update(index, { name: next })}
/>
<JsonField
value={config[port] ?? {}}
label={`Config of ${name}`}
check={objectOnly}
className="h-8 w-24 shrink-0"
onChange={(next) => {
if (port) onConfig(port, next as Record<string, unknown>)
}}
/>
</div>
</div>
)
})}
</div>
)
}
/** The value types a free-form setting can hold, and how to read one back. */
const FREE_TYPES = ["text", "number", "on/off", "json"] as const
type FreeType = (typeof FREE_TYPES)[number]
@@ -1572,37 +1896,48 @@ function PanelBody({
"md:w-[400px] md:shrink-0 md:overflow-y-auto md:border-l md:border-border",
)}
>
<PortList
title="Consumes"
specs={node.requires ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => editNode({ ...node, requires })}
/>
<PortList
title="Provides"
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => {
const [from, to] = portMove.current ?? ["", null]
portMove.current = null
editNode(repoint({ ...node, provides }, from, to))
}}
streamable
// Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed.
onRenamed={renamePort}
onPortRenamed={(from, to) => {
portMove.current = [from, to]
}}
onRemoved={(spec) => {
portMove.current = [portName(spec), null]
}}
/>
{settingsAreCode ? (
{node.type === DEVICE_TYPE ? (
<DeviceSettings
node={node}
flow={flow}
suggestions={suggestions}
onChange={editNode}
/>
) : (
<>
<PortList
title="Consumes"
specs={node.requires ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => editNode({ ...node, requires })}
/>
<PortList
title="Provides"
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => {
const [from, to] = portMove.current ?? ["", null]
portMove.current = null
editNode(repoint({ ...node, provides }, from, to))
}}
streamable
// Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed.
onRenamed={renamePort}
onPortRenamed={(from, to) => {
portMove.current = [from, to]
}}
onRemoved={(spec) => {
portMove.current = [portName(spec), null]
}}
/>
</>
)}
{node.type === DEVICE_TYPE ? null : settingsAreCode ? (
/* An exec node's command is its code, so the whole form is. */
<fieldset disabled className="grid min-w-0 gap-2">
{paramsForm}
+16 -6
View File
@@ -1,14 +1,15 @@
import type { Edge, Node as FlowCanvasNode } from "@xyflow/react"
import type { Endpoint, FlowDef_Input, NodeDef_Input } from "@/client"
import { DEVICE_TYPE } from "@/components/Devices/ports"
import type { FlowEdgeData } from "./deriveEdges"
import { displayName, portOf, qualify } from "./deriveEdges"
/**
* Canvas elements for things wired into a flow that are not nodes in it.
*
* A dashboard control setting one of its messages, a tile showing one, a port
* on a panel's own hardware, or a node in another flow across a dotted name. Without these the canvas shows a
* A dashboard control setting one of its messages, a tile showing one, or a
* node in another flow across a dotted name. Without these the canvas shows a
* value changing with nothing to account for it — and pulses whichever node
* happens to be drawn as a producer, which did nothing.
*
@@ -21,7 +22,7 @@ import { displayName, portOf, qualify } from "./deriveEdges"
export const ENDPOINT_TYPE = "endpoint"
/** The id prefixes an endpoint may carry, none of which is a node id. */
const PREFIXES = ["dashboard:", "panel:", "flow:", "input:", "output:"]
const PREFIXES = ["dashboard:", "flow:", "input:", "output:"]
export type EndpointNodeData = {
label: string
@@ -58,8 +59,9 @@ export function publisherOf(endpointId: string): string {
* rather than from the server, so declaring one shows up on the canvas before
* it has been published.
*
* An input a dashboard or another flow already accounts for is left out: the
* label exists so a value has a visible origin, and it already has one.
* An input a dashboard, another flow or a device node already accounts for is
* left out: the label exists so a value has a visible origin, and it already
* has one.
*/
function boundaryOf(
doc: FlowDef_Input,
@@ -115,7 +117,15 @@ export function deriveEndpoints(
flow: string,
doc: FlowDef_Input,
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
const accounted = new Set(endpoints.flatMap((one) => one.provides ?? []))
const accounted = new Set([
...endpoints.flatMap((one) => one.provides ?? []),
// A device publishing on a port is where that value comes from.
...definitions
.filter((node) => node.type === DEVICE_TYPE)
.flatMap((node) =>
(node.provides ?? []).map((spec) => qualify(flow, spec.name ?? "")),
),
])
const all = [...boundaryOf(doc, flow, accounted), ...endpoints]
if (all.length === 0) return { nodes: [], edges: [] }