From 373d19c64a310be8d7ea8c2848dc82e9920974c1 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Wed, 14 Oct 2026 09:25:49 -0700 Subject: [PATCH 1/2] calibration: show the gear train --- src/components/calibration-card.tsx | 17 ++++++++++++++++- src/lib/bus/spans.test.ts | 6 ++++-- src/lib/bus/spans.ts | 13 +++++++++++-- src/lib/units.test.ts | 6 ++++++ src/lib/units.ts | 8 ++++++++ tests/e2e/calibration.spec.ts | 3 +++ 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/components/calibration-card.tsx b/src/components/calibration-card.tsx index 7457838..6072907 100644 --- a/src/components/calibration-card.tsx +++ b/src/components/calibration-card.tsx @@ -13,7 +13,13 @@ import { CALIBRATION_REGISTERS, editReason, type CalibrationRegister } from "@/l import { hex16, hexAddr } from "@/lib/format"; import { useSession } from "@/lib/session"; import { stampWords } from "@/lib/stamp"; -import { calibrationStatus, type Calibration } from "@/lib/units"; +import { calibrationStatus, type Calibration, type GearClass } from "@/lib/units"; + +const GEAR_CLASS_TEXT: Record = { + unknown: "Unknown", + plastic: "Plastic", + metal: "Metal", +}; export function CalibrationCard({ id, uid }: { id: number; uid: string }) { const { descriptor, descriptorError, values, refreshConstants } = useSession(); @@ -79,6 +85,15 @@ export function CalibrationCard({ id, uid }: { id: number; uid: string }) { onApply={apply} /> ))} + {card !== undefined && ( +
+ Gear train + {GEAR_CLASS_TEXT[card.constants.gearClass]} + + gear_class {hexAddr(fieldNamed(fields, "gear_class").addr)} + +
+ )}
Plant stamp {stamp.error !== undefined ? ( diff --git a/src/lib/bus/spans.test.ts b/src/lib/bus/spans.test.ts index 6f2ecb9..45c5204 100644 --- a/src/lib/bus/spans.test.ts +++ b/src/lib/bus/spans.test.ts @@ -28,8 +28,8 @@ test("the live span is one read from pos through vmotor_bias_counts", () => { expect(span(fields, LIVE_REGISTERS)).toEqual({ addr: 568, count: 56 }); }); -test("the constants span is one read from raw_min through vmotor_bias_bot_ohm", () => { - expect(span(fields, CONSTANT_REGISTERS)).toEqual({ addr: 160, count: 46 }); +test("the constants span is one read from raw_min through gear_class", () => { + expect(span(fields, CONSTANT_REGISTERS)).toEqual({ addr: 160, count: 49 }); }); test("plan fails loudly on a descriptor missing a register", () => { @@ -181,6 +181,7 @@ test("the live, health and constants layouts decode from a whole-table image", ( put("angle_min_cdeg", -9500); put("angle_max_cdeg", 9500); put("gear_ratio_centi", 100); + put("gear_class", 2); put("shunt_r_mohm", 10); put("gain_milli", 32000); put("vdd_mv", 3300); @@ -210,6 +211,7 @@ test("the live, health and constants layouts decode from a whole-table image", ( }); expect(constants.sense.gainMilli).toBe(32000); expect(constants.calibrated.valid).toBe(true); + expect(constants.gearClass).toBe("metal"); expect(liveFrom(read(LIVE_REGISTERS))).toEqual({ pos: 2054, current: 2100, diff --git a/src/lib/bus/spans.ts b/src/lib/bus/spans.ts index d08ab1c..e601fd3 100644 --- a/src/lib/bus/spans.ts +++ b/src/lib/bus/spans.ts @@ -8,10 +8,12 @@ import { biasesFromTable, calibrationFromTable, calibrationStatus, + gearClassFromTable, senseFromTable, type Biases, type Calibration, type CalibrationStatus, + type GearClass, type ReadRegister, type Sense, } from "../units"; @@ -128,7 +130,7 @@ export function decodeSpan(fields: readonly Field[], span: Span, bytes: Uint8Arr }; } -/** CALIB pot, kinematics, sense and sense_ext: read once per servo. */ +/** CALIB pot, kinematics, sense, sense_ext and gear class: read once per servo. */ export const CONSTANT_REGISTERS: readonly string[] = [ "raw_min", "raw_max", @@ -148,6 +150,7 @@ export const CONSTANT_REGISTERS: readonly string[] = [ "vmotor_bias_nom_counts", "vmotor_bias_top_ohm", "vmotor_bias_bot_ohm", + "gear_class", ]; /** TELEMETRY sensors the cards show, plus the biases their conversion needs. */ @@ -190,11 +193,17 @@ export interface Constants { sense: Sense; calibration: Calibration; calibrated: CalibrationStatus; + gearClass: GearClass; } export function constantsFrom(read: ReadRegister): Constants { const calibration = calibrationFromTable(read); - return { sense: senseFromTable(read), calibration, calibrated: calibrationStatus(calibration) }; + return { + sense: senseFromTable(read), + calibration, + calibrated: calibrationStatus(calibration), + gearClass: gearClassFromTable(read), + }; } export interface Live { diff --git a/src/lib/units.test.ts b/src/lib/units.test.ts index 339af7b..df5a618 100644 --- a/src/lib/units.test.ts +++ b/src/lib/units.test.ts @@ -10,6 +10,7 @@ import { currentMa, degPerCount, degToCounts, + gearClassFromTable, motorV, positionDeg, senseFromTable, @@ -123,6 +124,11 @@ test("calibrationStatus rejects an unset gear ratio", () => { expect(got.reason).toMatch(/gear/); }); +test("gear_class names plastic and metal and reads anything else as unknown", () => { + const named = [0, 1, 2, 3, 255].map((code) => gearClassFromTable(readFrom({ gear_class: code }))); + expect(named).toEqual(["unknown", "plastic", "metal", "unknown", "unknown"]); +}); + test("positionDeg maps the calibrated span onto the angle endpoints", () => { expect(positionDeg(CAL.rawMin, CAL)).toBeCloseTo(-95, 10); expect(positionDeg(CAL.rawMax, CAL)).toBeCloseTo(95, 10); diff --git a/src/lib/units.ts b/src/lib/units.ts index 6384559..cbca147 100644 --- a/src/lib/units.ts +++ b/src/lib/units.ts @@ -53,6 +53,14 @@ export interface Calibration { gearRatioCenti: number; } +/** `gear_class`, which cal writes; 0 or a code this build does not know is unknown. */ +export type GearClass = "unknown" | "plastic" | "metal"; + +export function gearClassFromTable(read: ReadRegister): GearClass { + const code = read("gear_class"); + return code === 1 ? "plastic" : code === 2 ? "metal" : "unknown"; +} + /** Bias counts the servo measures for itself; both live in TELEMETRY, not CALIB. */ export interface Biases { currentBiasCounts: number; diff --git a/tests/e2e/calibration.spec.ts b/tests/e2e/calibration.spec.ts index 1d94a8f..afae892 100644 --- a/tests/e2e/calibration.spec.ts +++ b/tests/e2e/calibration.spec.ts @@ -33,6 +33,9 @@ test("the card shows the status chip and one row per calibration register", asyn await expect(group.getByRole("button", { name: value })).toBeVisible(); } await expect(card.getByText("raw_max 0x0a2")).toBeVisible(); + const gears = card.getByRole("group", { name: "Gear train" }); + await expect(gears).toContainText("Plastic"); + await expect(gears).toContainText("gear_class 0x0d0"); }); test("edits write through, the row shows the new value and the status stays Calibrated", async ({ From 752ccd1dd5837480a83c17f3fcf3c123553a1915 Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Wed, 14 Oct 2026 09:54:26 -0700 Subject: [PATCH 2/2] live: read a descending angle map lowest degree first --- src/lib/control.test.ts | 16 ++++++++++++++++ src/lib/control.ts | 11 +++++++++++ src/lib/table-model.test.ts | 6 ++++++ src/lib/table-model.ts | 11 +++++++++-- src/routes/live.tsx | 11 +++++++---- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/lib/control.test.ts b/src/lib/control.test.ts index 45bcd82..1a2c4a7 100644 --- a/src/lib/control.test.ts +++ b/src/lib/control.test.ts @@ -5,6 +5,7 @@ import fixture from "../../tests/fixtures/stall-24mhz.json"; import { clampGoal, CONTROL_REGISTERS, + displayEnds, goalAtStop, decodeControl, dutyPercent, @@ -157,6 +158,21 @@ test("the position goal is degrees through the calibration, or counts in raw mod expect(raw.fromDisplay(1234.6)).toBe(1235); }); +test("a descending angle map reads its goal ends lowest degree first, the slider inverted", () => { + // degrees counterclockwise-positive on a pot whose counts rise clockwise + const ccwLow: Calibration = { ...cal, angleMinCdeg: 18000, angleMaxCdeg: 0 }; + const deg = goalSpec("Position", { ...ctx, cal: ccwLow }); + expect(deg.range).toEqual({ min: 200, max: 3800 }); + expect(deg.fromDisplay(135)).toBe(1100); + expect(displayEnds(deg)).toEqual({ low: 3800, high: 200, inverted: true }); + expect(displayEnds(goalSpec("Position", ctx))).toEqual({ low: 200, high: 3800, inverted: false }); + const vel = goalSpec("Velocity", { ...ctx, cal: ccwLow }); + expect(vel.fromDisplay(50)).toBe(-1000); + expect(displayEnds(vel)).toEqual({ low: 4000, high: -4000, inverted: true }); + const raw = goalSpec("Position", { ...ctx, cal: ccwLow, raw: true }); + expect(displayEnds(raw).inverted).toBe(false); +}); + test("the velocity goal is deg/s over the velocity limit, counts/s in raw mode", () => { const spec = goalSpec("Velocity", ctx); expect(spec).toMatchObject({ register: "goal_velocity", unit: "deg/s", digits: 0 }); diff --git a/src/lib/control.ts b/src/lib/control.ts index 3ef680a..596c6aa 100644 --- a/src/lib/control.ts +++ b/src/lib/control.ts @@ -243,3 +243,14 @@ export interface GoalSpec extends GoalUnits { export function goalSpec(mode: ModeName, ctx: GoalContext): GoalSpec { return { ...goalUnits(mode, ctx), range: goalRange(mode, ctx.cal, ctx.limits) }; } + +/** + * The goal range's ends as the display reads them, lowest first. Degrees rise + * counterclockwise, so on a pot whose counts rise clockwise the angle map + * descends and the ends swap: `inverted` turns the slider to match. + */ +export function displayEnds(spec: GoalSpec): { low: number; high: number; inverted: boolean } { + const { min, max } = spec.range; + const inverted = spec.toDisplay(max) < spec.toDisplay(min); + return inverted ? { low: max, high: min, inverted } : { low: min, high: max, inverted }; +} diff --git a/src/lib/table-model.test.ts b/src/lib/table-model.test.ts index f32973c..7b7fea6 100644 --- a/src/lib/table-model.test.ts +++ b/src/lib/table-model.test.ts @@ -348,6 +348,12 @@ describe("real units", () => { expect(realValue(rowOf("accel_limit_q88"), 3840, cal)).toBe("1500 deg/s^2"); }); + test("a descending angle map leaves the trajectory limits unsigned", () => { + const ccwLow = { ...cal, angleMinCdeg: 18000, angleMaxCdeg: 0 }; + expect(realValue(rowOf("velocity_limit_cps"), 1500, ccwLow)).toBe("75 deg/s"); + expect(realValue(rowOf("accel_limit_q88"), 3840, ccwLow)).toBe("1500 deg/s^2"); + }); + test("without a usable calibration, or for any other register, the raw value stands alone", () => { expect(realValue(rowOf("velocity_limit_cps"), 1500, undefined)).toBeUndefined(); expect(realValue(rowOf("velocity_limit_cps"), 1500, { ...cal, rawMax: 100 })).toBeUndefined(); diff --git a/src/lib/table-model.ts b/src/lib/table-model.ts index 695b6db..f77e57f 100644 --- a/src/lib/table-model.ts +++ b/src/lib/table-model.ts @@ -188,8 +188,15 @@ const TEXT: ReadonlyMap string> = new Map([ /** Registers that read in real units through a usable calibration; the raw value stays beside them. */ const REAL: ReadonlyMap string> = new Map([ - ["velocity_limit_cps", (v, cal) => formatQuantity(velocityDegPerS(v, cal), DISPLAY.velocity)], - ["accel_limit_q88", (v, cal) => formatQuantity(accelDegPerS2(v, cal), DISPLAY.acceleration)], + // limits are magnitudes: a descending angle map must not sign them + [ + "velocity_limit_cps", + (v, cal) => formatQuantity(Math.abs(velocityDegPerS(v, cal)), DISPLAY.velocity), + ], + [ + "accel_limit_q88", + (v, cal) => formatQuantity(Math.abs(accelDegPerS2(v, cal)), DISPLAY.acceleration), + ], ]); export function realValue( diff --git a/src/routes/live.tsx b/src/routes/live.tsx index f81d59b..e822a7f 100644 --- a/src/routes/live.tsx +++ b/src/routes/live.tsx @@ -37,6 +37,7 @@ import { dutyPercent, goalOf, goalAtStop, + displayEnds, goalSpec, goalUnits, isWindow, @@ -675,6 +676,7 @@ function Controls({ }; const fmt = (c: number) => (spec === undefined ? "" : spec.toDisplay(c).toFixed(spec.digits)); + const ends = spec === undefined ? { low: 0, high: 0, inverted: false } : displayEnds(spec); // A latched fault holds the motor off whatever torque_enable says // (firmware kernel/faults.rs); the switch alone does not say so. const faulted = servo?.fault !== undefined; @@ -758,6 +760,7 @@ function Controls({ aria-label="Goal" min={spec.range.min} max={spec.range.max} + inverted={ends.inverted} step={1} value={[counts]} onValueChange={([v]) => { @@ -765,8 +768,8 @@ function Controls({ }} />
- {fmt(spec.range.min)} - {fmt(spec.range.max)} + {fmt(ends.low)} + {fmt(ends.high)}