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
+13 -3
View File
@@ -112,10 +112,15 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]:
panel displays and another controls stays writable, because the unlocked
one is what entitles the screen to it. Reads are untouched — read-only is
not blind, and a locked dashboard has to keep drawing live data.
The panel's own ``in`` ports are added outside the lock: it covers the
controls on a dashboard, not the sensors on the device. Its ``out`` ports
are read, not written, unless a widget targets them too.
"""
store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None)
if store is None:
return set()
writable = panels.io_in(panel_id)
defns = panels.dashboards_for(panel_id, store)
bound = sorted(
{s.message for d in defns if (s := d.settings.get("locked")) and s.message}
@@ -124,7 +129,6 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]:
request.app.state, "flow_controller", None
)
live = controller.state.get_present(bound) if bound and controller else {}
writable: set[str] = set()
for defn in defns:
writable |= (
panels.requests_of(defn)
@@ -162,8 +166,10 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
A panel credential names the account that approved the pairing, so without
this it would be that person's session hanging on a wall. What a panel
genuinely needs is small and worth writing out: the dashboards it was
assigned, its own definition, the messages its own widgets bind to, and the
bytes those messages currently point at, for a tile drawing a camera frame.
assigned, its own definition, the messages its own widgets and ports bind
to, and the bytes those messages currently point at, for a tile drawing a
camera frame — plus an upload named after one of its ``in`` ports, for a
microphone whose recording is the value it publishes.
Publishing is in the list because a panel cannot be strictly read-only — a
control on a panel is the point of putting one there, and a querying chart
@@ -223,6 +229,10 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
# off an old run because it happens to know the digest.
digest = unquote(path[len(f"{api}/artifacts/") :])
allowed = "/" not in digest and digest in _panel_digests(panel.id, request)
elif method == "PUT" and path == f"{api}/artifacts":
# What the device's own sensor recorded — a microphone's utterance —
# uploaded under the ``in`` message it is about to publish it to.
allowed = request.query_params.get("name", "") in panels.io_in(panel.id)
if not allowed:
raise HTTPException(
+58 -5
View File
@@ -12,8 +12,10 @@ which screen hangs where is the deployment's concern, not any one dashboard's.
from __future__ import annotations
import re
import threading
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
@@ -21,6 +23,43 @@ from fluksio.core.config import settings
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
from fluksio.flow.schemas import _validate_name
#: Free text the device interprets: ``gpio.4``, ``backlight``, ``mic``.
PORT_PATTERN = re.compile(r"^[a-z0-9_.]+$")
class PanelPort(BaseModel):
"""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.
"""
port: str
#: Qualified, ``flow.name`` — a flow still has to declare it, as for any
#: widget, before a publish to it lands.
message: str
#: ``in`` is the device publishing into fluksio; ``out`` is the device
#: following the message.
direction: Literal["in", "out"]
config: dict[str, Any] = Field(default_factory=dict)
@field_validator("port")
@classmethod
def _check_port(cls, value: str) -> str:
if not PORT_PATTERN.fullmatch(value):
raise ValueError("Use lowercase letters, digits, underscores and dots")
return value
@field_validator("message")
@classmethod
def _check_message(cls, value: str) -> str:
flow, _, name = value.partition(".")
if not name:
raise ValueError("Name the message with its flow, as flow.name")
_validate_name(flow)
return value
class PanelDef(BaseModel):
"""One device, and what it shows."""
@@ -36,6 +75,10 @@ class PanelDef(BaseModel):
#: any dashboard: the same dashboard may hang on a hallway tablet and on a
#: desk browser, and only one of them has fingers on it.
touch: bool = False
#: The device's own inputs and outputs. An ``in`` port may publish whether
#: or not a dashboard is locked — a lock covers the controls on a
#: dashboard, not the sensors on the wall — and an ``out`` port only reads.
io: list[PanelPort] = Field(default_factory=list)
#: Which generation of credential this panel honours. A token names the
#: nonce it was minted at, so bumping this refuses the screen currently
#: hanging here and leaves the panel, its dashboards and their arrangement
@@ -151,12 +194,22 @@ def requests_of(defn: DashboardDef) -> set[str]:
return {w.target for w in defn.widgets if w.type == "chart" and w.target}
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
"""Every message this panel's dashboards read or write.
def io_in(panel_id: str) -> set[str]:
"""The messages this panel's own hardware publishes to."""
panel = find(panel_id)
if panel is None:
return set()
return {port.message for port in panel.io if port.direction == "in"}
What a screen is entitled to see, as its own dashboards define it, and
empty for a panel that is gone — which is the same answer as "nothing".
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
"""Every message this panel's dashboards and its own I/O read or write.
What a screen is entitled to see, as its dashboards and ports define it,
and empty for a panel that is gone — which is the same answer as "nothing".
"""
return {
panel = find(panel_id)
own = {port.message for port in panel.io} if panel else set()
return own | {
name for defn in dashboards_for(panel_id, store) for name in messages_of(defn)
}
+78
View File
@@ -437,6 +437,84 @@ def test_a_locked_dashboard_entitles_a_panel_to_read_and_not_to_publish(
)
PANEL_IO = [
{
"port": "gpio.4",
"message": "demo.doorbell",
"direction": "in",
"config": {"pull": "up", "debounce_ms": 50},
},
{"port": "backlight", "message": "demo.backlight", "direction": "out"},
]
def test_a_panels_io_is_saved_and_read_back(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "porch_io", "io": PANEL_IO}]},
)
read = client.get(f"{PREFIX}/porch_io", headers=superuser_token_headers).json()
assert read["io"] == [PANEL_IO[0], {**PANEL_IO[1], "config": {}}]
def test_a_panels_own_ports_publish_through_a_lock(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""An ``in`` port is the device's own sensor, which a dashboard lock is not about.
An ``out`` port is the device following a message, so it reads and does
not write — and only an ``in`` port's name opens the artifact upload.
"""
_dashboard_with(client, superuser_token_headers, "panel_io_locked", PANEL_WIDGETS)
_publish_settings(
client, superuser_token_headers, "panel_io_locked", {"locked": {"value": True}}
)
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "door", "dashboards": ["panel_io_locked"], "io": PANEL_IO}]},
)
panel_headers = _pair(client, superuser_token_headers, "door")
messages = f"{settings.API_V1_STR}/messages"
# 404: past the gate, refused by an engine no flow here declares it in.
assert (
client.post(
f"{messages}/demo.doorbell", headers=panel_headers, json={"value": True}
).status_code
== 404
)
assert (
client.post(
f"{messages}/demo.backlight", headers=panel_headers, json={"value": 50}
).status_code
== 403
)
assert (
client.get(
f"{messages}/demo.backlight/history", headers=panel_headers
).status_code
== 200
)
artifacts = f"{settings.API_V1_STR}/artifacts"
for name, status in (
("demo.doorbell", 200),
("demo.backlight", 403),
("demo.unrelated", 403),
):
uploaded = client.put(
artifacts,
headers=panel_headers,
params={"name": name, "media_type": "audio/wav", "volatile": True},
content=b"RIFF",
)
assert uploaded.status_code == status, name
def test_a_panel_may_read_its_dashboards_own_settings(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
+18 -1
View File
@@ -188,12 +188,29 @@ Several dashboards get a navigation rail.
A panel credential permits its assigned dashboards, their bound messages and
the artifacts they show. It also permits publishing from its controls and query
charts. **Lock** changes the viewing surface; it does not narrow the credential.
charts. On a locked dashboard only the query charts may still publish.
**Unpair screen** revokes a locally paired credential and keeps the panel's
dashboard assignments. Deleting the panel removes the assignment and its
access.
### The device's own I/O
A panel can also act as a node. Under **I/O**, wire a port on the device to a
message:
| Field | Meaning |
|---|---|
| `port` | The device's name for it, such as `gpio.4`, `backlight`, `screen`, `touch`, `mic` or `speaker`. Lowercase letters, digits, `_` and `.` |
| `direction` | `in`: the device publishes to the message. `out`: the device follows it |
| `message` | A qualified `flow.name`. A flow must declare it, as for a control |
| `config` | A JSON object passed to the device unread, such as `{"pull": "up", "debounce_ms": 50}` |
The credential may publish to its `in` messages even when a dashboard is
locked, and may upload an artifact named after one
(`PUT /artifacts?name=<message>`), which is how a microphone sends a recording.
`out` messages are read only.
!!! note "If the local link is wrong"
Pairing links use `FRONTEND_HOST`. Set it to the app address reachable
+13 -1
View File
@@ -89,13 +89,14 @@ for (const theme of ["light", "dark"]) {
await captureFlows(page, dir)
await captureDashboards(page, dir)
await capturePanels(page, dir)
await captureMedia(page, dir)
await captureRuns(page, dir)
await captureWorkers(page, dir)
await context.close()
console.log(
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-runs,app-run,app-run-context,app-workers}.png`,
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-panels,app-runs,app-run,app-run-context,app-workers}.png`,
)
}
}
@@ -202,6 +203,17 @@ async function captureDashboards(page, dir) {
await page.screenshot({ path: `${dir}/app-panel.png` })
}
/** The panels dialog, scrolled to the first panel's I/O when there is one. */
async function capturePanels(page, dir) {
await page.goto(`${APP_URL}/dashboards?panels=true`, {
waitUntil: "networkidle",
})
const io = page.locator("[data-testid^=add-port-]").first()
if (await io.count()) await io.scrollIntoViewIfNeeded()
await page.waitForTimeout(1000)
await page.screenshot({ path: `${dir}/app-panels.png` })
}
/**
* A media tile drawing what a camera published, where there is one.
*
+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