Panel I/O: PanelDef.io ports, scoped publish and upload, I/O section in the Panels dialog

This commit is contained in:
Melvin Strobl
2026-10-13 21:10:54 +02:00
parent 80e595c02e
commit 659799d1e5
9 changed files with 443 additions and 14 deletions
+38
View File
@@ -2863,6 +2863,13 @@ export const PanelDefSchema = {
title: 'Touch',
default: false
},
io: {
items: {
'$ref': '#/components/schemas/PanelPort'
},
type: 'array',
title: 'Io'
},
nonce: {
type: 'integer',
title: 'Nonce',
@@ -2875,6 +2882,37 @@ export const PanelDefSchema = {
description: 'One device, and what it shows.'
} as const;
export const PanelPortSchema = {
properties: {
port: {
type: 'string',
title: 'Port'
},
message: {
type: 'string',
title: 'Message'
},
direction: {
type: 'string',
enum: ['in', 'out'],
title: 'Direction'
},
config: {
additionalProperties: true,
type: 'object',
title: 'Config'
}
},
type: 'object',
required: ['port', 'message', 'direction'],
title: 'PanelPort',
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.`
} as const;
export const PanelsConfigSchema = {
properties: {
panels: {
+19
View File
@@ -962,9 +962,28 @@ export type PanelDef = {
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
io?: Array<PanelPort>;
nonce?: number;
};
/**
* 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.
*/
export type PanelPort = {
port: string;
message: string;
direction: 'in' | 'out';
config?: {
[key: string]: unknown;
};
};
export type direction = 'in' | 'out';
/**
* Every panel this instance knows about.
*/
@@ -1,22 +1,25 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Trash2 } from "lucide-react"
import { Plus, Trash2, X } from "lucide-react"
import { useState } from "react"
import {
type ApiError,
CloudService,
type PanelDef,
type PanelPort,
type PanelsConfig,
PanelsService,
} from "@/client"
import { initials, resolveIcon } from "@/components/Dashboard/icons"
import { MessagePicker } from "@/components/Dashboard/panels"
import {
dashboardsQueryOptions,
panelsQueryOptions,
useSavePanels,
useUnpairPanel,
} from "@/components/Dashboard/queries"
import { InfoTip } from "@/components/Flow/SidePanel"
import { JsonField } from "@/components/Flow/JsonField"
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
@@ -26,6 +29,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Segmented } from "@/components/ui/segmented"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
@@ -43,6 +47,31 @@ const slugify = (value: string) =>
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
/** Port names the panel firmware knows; `gpio.` takes a pin number. */
// ponytail: free-text ports, no device capability report; add PUT /panels/{id}/device when a second device kind exists
const PORT_SUGGESTIONS = [
"gpio.",
"backlight",
"screen",
"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, "")
const objectOnly = (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? null
: "Config is a JSON object"
/**
* Which dashboards hang on which screen, and adopting the screens themselves.
*
@@ -361,6 +390,8 @@ function PanelRow({
</div>
)}
<PanelIo panel={panel} canEdit={canEdit} onChange={onChange} />
<div className="grid gap-2">
<Input
readOnly
@@ -460,3 +491,169 @@ function PanelRow({
</div>
)
}
/**
* The device's own inputs and outputs, each wired to a message.
*
* Every change saves as it is made, like the rest of the dialog. A new port
* is held here until it has both a name and a message, since the server
* refuses a port without either.
*/
function PanelIo({
panel,
canEdit,
onChange,
}: {
panel: PanelDef
canEdit: boolean
onChange: (next: PanelDef) => void
}) {
const io = panel.io ?? []
const [draft, setDraft] = useState<PanelPort | null>(null)
const listId = `ports-${panel.id}`
const write = (next: PanelPort[]) => onChange({ ...panel, io: next })
const keepDraft = (next: PanelPort) => {
if (!next.port || !next.message) return setDraft(next)
setDraft(null)
write([...io, next])
}
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."
>
<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-${panel.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-${panel.id}-new`}
onChange={keepDraft}
onRemove={() => setDraft(null)}
/>
) : null}
{canEdit && !draft ? (
<Button
variant="outline"
size="sm-touch"
className="justify-self-start"
data-testid={`add-port-${panel.id}`}
onClick={() => setDraft({ port: "", message: "", direction: "in" })}
>
<Plus />
Add port
</Button>
) : null}
</fieldset>
</PanelSection>
)
}
function IoRow({
row,
listId,
testId,
onChange,
onRemove,
}: {
row: PanelPort
/** The datalist of port names to suggest. */
listId: string
testId: string
onChange: (next: PanelPort) => void
onRemove: () => void
}) {
const name = row.port || "new port"
return (
<div className="grid gap-2" data-testid={testId}>
<div className="flex items-center gap-2">
<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 PanelPort["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 PanelPort["config"] })
}
/>
</div>
</div>
)
}
+7 -2
View File
@@ -111,10 +111,11 @@ function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
* Searchable, as the flow editor's message fields are: the catalog is every
* message every flow declares.
*/
function MessagePicker({
export function MessagePicker({
kind,
value,
label,
hideLabel = false,
testId,
placeholder = "Pick a message",
filter,
@@ -125,6 +126,8 @@ function MessagePicker({
kind?: WidgetKind
value: string
label: string
/** Keeps the caption for screen readers only, in a row that is its own caption. */
hideLabel?: boolean
testId?: string
placeholder?: string
/** What this slot takes, when the widget's own type does not decide it —
@@ -143,7 +146,9 @@ function MessagePicker({
return (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">{label}</Label>
<Label className={cn("text-sm font-normal", hideLabel && "sr-only")}>
{label}
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button