diff --git a/backend/fluksio/api/deps.py b/backend/fluksio/api/deps.py index 03497b3..d92b336 100644 --- a/backend/fluksio/api/deps.py +++ b/backend/fluksio/api/deps.py @@ -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( diff --git a/backend/fluksio/flow/panels.py b/backend/fluksio/flow/panels.py index dc3bc19..f8f62a9 100644 --- a/backend/fluksio/flow/panels.py +++ b/backend/fluksio/flow/panels.py @@ -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) } diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index 12c67c0..2e1c5cd 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -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: diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 446e8e1..35361a7 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -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=`), 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 diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index 88d90e9..57f7a17 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -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. * diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fb7dbfa..d543b85 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 8d2d3c9..f2c055e 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -962,9 +962,28 @@ export type PanelDef = { title?: string; dashboards?: Array<(string)>; touch?: boolean; + io?: Array; 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. */ diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx index 43ce295..f6f8805 100644 --- a/frontend/src/components/Dashboard/PanelsDialog.tsx +++ b/frontend/src/components/Dashboard/PanelsDialog.tsx @@ -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({ )} + +
) } + +/** + * 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(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 ( + + + {PORT_SUGGESTIONS.map((port) => ( + + {/* `disabled` on a fieldset reaches every control inside it. */} +
+ {io.length === 0 && !draft ? ( +

No ports yet.

+ ) : null} + {io.map((row, index) => ( + + write(io.map((one, at) => (at === index ? next : one))) + } + onRemove={() => write(io.filter((_, at) => at !== index))} + /> + ))} + {draft ? ( + setDraft(null)} + /> + ) : null} + {canEdit && !draft ? ( + + ) : null} +
+
+ ) +} + +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 ( +
+
+ { + const port = portOf(event.currentTarget.value) + event.currentTarget.value = port || row.port + if (port && port !== row.port) onChange({ ...row, port }) + }} + /> +
+ + onChange({ + ...row, + direction: direction as PanelPort["direction"], + }) + } + /> +
+ +
+
+
+ message.writable !== false + : undefined + } + onPick={(message) => onChange({ ...row, message })} + /> +
+ + onChange({ ...row, config: config as PanelPort["config"] }) + } + /> +
+
+ ) +} diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 88cf9a3..7684fe5 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -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 (
- +