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:
@@ -911,6 +911,36 @@ export const DeadLetterSchema = {
|
||||
title: 'DeadLetter'
|
||||
} as const;
|
||||
|
||||
export const DeviceBindingSchema = {
|
||||
properties: {
|
||||
flow: {
|
||||
type: 'string',
|
||||
title: 'Flow'
|
||||
},
|
||||
node: {
|
||||
type: 'string',
|
||||
title: 'Node'
|
||||
},
|
||||
port: {
|
||||
type: 'string',
|
||||
title: 'Port'
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
title: 'Message'
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['in', 'out'],
|
||||
title: 'Direction'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['flow', 'node', 'port', 'message', 'direction'],
|
||||
title: 'DeviceBinding',
|
||||
description: 'Where a port is wired: the device node holding it.'
|
||||
} as const;
|
||||
|
||||
export const DeviceCreateSchema = {
|
||||
properties: {
|
||||
title: {
|
||||
@@ -946,13 +976,6 @@ export const DeviceDefSchema = {
|
||||
title: 'Touch',
|
||||
default: false
|
||||
},
|
||||
io: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/DevicePort'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Io'
|
||||
},
|
||||
version: {
|
||||
type: 'integer',
|
||||
title: 'Version',
|
||||
@@ -965,7 +988,59 @@ export const DeviceDefSchema = {
|
||||
description: 'One device, and what it shows.'
|
||||
} as const;
|
||||
|
||||
export const DevicePortSchema = {
|
||||
export const DeviceDocumentSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title',
|
||||
default: ''
|
||||
},
|
||||
dashboards: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Dashboards'
|
||||
},
|
||||
touch: {
|
||||
type: 'boolean',
|
||||
title: 'Touch',
|
||||
default: false
|
||||
},
|
||||
version: {
|
||||
type: 'integer',
|
||||
title: 'Version',
|
||||
default: 0
|
||||
},
|
||||
io: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/DeviceIo'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Io'
|
||||
},
|
||||
bindings: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/DeviceBinding'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Bindings'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
title: 'DeviceDocument',
|
||||
description: `A device as it reads itself: what it shows, and what its ports carry.
|
||||
|
||||
\`\`io\`\` is worked out from the device nodes in published flows, in a fixed
|
||||
order, so an unchanged state reads back as the same bytes.`
|
||||
} as const;
|
||||
|
||||
export const DeviceIoSchema = {
|
||||
properties: {
|
||||
port: {
|
||||
type: 'string',
|
||||
@@ -988,12 +1063,8 @@ export const DevicePortSchema = {
|
||||
},
|
||||
type: 'object',
|
||||
required: ['port', 'message', 'direction'],
|
||||
title: 'DevicePort',
|
||||
description: `One piece of the device's own hardware, wired to a message.
|
||||
|
||||
A wall panel is a node as well as a screen: a button on its GPIO, a
|
||||
microphone, a speaker. Which ports exist is the device's business, so the
|
||||
name is free text and \`\`config\`\` is passed through to it unread.`
|
||||
title: 'DeviceIo',
|
||||
description: 'One port as the device reads it: the message it publishes or follows.'
|
||||
} as const;
|
||||
|
||||
export const DevicePublicSchema = {
|
||||
@@ -1019,18 +1090,25 @@ export const DevicePublicSchema = {
|
||||
title: 'Touch',
|
||||
default: false
|
||||
},
|
||||
io: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/DevicePort'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Io'
|
||||
},
|
||||
version: {
|
||||
type: 'integer',
|
||||
title: 'Version',
|
||||
default: 0
|
||||
},
|
||||
io: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/DeviceIo'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Io'
|
||||
},
|
||||
bindings: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/DeviceBinding'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Bindings'
|
||||
},
|
||||
online: {
|
||||
type: 'boolean',
|
||||
title: 'Online',
|
||||
@@ -1047,6 +1125,18 @@ export const DevicePublicSchema = {
|
||||
}
|
||||
],
|
||||
title: 'Last Seen'
|
||||
},
|
||||
report: {
|
||||
anyOf: [
|
||||
{
|
||||
additionalProperties: true,
|
||||
type: 'object'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Report'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -1125,9 +1215,8 @@ export const EndpointSchema = {
|
||||
title: 'Endpoint',
|
||||
description: `Something wired into this flow that is not a node 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 on the far side
|
||||
of a dotted name. The canvas draws these so a value never appears to come
|
||||
A dashboard control setting one of its messages, a tile showing one, or a
|
||||
node in another flow on the far side of a dotted name. The canvas draws these so a value never appears to come
|
||||
from nowhere — or worse, appears to come from whichever node happens to be
|
||||
drawn as a producer.`
|
||||
} as const;
|
||||
@@ -4638,7 +4727,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_device'],
|
||||
enum: ['cycle', 'unconnected_input', 'missing_initial_value', 'node_error', 'unauthenticated_hook', 'self_loop_needs_initial', 'missing_source', 'node_unhealthy', 'ignored_device', 'unknown_device', 'device_port'],
|
||||
title: 'Code'
|
||||
},
|
||||
message: {
|
||||
@@ -4694,12 +4783,11 @@ export const ValidationIssueSchema = {
|
||||
advisory: {
|
||||
type: 'boolean',
|
||||
title: 'Advisory',
|
||||
description: 'Worth saying, but not a fault — the UI says so in a softer tone.',
|
||||
readOnly: true
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['code', 'message', 'advisory'],
|
||||
required: ['code', 'message'],
|
||||
title: 'ValidationIssue',
|
||||
description: 'Something wrong with a flow — a fault, or merely advisory.'
|
||||
} as const;
|
||||
|
||||
@@ -935,13 +935,14 @@ export class DevicesService {
|
||||
* Read Device
|
||||
* One device, read by a person or by the device holding its credential.
|
||||
*
|
||||
* The definition alone: a device compares what it reads with what it is
|
||||
* showing, and a field that changes on every request would rebuild its
|
||||
* screen each time. Declared after the pairing routes and ``/pending``, so
|
||||
* neither is mistaken for a device of that name.
|
||||
* The definition and what the flows wire its ports to, nothing about this
|
||||
* moment: a device compares what it reads with what it is showing, and a
|
||||
* field that changes on every request would rebuild its screen each time.
|
||||
* Declared after the pairing routes and ``/pending``, so neither is mistaken
|
||||
* for a device of that name.
|
||||
* @param data The data for the request.
|
||||
* @param data.deviceId
|
||||
* @returns DeviceDef Successful Response
|
||||
* @returns DeviceDocument Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readDevice(data: DevicesReadDeviceData): CancelablePromise<DevicesReadDeviceResponse> {
|
||||
|
||||
@@ -229,6 +229,19 @@ export type DeadLetter = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a port is wired: the device node holding it.
|
||||
*/
|
||||
export type DeviceBinding = {
|
||||
flow: string;
|
||||
node: string;
|
||||
port: string;
|
||||
message: string;
|
||||
direction: 'in' | 'out';
|
||||
};
|
||||
|
||||
export type direction = 'in' | 'out';
|
||||
|
||||
export type DeviceCreate = {
|
||||
title?: string;
|
||||
};
|
||||
@@ -241,18 +254,29 @@ export type DeviceDef = {
|
||||
title?: string;
|
||||
dashboards?: Array<(string)>;
|
||||
touch?: boolean;
|
||||
io?: Array<DevicePort>;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One piece of the device's own hardware, wired to a message.
|
||||
* A device as it reads itself: what it shows, and what its ports carry.
|
||||
*
|
||||
* A wall panel is a node as well as a screen: a button on its GPIO, a
|
||||
* microphone, a speaker. Which ports exist is the device's business, so the
|
||||
* name is free text and ``config`` is passed through to it unread.
|
||||
* ``io`` is worked out from the device nodes in published flows, in a fixed
|
||||
* order, so an unchanged state reads back as the same bytes.
|
||||
*/
|
||||
export type DevicePort = {
|
||||
export type DeviceDocument = {
|
||||
id: string;
|
||||
title?: string;
|
||||
dashboards?: Array<(string)>;
|
||||
touch?: boolean;
|
||||
version?: number;
|
||||
io?: Array<DeviceIo>;
|
||||
bindings?: Array<DeviceBinding>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One port as the device reads it: the message it publishes or follows.
|
||||
*/
|
||||
export type DeviceIo = {
|
||||
port: string;
|
||||
message: string;
|
||||
direction: 'in' | 'out';
|
||||
@@ -261,8 +285,6 @@ export type DevicePort = {
|
||||
};
|
||||
};
|
||||
|
||||
export type direction = 'in' | 'out';
|
||||
|
||||
/**
|
||||
* A device as the Devices tab lists it, with whether it is there.
|
||||
*/
|
||||
@@ -271,10 +293,14 @@ export type DevicePublic = {
|
||||
title?: string;
|
||||
dashboards?: Array<(string)>;
|
||||
touch?: boolean;
|
||||
io?: Array<DevicePort>;
|
||||
version?: number;
|
||||
io?: Array<DeviceIo>;
|
||||
bindings?: Array<DeviceBinding>;
|
||||
online?: boolean;
|
||||
last_seen?: (string | null);
|
||||
report?: ({
|
||||
[key: string]: unknown;
|
||||
} | null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -318,9 +344,8 @@ export type DType = 'float' | 'int' | 'str' | 'bool' | 'json' | 'series' | 'reco
|
||||
/**
|
||||
* Something wired into this flow that is not a node 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 on the far side
|
||||
* of a dotted name. The canvas draws these so a value never appears to come
|
||||
* A dashboard control setting one of its messages, a tile showing one, or a
|
||||
* node in another flow on the far side of a dotted name. The canvas draws these so a value never appears to come
|
||||
* from nowhere — or worse, appears to come from whichever node happens to be
|
||||
* drawn as a producer.
|
||||
*/
|
||||
@@ -1489,20 +1514,17 @@ 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_device';
|
||||
code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'ignored_device' | 'unknown_device' | 'device_port';
|
||||
message: string;
|
||||
flow?: string;
|
||||
nodes?: Array<(string)>;
|
||||
node?: (string | null);
|
||||
port?: (string | null);
|
||||
message_name?: (string | null);
|
||||
/**
|
||||
* Worth saying, but not a fault — the UI says so in a softer tone.
|
||||
*/
|
||||
readonly advisory: boolean;
|
||||
advisory?: boolean;
|
||||
};
|
||||
|
||||
export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'ignored_device';
|
||||
export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'ignored_device' | 'unknown_device' | 'device_port';
|
||||
|
||||
export type ValidationResult = {
|
||||
issues?: Array<ValidationIssue>;
|
||||
@@ -1838,7 +1860,7 @@ export type DevicesReadDeviceData = {
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
export type DevicesReadDeviceResponse = (DeviceDef);
|
||||
export type DevicesReadDeviceResponse = (DeviceDocument);
|
||||
|
||||
export type DevicesSaveDeviceData = {
|
||||
deviceId: string;
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
CloudService,
|
||||
type DeviceDef,
|
||||
type DevicePort,
|
||||
type DevicePublic,
|
||||
DevicesService,
|
||||
FlowsService,
|
||||
} from "@/client"
|
||||
import { initials, resolveIcon } from "@/components/Dashboard/icons"
|
||||
import { MessagePicker } from "@/components/Dashboard/panels"
|
||||
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { qualifiedProblem } from "@/components/Flow/deriveEdges"
|
||||
import { JsonField } from "@/components/Flow/JsonField"
|
||||
import { flowsQueryOptions } from "@/components/Flow/queries"
|
||||
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Segmented } from "@/components/ui/segmented"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,6 +31,13 @@ import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { ago, cn } from "@/lib/utils"
|
||||
import { handleError } from "@/utils"
|
||||
import {
|
||||
DEVICE_TYPE,
|
||||
deviceNodeOf,
|
||||
freeNodeId,
|
||||
type ReportedPort,
|
||||
withPort,
|
||||
} from "./ports"
|
||||
import {
|
||||
devicesQueryOptions,
|
||||
pendingQueryOptions,
|
||||
@@ -47,28 +51,6 @@ const CARD = "rounded-lg border border-border bg-card p-4 shadow-e1"
|
||||
/** As many characters as a device puts on the wall. */
|
||||
const CODE_LENGTH = 6
|
||||
|
||||
/** Port names the panel firmware knows; `gpio.` takes a pin number. */
|
||||
// ponytail: free-text ports until a device reports what it has (PUT /devices/{id}/report)
|
||||
const PORT_SUGGESTIONS = ["gpio.", "backlight", "touch", "mic", "speaker"]
|
||||
|
||||
const DIRECTIONS = [
|
||||
["in", "In"],
|
||||
["out", "Out"],
|
||||
] as const
|
||||
|
||||
/** What the server accepts as a port name. */
|
||||
const portOf = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
|
||||
|
||||
/** A new port the server will take: a name, and a message named with its flow. */
|
||||
const ready = (port: DevicePort) =>
|
||||
Boolean(port.port) && qualifiedProblem(port.message) === null
|
||||
|
||||
const objectOnly = (value: unknown) =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? null
|
||||
: "Config is a JSON object"
|
||||
|
||||
/** "Online", or when it was last heard from. */
|
||||
export function presenceText(device: DevicePublic) {
|
||||
if (device.online) return "Online"
|
||||
@@ -159,7 +141,7 @@ export function DeviceDetail({ id }: { id: string }) {
|
||||
/>
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
<DeviceIo device={device} canEdit={canEdit} onChange={write} />
|
||||
<DevicePorts device={device} canEdit={canEdit} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,184 +546,131 @@ function Pairing({
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's own inputs and outputs, each wired to a message.
|
||||
*
|
||||
* A new port is held here until it has a name and a qualified message, since
|
||||
* the server refuses a port without either.
|
||||
* 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.
|
||||
*/
|
||||
function DeviceIo({
|
||||
function DevicePorts({
|
||||
device,
|
||||
canEdit,
|
||||
onChange,
|
||||
}: {
|
||||
device: DeviceDef
|
||||
device: DevicePublic
|
||||
canEdit: boolean
|
||||
onChange: (changes: Partial<DeviceDef>) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState<DevicePort | null>(null)
|
||||
const io = device.io ?? []
|
||||
const listId = `ports-${device.id}`
|
||||
|
||||
const write = (io: DevicePort[]) => onChange({ io })
|
||||
const keepDraft = (next: DevicePort) => {
|
||||
if (!ready(next)) return setDraft(next)
|
||||
setDraft(null)
|
||||
write([...io, next])
|
||||
}
|
||||
|
||||
const bindings = device.bindings ?? []
|
||||
return (
|
||||
<PanelSection
|
||||
title="I/O"
|
||||
help="Hardware on the device itself, such as a GPIO pin or its microphone. In publishes to the message, Out follows it, and Config goes to the device as it is."
|
||||
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."
|
||||
>
|
||||
<datalist id={listId}>
|
||||
{PORT_SUGGESTIONS.map((port) => (
|
||||
<option key={port} value={port} />
|
||||
))}
|
||||
</datalist>
|
||||
{/* `disabled` on a fieldset reaches every control inside it. */}
|
||||
<fieldset disabled={!canEdit} className="grid min-w-0 gap-4">
|
||||
{io.length === 0 && !draft ? (
|
||||
<p className="text-sm text-muted-foreground">No ports yet.</p>
|
||||
) : null}
|
||||
{io.map((row, index) => (
|
||||
<IoRow
|
||||
// Ports have no id, and two may share a name until one is renamed.
|
||||
key={index}
|
||||
row={row}
|
||||
listId={listId}
|
||||
testId={`io-${device.id}-${index}`}
|
||||
onChange={(next) =>
|
||||
write(io.map((one, at) => (at === index ? next : one)))
|
||||
}
|
||||
onRemove={() => write(io.filter((_, at) => at !== index))}
|
||||
/>
|
||||
))}
|
||||
{draft ? (
|
||||
<IoRow
|
||||
row={draft}
|
||||
listId={listId}
|
||||
testId={`io-${device.id}-new`}
|
||||
onChange={keepDraft}
|
||||
onType={(port) => setDraft({ ...draft, port })}
|
||||
onRemove={() => setDraft(null)}
|
||||
/>
|
||||
) : null}
|
||||
{canEdit && !draft ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm-touch"
|
||||
className="justify-self-start"
|
||||
data-testid={`add-port-${device.id}`}
|
||||
onClick={() => setDraft({ port: "", message: "", direction: "in" })}
|
||||
>
|
||||
<Plus />
|
||||
Add port
|
||||
</Button>
|
||||
) : null}
|
||||
</fieldset>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">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"
|
||||
return (
|
||||
<li
|
||||
key={`${binding.flow}.${binding.node}.${binding.port}.${binding.message}`}
|
||||
className="flex min-w-0 items-center gap-2 text-sm"
|
||||
>
|
||||
<span className="w-24 shrink-0 truncate font-mono">
|
||||
{binding.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>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{canEdit ? <UseInFlow device={device} /> : null}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
function IoRow({
|
||||
row,
|
||||
listId,
|
||||
testId,
|
||||
onChange,
|
||||
onType,
|
||||
onRemove,
|
||||
/**
|
||||
* Put this device in a flow: its device node there, made when there is none,
|
||||
* with `port` on it when one is given, and the flow opened on it. A draft,
|
||||
* so nothing is wired until the flow is published.
|
||||
*/
|
||||
export function UseInFlow({
|
||||
device,
|
||||
port,
|
||||
}: {
|
||||
row: DevicePort
|
||||
/** The datalist of port names to suggest. */
|
||||
listId: string
|
||||
testId: string
|
||||
onChange: (next: DevicePort) => void
|
||||
/** A new port's name as it is typed. A saved port commits on blur instead. */
|
||||
onType?: (port: string) => void
|
||||
onRemove: () => void
|
||||
device: DevicePublic
|
||||
port?: ReportedPort
|
||||
}) {
|
||||
const name = row.port || "new port"
|
||||
const problem = row.message ? qualifiedProblem(row.message) : null
|
||||
const navigate = useNavigate()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const { data } = useQuery(flowsQueryOptions())
|
||||
const flows = data?.data ?? []
|
||||
const place = useMutation({
|
||||
mutationFn: async (flowName: string) => {
|
||||
const { definition } = await FlowsService.readFlow({ name: flowName })
|
||||
const nodes = definition.nodes ?? []
|
||||
const existing = deviceNodeOf(nodes, device.id)
|
||||
const node = existing ?? {
|
||||
id: freeNodeId(nodes, device.id),
|
||||
type: DEVICE_TYPE,
|
||||
title: device.title || device.id,
|
||||
params: { device: device.id, config: {} },
|
||||
requires: [],
|
||||
provides: [],
|
||||
}
|
||||
const placed = port ? withPort(node, port) : node
|
||||
if (placed !== existing) {
|
||||
await FlowsService.saveFlow({
|
||||
name: flowName,
|
||||
requestBody: {
|
||||
...definition,
|
||||
nodes: existing
|
||||
? nodes.map((one) => (one === existing ? placed : one))
|
||||
: [...nodes, placed],
|
||||
},
|
||||
})
|
||||
}
|
||||
return { flowName, node: placed.id }
|
||||
},
|
||||
onSuccess: ({ flowName, node }) =>
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName },
|
||||
search: { node },
|
||||
}),
|
||||
onError: (error) => handleError.call(showErrorToast, error as ApiError),
|
||||
})
|
||||
|
||||
if (flows.length === 0) return null
|
||||
return (
|
||||
<div className="grid gap-2" data-testid={testId}>
|
||||
<div className="flex items-center gap-2">
|
||||
{onType ? (
|
||||
<Input
|
||||
value={row.port}
|
||||
placeholder="Port, e.g. gpio.4"
|
||||
aria-label={`Port of ${name}`}
|
||||
autoComplete="off"
|
||||
list={listId}
|
||||
onChange={(event) => onType(portOf(event.currentTarget.value))}
|
||||
onBlur={() => onChange(row)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
// Remounted with whatever was saved, so a refused name snaps back.
|
||||
key={row.port}
|
||||
defaultValue={row.port}
|
||||
placeholder="Port, e.g. gpio.4"
|
||||
aria-label={`Port of ${name}`}
|
||||
autoComplete="off"
|
||||
list={listId}
|
||||
onBlur={(event) => {
|
||||
const port = portOf(event.currentTarget.value)
|
||||
event.currentTarget.value = port || row.port
|
||||
if (port && port !== row.port) onChange({ ...row, port })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
<Segmented
|
||||
value={row.direction}
|
||||
options={DIRECTIONS}
|
||||
label={`Direction of ${name}`}
|
||||
onChange={(direction) =>
|
||||
onChange({
|
||||
...row,
|
||||
direction: direction as DevicePort["direction"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label={`Remove ${name}`}
|
||||
title={`Remove ${name}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePicker
|
||||
value={row.message}
|
||||
label={`Message of ${name}`}
|
||||
hideLabel
|
||||
// A port the device publishes from needs a message a flow takes in.
|
||||
filter={
|
||||
row.direction === "in"
|
||||
? (message) => message.writable !== false
|
||||
: undefined
|
||||
}
|
||||
onPick={(message) => onChange({ ...row, message })}
|
||||
/>
|
||||
</div>
|
||||
<JsonField
|
||||
value={row.config ?? {}}
|
||||
label={`Config of ${name}`}
|
||||
check={objectOnly}
|
||||
className="h-9 w-28 shrink-0"
|
||||
onChange={(config) =>
|
||||
onChange({ ...row, config: config as DevicePort["config"] })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{problem ? <p className="text-xs text-destructive">{problem}</p> : null}
|
||||
</div>
|
||||
<Select value="" onValueChange={(flowName) => place.mutate(flowName)}>
|
||||
<SelectTrigger
|
||||
className="justify-self-start"
|
||||
aria-label={`Use ${device.title || device.id} in a flow`}
|
||||
disabled={place.isPending}
|
||||
data-testid="use-in-flow"
|
||||
>
|
||||
<SelectValue placeholder="Use in flow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{flows.map((flow) => (
|
||||
<SelectItem key={flow.name} value={flow.name}>
|
||||
{flow.title || flow.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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
|
||||
direction: "in" | "out"
|
||||
dtype?: DType
|
||||
}
|
||||
|
||||
/** What the server accepts as a port name. */
|
||||
export const portOf = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
|
||||
|
||||
/**
|
||||
* The ports a device says it has, or null when it has not said.
|
||||
*
|
||||
* Read leniently: the report is the device's own word, and without one ports
|
||||
* are free text.
|
||||
*/
|
||||
export function reportedPorts(device: DevicePublic | undefined) {
|
||||
const ports = (device?.report as { ports?: unknown } | null | undefined)
|
||||
?.ports
|
||||
if (!Array.isArray(ports)) return null
|
||||
return ports.filter(
|
||||
(port): port is ReportedPort =>
|
||||
typeof port?.port === "string" &&
|
||||
(port.direction === "in" || port.direction === "out"),
|
||||
)
|
||||
}
|
||||
|
||||
/** The device node standing for `device` in a flow, if there is one. */
|
||||
export function deviceNodeOf(nodes: NodeDef_Input[], device: string) {
|
||||
return nodes.find(
|
||||
(node) => node.type === DEVICE_TYPE && node.params?.device === device,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The node with one more port: an `in` port publishes, so it is an output,
|
||||
* and an `out` port follows, so it is an input that never wakes anything.
|
||||
* The message is named after the port until somebody names it.
|
||||
*/
|
||||
export function withPort(node: NodeDef_Input, port: ReportedPort) {
|
||||
const spec: MessageSpec = {
|
||||
name: port.port.replace(/\./g, "_"),
|
||||
port: port.port,
|
||||
dtype: port.dtype ?? "float",
|
||||
}
|
||||
if (port.direction === "in") {
|
||||
if ((node.provides ?? []).some((one) => one.port === port.port)) return node
|
||||
return { ...node, provides: [...(node.provides ?? []), spec] }
|
||||
}
|
||||
if ((node.requires ?? []).some((one) => one.port === port.port)) return node
|
||||
return {
|
||||
...node,
|
||||
requires: [...(node.requires ?? []), { ...spec, trigger: false }],
|
||||
}
|
||||
}
|
||||
|
||||
/** A node id the flow does not have yet, `base` itself when it is free. */
|
||||
export function freeNodeId(nodes: NodeDef_Input[], base: string) {
|
||||
const taken = new Set(nodes.map((node) => node.id))
|
||||
let id = base
|
||||
for (let index = 2; taken.has(id); index += 1) id = `${base}_${index}`
|
||||
return id
|
||||
}
|
||||
@@ -69,8 +69,16 @@ export function useSaveDevice(id: string) {
|
||||
mutationFn: (changes: Partial<DeviceDef>) => {
|
||||
const current = stored()
|
||||
if (!current) throw new Error(`No device named ${id}`)
|
||||
// Presence is the list's, not part of what is saved.
|
||||
const { online: _online, last_seen: _seen, ...device } = current
|
||||
// Presence, the report and the wiring are the list's, not part of what
|
||||
// is saved: the ports are wired in flows.
|
||||
const {
|
||||
online: _online,
|
||||
last_seen: _seen,
|
||||
report: _report,
|
||||
io: _io,
|
||||
bindings: _bindings,
|
||||
...device
|
||||
} = current
|
||||
return DevicesService.saveDevice({
|
||||
deviceId: id,
|
||||
requestBody: { ...device, ...changes },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user