Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 6 additions & 33 deletions src/components/about-card.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
import { unpackVersion, type Identity } from "@openservocore/client";
import { unpackVersion } from "@openservocore/client";
import { Cog } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import 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 { useReadOnce } from "@/lib/bus/hooks";
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(", ");
}
import { features, identityFrom, IDENTITY_REGISTERS } from "@/lib/identity";

function Row({ label, children }: { label: string; children: ReactNode }) {
return (
Expand All @@ -30,24 +19,8 @@ function Row({ label, children }: { label: string; children: ReactNode }) {
}

export function AboutCard({ id, uid }: { id: number; uid: string }) {
const { run } = useSession();
const [identity, setIdentity] = useState<Identity>();
const [error, setError] = useState<string>();

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 { snapshot, error } = useReadOnce(id, IDENTITY_REGISTERS);
const identity = snapshot === undefined ? undefined : identityFrom(snapshot.read);

const name = identity === undefined ? undefined : modelName(identity.model);
return (
Expand Down
37 changes: 10 additions & 27 deletions src/components/calibration-card.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,29 @@
import type { Field, OscClient } from "@openservocore/client";
import type { Field } from "@openservocore/client";
import { Check, CircleQuestionMark, Ruler } from "lucide-react";
import { useId, useState } from "react";
import { useId } 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 { useBus } from "@/lib/bus/hooks";
import { CALIBRATION_REGISTERS, editReason, type CalibrationRegister } from "@/lib/calibration";
import { decodeSpan, span } from "@/lib/bus/spans";
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<Calibration> {
const block = span(fields, NAMES);
const bytes = await client.read(id, block.addr, block.count);
return calibrationFromTable(decodeSpan(fields, block, bytes));
}
import { calibrationStatus, type Calibration } from "@/lib/units";

export function CalibrationCard({ id, uid }: { id: number; uid: string }) {
const { descriptor, descriptorError, values, run, refreshConstants } = useSession();
const [own, setOwn] = useState<Calibration>();
const cal = own ?? values.get(uid)?.constants.calibration;
const { descriptor, descriptorError, values, refreshConstants } = useSession();
const bus = useBus();
const cal = 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);
await bus.write(id, field.name, { kind: field.kind === "int" ? "int" : "uint", value: raw });
// The write dirties the CALIB span; the session re-reads the constants
// every card on the page shows.
refreshConstants(uid);
}

Expand Down
8 changes: 4 additions & 4 deletions src/components/connection-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ export function ConnectionPopover({
children: ReactNode;
}) {
const session = useSession();
const { status, client, simulated, baud, rails, missing, error } = session;
const { status, linkInfo, simulated, baud, rails, missing, error } = session;
const [confirming, setConfirming] = useState(false);
const [choice, setChoice] = useState<BaudRate>();
const [railsError, setRailsError] = useState<string>();
const rate = choice ?? baud ?? "b1000000";
const connected = client !== undefined;
const connected = linkInfo !== undefined;
const ready = status === "ready";
const badge = chip(status);

Expand Down Expand Up @@ -92,8 +92,8 @@ export function ConnectionPopover({
<span className="font-medium">{simulated ? "Simulated adapter" : "osc-adapter"}</span>
<Badge className={badge.className}>{badge.text}</Badge>
</div>
{connected && (
<span className="text-xs text-text-3">Firmware {client.linkInfo().version}</span>
{linkInfo !== undefined && (
<span className="text-xs text-text-3">Firmware {linkInfo.version}</span>
)}
</div>

Expand Down
50 changes: 13 additions & 37 deletions src/components/health-card.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import type { Health } from "@openservocore/client";
import { CircleAlert, CircleCheck, HeartPulse, TriangleAlert } from "lucide-react";
import { useEffect, useState } from "react";
import { 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 { useBus, useRegisters } from "@/lib/bus/hooks";
import { healthFrom, HEALTH_REGISTERS } from "@/lib/bus/spans";
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<Level, string> = {
Expand All @@ -18,44 +16,22 @@ const tone: Record<Level, string> = {
};

export function HealthCard({ id }: { id: number }) {
const { run } = useSession();
const [health, setHealth] = useState<Health>();
const bus = useBus();
const snapshot = useRegisters(id, HEALTH_REGISTERS, "slow");
const [error, setError] = useState<string>();
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]);
// A stale snapshot carries what the cache still holds, which may be nothing.
const complete =
snapshot !== undefined && HEALTH_REGISTERS.every((name) => snapshot.values.has(name));
const health = complete ? healthFrom(snapshot.read) : undefined;
const problem = error ?? (snapshot?.stale === true ? snapshot.error : undefined);

async function clear() {
setClearing(true);
try {
await run((c) => c.clearCounters(id));
setHealth(await run((c) => c.health(id)));
// The counters come back on the subscription's next read.
await bus.command((c) => c.clearCounters(id));
setError(undefined);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
Expand All @@ -73,7 +49,7 @@ export function HealthCard({ id }: { id: number }) {
</CardTitle>
</CardHeader>
<CardContent>
{error !== undefined && <p className="text-danger">{error}</p>}
{problem !== undefined && <p className="text-danger">{problem}</p>}
{health === undefined ? (
<>
<Skeleton className="h-5 w-full" />
Expand Down
55 changes: 15 additions & 40 deletions src/components/manage-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Descriptor, OscClient } from "@openservocore/client";
import type { 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";
Expand All @@ -7,11 +7,13 @@ 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 { useBus, useRegisters } from "@/lib/bus/hooks";
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;
/** Inside the cards' span on every servo, so the gate costs no exchange. */
const TORQUE_REGISTERS: readonly string[] = ["torque_enable"];

const HELP = {
id: "Addressed by serial, so it also fixes two servos sharing an id. Ids 1 to 249.",
Expand All @@ -31,13 +33,14 @@ interface Note {
}

export function ManageCard({ id, uid }: { id: number; uid: string }) {
const { servos, descriptor, run, discover, select } = useSession();
const { servos, discover, select } = useSession();
const bus = useBus();
const inputId = useId();
const [text, setText] = useState(String(id));
const [busy, setBusy] = useState<ManageAction>();
const [note, setNote] = useState<Note>();
const [confirming, setConfirming] = useState(false);
const torque = useTorque(id, descriptor);
const torque = useTorque(id);

useEffect(() => {
if (note === undefined || note.bad) return;
Expand Down Expand Up @@ -70,7 +73,7 @@ export function ManageCard({ id, uid }: { id: number; uid: string }) {
setBusy(action);
setNote(undefined);
try {
await run(fn);
await bus.command(fn);
if (rescans(action)) await discover();
const pick = selectAfter(action, newId);
if (pick !== undefined) select(pick);
Expand Down Expand Up @@ -246,39 +249,11 @@ function Hint({ note }: { note: Note }) {
return <p className={`text-xs ${note.bad ? "text-danger" : "text-text-3"}`}>{note.text}</p>;
}

/** 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<boolean>();

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;
/**
* The Save gate: `torque_enable` on the cards' cadence. A stale snapshot still
* carries the last reading; Health reports a servo gone quiet.
*/
function useTorque(id: number): boolean | undefined {
const value = useRegisters(id, TORQUE_REGISTERS, "slow")?.values.get("torque_enable");
return value?.kind === "bool" ? value.value : undefined;
}
26 changes: 26 additions & 0 deletions src/lib/identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, test } from "vitest";
import { features, identityFrom, IDENTITY_REGISTERS } from "./identity";

test("identityFrom reads the four identity registers", () => {
const table = new Map([
["model_number", 0x0101],
["firmware_version", 0x0100],
["hardware_revision", 3],
["capability_flags", 1],
]);
const names: string[] = [];
const read = (name: string): number => {
names.push(name);
const v = table.get(name);
if (v === undefined) throw new Error(`${name} outside the read`);
return v;
};
expect(identityFrom(read)).toEqual({ model: 0x0101, fw: 0x0100, hw: 3, capabilities: 1 });
expect(new Set(names)).toEqual(new Set(IDENTITY_REGISTERS));
});

test("features names the known bits and numbers the rest", () => {
expect(features(0)).toBe("None");
expect(features(1)).toBe("Motor encoder");
expect(features(0b101)).toBe("Motor encoder, bit 2");
});
31 changes: 31 additions & 0 deletions src/lib/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Identity } from "@openservocore/client";
import type { ReadRegister } from "./units";

/** CONFIG-COMMON front: what a servo says about itself, read once per servo. */
export const IDENTITY_REGISTERS: readonly string[] = [
"model_number",
"firmware_version",
"hardware_revision",
"capability_flags",
];

export function identityFrom(read: ReadRegister): Identity {
return {
model: read("model_number"),
fw: read("firmware_version"),
hw: read("hardware_revision"),
capabilities: read("capability_flags"),
};
}

/** `capability_flags` bit order (protocol sec 5.4). */
const CAPABILITIES: readonly string[] = ["Motor encoder"];

export 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(", ");
}
Loading
Loading