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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user