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
99 changes: 99 additions & 0 deletions src/components/about-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-baseline justify-between gap-6">
<span className="shrink-0 text-text-3">{label}</span>
<span className="text-right break-all">{children}</span>
</div>
);
}

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 name = identity === undefined ? undefined : modelName(identity.model);
return (
<Card role="region" aria-label="About">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cog className="size-4" />
About
</CardTitle>
</CardHeader>
<CardContent>
{error !== undefined && <p className="text-danger">{error}</p>}
{error === undefined && identity === undefined && (
<>
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</>
)}
{identity !== undefined && (
<>
<Row label="Model">
{name === undefined ? (
<span className="font-mono">{hex16(identity.model)}</span>
) : (
<Tooltip>
<TooltipTrigger className="underline decoration-dotted underline-offset-4">
{name}
</TooltipTrigger>
<TooltipContent>{hex16(identity.model)}</TooltipContent>
</Tooltip>
)}
</Row>
<Row label="Firmware">
<span className="font-mono">{formatVersion(unpackVersion(identity.fw))}</span>
</Row>
<Row label="Hardware">
<span className="font-mono">{identity.hw}</span>
</Row>
<Row label="Serial">
<span className="font-mono text-xs">{uid}</span>
</Row>
<Row label="Features">{features(identity.capabilities)}</Row>
</>
)}
</CardContent>
</Card>
);
}
156 changes: 156 additions & 0 deletions src/components/calibration-card.tsx
Original file line number Diff line number Diff line change
@@ -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<Calibration> {
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<Calibration>();
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 (
<Card role="region" aria-label="Calibration" className="col-span-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Ruler className="size-4" />
Calibration
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label="About calibration">
<CircleQuestionMark />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start">
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.
</PopoverContent>
</Popover>
</CardTitle>
{cal !== undefined && (
<CardAction>
<StatusChip cal={cal} />
</CardAction>
)}
</CardHeader>
<CardContent>
{descriptorError !== undefined && <p className="text-danger">{descriptorError}</p>}
{cal === undefined || fields === undefined ? (
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
{CALIBRATION_REGISTERS.map((reg) => (
<Skeleton key={reg.name} className="h-12 w-40" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
{CALIBRATION_REGISTERS.map((reg) => (
<Row
key={reg.name}
reg={reg}
field={fieldNamed(fields, reg.name)}
cal={cal}
onApply={apply}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}

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 (
<Badge className="bg-success-soft text-success">
<Check />
Calibrated
</Badge>
);
}
return (
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-text-3">
Not calibrated
</Badge>
</TooltipTrigger>
<TooltipContent>{status.reason}</TooltipContent>
</Tooltip>
);
}

function Row({
reg,
field,
cal,
onApply,
}: {
reg: CalibrationRegister;
field: Field;
cal: Calibration;
onApply: (field: Field, raw: number) => Promise<void>;
}) {
const labelId = useId();
return (
<div role="group" aria-labelledby={labelId} className="flex flex-col gap-0.5">
<span id={labelId} className="text-xs text-text-3">
{reg.label}
</span>
<span className="self-start text-base">
<ValueEditor
field={field}
value={cal[reg.key]}
display={reg.display}
validate={(raw) => (typeof raw === "number" ? editReason(cal, reg.name, raw) : undefined)}
onApply={(raw) => (typeof raw === "number" ? onApply(field, raw) : Promise.resolve())}
/>
</span>
<span className="font-mono text-xs text-text-3">
{field.name} {hexAddr(field.addr)}
</span>
</div>
);
}
81 changes: 0 additions & 81 deletions src/components/editor-preview.tsx

This file was deleted.

Loading
Loading