import { useMutation, useQuery } from "@tanstack/react-query" import { Link, useNavigate } from "@tanstack/react-router" import { ArrowDown, ArrowLeft, ArrowLeftRight, ArrowRight, ArrowUp, X, } from "lucide-react" import { useState } from "react" import { type ApiError, CloudService, type DeviceDef, type DevicePublic, DevicesService, FlowsService, } from "@/client" import { initials, resolveIcon } from "@/components/Dashboard/icons" import { MediaView } from "@/components/Dashboard/MediaWidget" import { dashboardsQueryOptions } from "@/components/Dashboard/queries" 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { ago, cn } from "@/lib/utils" import { handleError } from "@/utils" import { DEVICE_TYPE, type DeviceReport, deviceNodeOf, freeNodeId, LIVE_PORT, type ReportedPort, reportedPorts, reportOf, wiresAs, withPort, } from "./ports" import { devicesQueryOptions, pendingQueryOptions, usePairDevice, useSaveDevice, useUnpairDevice, } from "./queries" 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 /** "Online", or when it was last heard from. */ export function presenceText(device: DevicePublic) { if (device.online) return "Online" return device.last_seen ? `Seen ${ago(device.last_seen)}` : "Not seen yet" } /** * One device: whether it is there, how it pairs, what it shows and its own * hardware. * * Every change saves as it is made, against the version it was read at, so * there is no form to submit and no draft to publish: a device is a thing on * the wall rather than a document. */ export function DeviceDetail({ id }: { id: string }) { const { data, isPending } = useQuery(devicesQueryOptions()) const { user } = useAuth() const save = useSaveDevice(id) const { showErrorToast } = useCustomToast() // Reading devices is any account's, every change to them a superuser's. const canEdit = Boolean(user?.is_superuser) if (isPending) return const device = data?.data?.find((one) => one.id === id) if (!device) { return (
Devices

No device named {id}.

) } const write = (changes: Partial) => save.mutate(changes, { onError: (error) => (error as ApiError).status === 409 ? showErrorToast( "Someone else changed this device first. Their version is shown now, so make the change again.", ) : handleError.call(showErrorToast, error as ApiError), }) return (
Devices

{device.title || device.id}

{presenceText(device)}
) } function Basics({ device, canEdit, onChange, }: { device: DeviceDef canEdit: boolean onChange: (changes: Partial) => void }) { return (
{ if (event.key === "Enter") event.currentTarget.blur() }} onBlur={(event) => { const title = event.currentTarget.value.trim() event.currentTarget.value = title if (title !== device.title) onChange({ title }) }} /> {device.id}
) } /** * What the device's camera sees, once a flow wires its `camera` port. * * Watching is what turns the camera on: it sends frames only while this * preview, a tile or a flow reading them wants them. */ function Camera({ device }: { device: DevicePublic }) { const bound = (device.bindings ?? []).find( (binding) => binding.port === "camera" && binding.direction === "in", ) if (!bound) return null return (
) } /** Which dashboards the device shows, in the order its rail carries them. */ function Dashboards({ device, canEdit, onChange, }: { device: DeviceDef canEdit: boolean onChange: (changes: Partial) => void }) { const { data } = useQuery(dashboardsQueryOptions()) const known = data?.data ?? [] const assigned = device.dashboards ?? [] const byName = new Map(known.map((dashboard) => [dashboard.name, dashboard])) const free = known.filter((dashboard) => !assigned.includes(dashboard.name)) const set = (dashboards: string[]) => onChange({ dashboards }) const move = (from: number, by: number) => { const next = [...assigned] const [name] = next.splice(from, 1) next.splice(from + by, 0, name) set(next) } return ( {assigned.length === 0 ? (

No dashboards yet. The device shows nothing until it has one.

) : (
    {assigned.map((name, index) => { const dashboard = byName.get(name) const title = dashboard?.title || name // What this dashboard draws on the rail. const Glyph = resolveIcon(dashboard?.icon ?? "") return (
  1. {index + 1} {Glyph ? : initials(title)} {dashboard ? ( {title} ) : ( // Deleted since: the device skips it. {name} )} {canEdit ? ( ) : null}
  2. ) })}
)} {canEdit && free.length > 0 ? ( ) : null}
) } /** * How a screen gets its credential: by the code it shows, or picked from the * devices asking on this network. Plus the addresses to point one at, and the * way to sign the current one out. */ function Pairing({ id, host, canEdit, }: { id: string /** Where this instance answers, as it knows itself. */ host: string canEdit: boolean }) { const { showSuccessToast, showErrorToast } = useCustomToast() const [code, setCode] = useState("") // Unpairing cannot be undone from here, since the screen has to be at hand // to read out a new code, so the button asks a second time before it fires. const [confirmUnpair, setConfirmUnpair] = useState(false) const pair = usePairDevice(id) const unpair = useUnpairDevice(id) const typed = code.trim().toUpperCase() // Where a screen that cannot reach this instance pairs instead. The issuer // is the portal as a browser reaches it. const { data: cloud } = useQuery({ queryKey: ["cloud", "status"], queryFn: async () => (await CloudService.readStatus()) as { enrolled: boolean issuer: string | null instance_id: string | null }, }) const remoteLink = cloud?.enrolled && cloud.issuer && cloud.instance_id ? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}/panel` : "" // The instance's own address, not this browser's: administering through // the portal puts the page on the portal's origin, which a screen on this // network cannot be sent to. const link = host ? `${host}/panel/${id}` : "" const { data: pending } = useQuery({ ...pendingQueryOptions(), enabled: canEdit, }) // Approving a code adopts whatever holds it, so say what that is while // there is still time to stop. const { data: waiting } = useQuery({ queryKey: ["pending-device", typed], queryFn: () => DevicesService.pendingDevice({ code: typed }), enabled: canEdit && typed.length === CODE_LENGTH, retry: false, }) const paired = { onSuccess: () => { setCode("") showSuccessToast("Paired. The screen switches over within a few seconds.") }, onError: (error: Error) => handleError.call(showErrorToast, error as ApiError), } return (
event.currentTarget.select()} /> {remoteLink ? ( event.currentTarget.select()} /> ) : null}
{canEdit ? ( <>
{ event.preventDefault() if (typed) pair.mutate({ code: typed }, paired) }} > setCode(event.target.value.toUpperCase())} />
{typed.length === CODE_LENGTH ? (

{waiting ? `${waiting.name || waiting.device}${waiting.remote ? " · via portal" : ""}` : "No device is waiting on that code."}

) : null}
{pending?.length ? "Waiting to be paired" : "No device is waiting"} {pending?.some((entry) => !entry.needs_code) ? (

Compare the check beside each with the one the device shows next to its code before choosing Pair this one.

) : null} {(pending ?? []).map((entry) => (
{entry.check ? ( Check {entry.check.split("").join(" ")} ) : null} {entry.name || entry.board || "Unnamed device"} {entry.firmware ? ( {" "} · {entry.firmware} ) : null} {entry.device} {entry.needs_code ? ( {entry.remote ? "Via portal, " : ""}needs its code ) : ( )}
))}
Sends the screen back to a pairing code and keeps the device and its dashboards, also for a screen paired through the portal.
) : null}
) } /** "1024×600, touch", or "360×360, round, touch". */ function displayText(display: NonNullable) { return [ `${display.w}×${display.h}`, display.shape === "round" ? "round" : null, display.touch ? "touch" : null, ] .filter(Boolean) .join(", ") } /** * What the device says it has, and its ports as the published flows wire * them: each port it reports, with the message it publishes or follows or a * way to wire it, and any port wired that it does not report. Wiring is done * in a flow, so this only reads. */ function Capabilities({ device, canEdit, }: { device: DevicePublic canEdit: boolean }) { const report = reportOf(device) const reported = reportedPorts(device) const bindings = device.bindings ?? [] const camera = report?.camera const features = Object.keys(report?.features ?? {}).filter( (name) => report?.features?.[name], ) const facts = report ? [ ["Board", [report.board, report.platform].filter(Boolean).join(" · ")], ["Firmware", report.firmware], ["Display", report.display ? displayText(report.display) : "None"], camera?.sensor ? [ "Camera", [ camera.sensor, camera.w ? `${camera.w}×${camera.h} at ${camera.fps} fps` : "", ] .filter(Boolean) .join(", "), ] : null, features.length ? ["Features", features.join(", ")] : null, ].filter((fact): fact is [string, string] => Boolean(fact?.[1])) : [] // A port the device lists, then one wired that it does not, so a typo in a // flow shows up here as well as on the canvas. const rows = [ ...(reported ?? []).map((port) => ({ port: port.port, said: port })), ...[...new Set(bindings.map((binding) => binding.port))] .filter((port) => !reported?.some((one) => one.port === port)) .map((port) => ({ port, said: undefined })), ] return ( {report ? (
{facts.map(([term, value]) => (
{term}
{value}
))}
) : (

Not reported yet. A device says what it has once it is paired and connected.

)} {rows.length === 0 ? (

{report ? "It reports no ports." : "No ports wired yet."}

) : (
    {rows.map(({ port, said }) => { const wired = bindings.filter((binding) => binding.port === port) const either = (said?.modes?.length ?? 0) > 1 const direction = said?.direction ?? wired[0]?.direction const Way = either ? ArrowLeftRight : direction === "in" ? ArrowRight : ArrowLeft const way = either ? "Publishes or follows" : direction === "in" ? "Publishes" : "Follows" return (
  • {port} {said?.dtype ?? ""} {wired.map((binding) => ( {binding.message} ))} {report && !said ? ( Not reported by the device ) : null} {said && wired.some((binding) => !wiresAs(said, binding.direction)) ? ( Wired the other way round ) : null} {said?.port === LIVE_PORT ? ( Live view ) : canEdit && said && wired.length === 0 ? ( ) : null}
  • ) })}
)} {device.reported_at ? (

Reported {ago(device.reported_at)}

) : null} {canEdit ? : null}
) } /** * 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, size, }: { device: DevicePublic port?: ReportedPort size?: "sm" }) { 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 ( ) }