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