diff --git a/src/components/about-card.tsx b/src/components/about-card.tsx new file mode 100644 index 0000000..f578b7d --- /dev/null +++ b/src/components/about-card.tsx @@ -0,0 +1,99 @@ +import { unpackVersion, type Identity } from "@openservocore/client"; +import { Cog } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { modelName } from "@/lib/descriptor"; +import { formatVersion, hex16 } from "@/lib/format"; +import { useSession } from "@/lib/session"; + +/** `capability_flags` bit order (protocol sec 5.4). */ +const CAPABILITIES: readonly string[] = ["Motor encoder"]; + +function features(caps: number): string { + const set: string[] = []; + for (let bit = 0; bit < 32; bit++) { + if ((caps & (1 << bit)) === 0) continue; + set.push(CAPABILITIES[bit] ?? `bit ${bit}`); + } + return set.length === 0 ? "None" : set.join(", "); +} + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export function AboutCard({ id, uid }: { id: number; uid: string }) { + const { run } = useSession(); + const [identity, setIdentity] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + let live = true; + void (async () => { + try { + const read = await run((c) => c.identity(id)); + if (live) setIdentity(read); + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + live = false; + }; + }, [run, id]); + + const name = identity === undefined ? undefined : modelName(identity.model); + return ( + + + + + About + + + + {error !== undefined &&

{error}

} + {error === undefined && identity === undefined && ( + <> + + + + + )} + {identity !== undefined && ( + <> + + {name === undefined ? ( + {hex16(identity.model)} + ) : ( + + + {name} + + {hex16(identity.model)} + + )} + + + {formatVersion(unpackVersion(identity.fw))} + + + {identity.hw} + + + {uid} + + {features(identity.capabilities)} + + )} +
+
+ ); +} diff --git a/src/components/calibration-card.tsx b/src/components/calibration-card.tsx new file mode 100644 index 0000000..b8b1a47 --- /dev/null +++ b/src/components/calibration-card.tsx @@ -0,0 +1,156 @@ +import type { Field, OscClient } from "@openservocore/client"; +import { Check, CircleQuestionMark, Ruler } from "lucide-react"; +import { useId, useState } from "react"; +import { ValueEditor } from "@/components/value-editor"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { CALIBRATION_REGISTERS, editReason, type CalibrationRegister } from "@/lib/calibration"; +import { decodeSpan, span } from "@/lib/card-poll"; +import { hexAddr } from "@/lib/format"; +import { useSession } from "@/lib/session"; +import { calibrationFromTable, calibrationStatus, type Calibration } from "@/lib/units"; + +const NAMES = CALIBRATION_REGISTERS.map((r) => r.name); + +async function readCalibration( + client: OscClient, + id: number, + fields: readonly Field[], +): Promise { + const block = span(fields, NAMES); + const bytes = await client.read(id, block.addr, block.count); + return calibrationFromTable(decodeSpan(fields, block, bytes)); +} + +export function CalibrationCard({ id, uid }: { id: number; uid: string }) { + const { descriptor, descriptorError, values, run, refreshConstants } = useSession(); + const [own, setOwn] = useState(); + const cal = own ?? values.get(uid)?.constants.calibration; + const fields = descriptor?.fields(); + + async function apply(field: Field, raw: number) { + if (descriptor === undefined || fields === undefined) return; + const kind = field.kind === "int" ? "int" : "uint"; + const bytes = descriptor.encode(field.name, { kind, value: raw }); + const fresh = await run(async (c) => { + await c.write(id, field.addr, bytes); + return readCalibration(c, id, fields); + }); + setOwn(fresh); + refreshConstants(uid); + } + + return ( + + + + + Calibration + + + + + + Five numbers map the sensor's counts to degrees at the output shaft. Click a value to + edit it; degrees appear everywhere once all five make sense. + + + + {cal !== undefined && ( + + + + )} + + + {descriptorError !== undefined &&

{descriptorError}

} + {cal === undefined || fields === undefined ? ( +
+ {CALIBRATION_REGISTERS.map((reg) => ( + + ))} +
+ ) : ( +
+ {CALIBRATION_REGISTERS.map((reg) => ( + + ))} +
+ )} +
+
+ ); +} + +function fieldNamed(fields: readonly Field[], name: string): Field { + const f = fields.find((f) => f.name === name); + if (f === undefined) throw new Error(`descriptor has no ${name}`); + return f; +} + +function StatusChip({ cal }: { cal: Calibration }) { + const status = calibrationStatus(cal); + if (status.valid) { + return ( + + + Calibrated + + ); + } + return ( + + + + Not calibrated + + + {status.reason} + + ); +} + +function Row({ + reg, + field, + cal, + onApply, +}: { + reg: CalibrationRegister; + field: Field; + cal: Calibration; + onApply: (field: Field, raw: number) => Promise; +}) { + const labelId = useId(); + return ( +
+ + {reg.label} + + + (typeof raw === "number" ? editReason(cal, reg.name, raw) : undefined)} + onApply={(raw) => (typeof raw === "number" ? onApply(field, raw) : Promise.resolve())} + /> + + + {field.name} {hexAddr(field.addr)} + +
+ ); +} diff --git a/src/components/editor-preview.tsx b/src/components/editor-preview.tsx deleted file mode 100644 index 88e60fc..0000000 --- a/src/components/editor-preview.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { Field } from "@openservocore/client"; -import { useState } from "react"; -import { ValueEditor } from "./value-editor"; -import type { EditValue } from "@/lib/edit"; - -// Copied from descriptors/osc-servo/0.1.json so the preview needs no session. -const PREVIEW: { field: Field; initial: EditValue }[] = [ - { - field: { - name: "id", - addr: 16, - width: 1, - access: "rw", - kind: "uint", - min: 1, - max: 249, - variants: [], - }, - initial: 1, - }, - { - field: { - name: "mode", - addr: 388, - width: 1, - access: "rw", - kind: "enum", - variants: [ - { name: "OpenLoop", value: 0 }, - { name: "Current", value: 1 }, - { name: "Velocity", value: 2 }, - { name: "Position", value: 3 }, - ], - }, - initial: 3, - }, - { - field: { name: "torque_enable", addr: 384, width: 1, access: "rw", kind: "bool", variants: [] }, - initial: false, - }, - { - field: { name: "words", addr: 640, width: 8, access: "rw", kind: "bytes", variants: [] }, - initial: new Uint8Array([0x4f, 0x53, 0x43, 0x00, 0x01, 0x02, 0x03, 0x04]), - }, -]; - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function PreviewRow({ field, initial }: { field: Field; initial: EditValue }) { - const [value, setValue] = useState(initial); - return ( - <> -
{field.name}
-
- { - await delay(300); - setValue(raw); - }} - /> -
- - ); -} - -export function EditorPreview() { - return ( -
-

Editor preview

-
- {PREVIEW.map((p) => ( - - ))} -
-
- ); -} diff --git a/src/components/health-card.tsx b/src/components/health-card.tsx new file mode 100644 index 0000000..0d05de6 --- /dev/null +++ b/src/components/health-card.tsx @@ -0,0 +1,108 @@ +import type { Health } from "@openservocore/client"; +import { CircleAlert, CircleCheck, HeartPulse, TriangleAlert } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { countersLine, statements, trimLine, type Level } from "@/lib/health"; +import { useSession } from "@/lib/session"; + +const POLL_MS = 1000; + +const icons = { fault: CircleAlert, warn: TriangleAlert, ok: CircleCheck }; +const tone: Record = { + fault: "text-danger", + warn: "text-warning", + ok: "text-success", +}; + +export function HealthCard({ id }: { id: number }) { + const { run } = useSession(); + const [health, setHealth] = useState(); + const [error, setError] = useState(); + const [clearing, setClearing] = useState(false); + + useEffect(() => { + let live = true; + // One read in flight at a time: a poll that outlasts its period is skipped + // rather than queued behind itself. + let busy = false; + async function poll() { + if (busy || !live) return; + busy = true; + try { + const read = await run((c) => c.health(id)); + if (live) { + setHealth(read); + setError(undefined); + } + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } finally { + busy = false; + } + } + void poll(); + const timer = window.setInterval(() => void poll(), POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + }, [run, id]); + + async function clear() { + setClearing(true); + try { + await run((c) => c.clearCounters(id)); + setHealth(await run((c) => c.health(id))); + setError(undefined); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setClearing(false); + } + } + + return ( + + + + + Health + + + + {error !== undefined &&

{error}

} + {health === undefined ? ( + <> + + + + ) : ( + <> + {statements(health).map(({ level, text }) => { + const Icon = icons[level]; + return ( +
+ + {text} +
+ ); + })} + +
+ {trimLine(health)} +
+
+ {countersLine(health)} + +
+ + )} +
+
+ ); +} diff --git a/src/components/manage-card.tsx b/src/components/manage-card.tsx new file mode 100644 index 0000000..430802e --- /dev/null +++ b/src/components/manage-card.tsx @@ -0,0 +1,284 @@ +import type { Descriptor, OscClient } from "@openservocore/client"; +import { LoaderCircle, RotateCcw, Save, SlidersHorizontal, TriangleAlert } from "lucide-react"; +import { useEffect, useId, useState, type KeyboardEvent } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ID_MAX, ID_MIN, idIssue, rescans, selectAfter, type ManageAction } from "@/lib/manage"; +import { useSession } from "@/lib/session"; + +const POLL_MS = 1000; +const NOTE_MS = 4000; + +const HELP = { + id: "Addressed by serial, so it also fixes two servos sharing an id. Ids 1 to 249.", + settings: "Save keeps changes across power off.", + danger: "Erases settings and calibration; comes back as ID 1 at 1 M.", +}; + +/** Protocol sec 9.4: flash programming, not the data, is what torque gates. */ +const TORQUE_ON = "Turn torque off first: saving stalls the servo while it writes flash."; + +type Row = keyof typeof HELP; + +interface Note { + row: Row; + text: string; + bad: boolean; +} + +export function ManageCard({ id, uid }: { id: number; uid: string }) { + const { servos, descriptor, run, discover, select } = useSession(); + const inputId = useId(); + const [text, setText] = useState(String(id)); + const [busy, setBusy] = useState(); + const [note, setNote] = useState(); + const [confirming, setConfirming] = useState(false); + const torque = useTorque(id, descriptor); + + useEffect(() => { + if (note === undefined || note.bad) return; + const timer = window.setTimeout(() => { + setNote(undefined); + }, NOTE_MS); + return () => { + window.clearTimeout(timer); + }; + }, [note]); + + const next = Number(text); + // Untouched, the field holds this servo's own id: no point saying so. + const issue = + text === String(id) + ? undefined + : idIssue( + next, + id, + servos.map((s) => s.id), + ); + + async function act( + row: Row, + action: ManageAction, + fn: (client: OscClient) => Promise, + done: string, + newId: number, + ) { + setBusy(action); + setNote(undefined); + try { + await run(fn); + if (rescans(action)) await discover(); + const pick = selectAfter(action, newId); + if (pick !== undefined) select(pick); + setNote({ row, text: done, bad: false }); + } catch (e) { + setNote({ row, text: e instanceof Error ? e.message : String(e), bad: true }); + } finally { + setBusy(undefined); + } + } + + function hint(row: Row): Note { + if (row === "id" && issue !== undefined) return { row, text: issue, bad: true }; + if (note?.row === row) return note; + return { row, text: HELP[row], bad: false }; + } + + const saveButton = ( + + ); + + return ( + + + + + Manage + + + +
+ +
+ { + setText(e.target.value); + }} + /> + +
+ +
+ +
+
+ {torque === true ? ( + // The disabled button drops pointer events, so the wrapper hosts the tooltip. + + +
{saveButton}
+
+ {TORQUE_ON} +
+ ) : ( + saveButton + )} + +
+ +
+ + + +
+ + Danger zone + +
+ +
+ + {confirming && ( +
) => { + if (e.key !== "Escape") return; + e.stopPropagation(); + setConfirming(false); + }} + > +

+ ID {id} loses its settings and calibration, then comes back as ID 1 at 1 M. +

+
+ + +
+
+ )} +
+
+
+ ); +} + +function Hint({ note }: { note: Note }) { + return

{note.text}

; +} + +/** The Save gate: `torque_enable` read off the table on the cards' cadence. */ +function useTorque(id: number, descriptor: Descriptor | undefined): boolean | undefined { + const { run } = useSession(); + const [on, setOn] = useState(); + + useEffect(() => { + if (descriptor === undefined) return; + const table = descriptor; + const field = table.fields().find((f) => f.name === "torque_enable"); + if (field === undefined) return; + const { name, addr, width } = field; + let live = true; + let busy = false; + async function poll() { + if (busy || !live) return; + busy = true; + try { + const bytes = await run((c) => c.read(id, addr, width)); + const value = table.decode(name, bytes); + if (live && value.kind === "bool") setOn(value.value); + } catch { + // The gate holds its last reading; Health reports a servo gone quiet. + } finally { + busy = false; + } + } + void poll(); + const timer = window.setInterval(() => void poll(), POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + }, [run, id, descriptor]); + + return on; +} diff --git a/src/components/value-editor.tsx b/src/components/value-editor.tsx index a1ef8fb..9e918e5 100644 --- a/src/components/value-editor.tsx +++ b/src/components/value-editor.tsx @@ -11,6 +11,7 @@ import { toRaw, type EditValue, type FieldKind, + type NumberDisplay, } from "@/lib/edit"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -30,13 +31,29 @@ interface ValueEditorProps { field: Field; value: EditValue; onApply: (raw: EditValue) => Promise; + display?: NumberDisplay; + /** The page's own rule over a parsed edit; its message keeps Apply disabled. */ + validate?: (raw: EditValue) => string | undefined; disabled?: string; } const valueClass = "rounded-sm px-1 font-mono tabular-nums"; -export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProps) { - const kind = fieldKind(field); +function rawOf(kind: FieldKind, value: EditValue): EditValue { + return kind.kind === "number" && typeof value === "number" ? toRaw(kind, value) : value; +} + +export function ValueEditor({ + field, + value, + onApply, + display, + validate, + disabled, +}: ValueEditorProps) { + const base = fieldKind(field); + const kind: FieldKind = + base.kind === "number" && display !== undefined ? { ...base, ...display } : base; const label = formatValue(kind, value); const inputId = useId(); const [open, setOpen] = useState(false); @@ -58,7 +75,8 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp } const parsed = parseInput(kind, text); - const message = parsed.ok ? error : parsed.reason; + const issue = parsed.ok ? validate?.(rawOf(kind, parsed.value)) : parsed.reason; + const message = issue ?? error; function edit(next: string) { setText(next); @@ -74,14 +92,10 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp } async function apply() { - if (!parsed.ok || pending) return; + if (!parsed.ok || issue !== undefined || pending) return; setPending(true); try { - const raw = - kind.kind === "number" && typeof parsed.value === "number" - ? toRaw(kind, parsed.value) - : parsed.value; - await onApply(raw); + await onApply(rawOf(kind, parsed.value)); setOpen(false); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -115,7 +129,13 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp {field.name} {hexAddr(field.addr)} - + {kind.kind === "number" && rangeHint(kind) !== undefined && (

{rangeHint(kind)}

)} @@ -134,7 +154,7 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp