Public: anyone can read this; the crew can change it.

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

settings.servoGains holds the measured per-joint P/I/D, validated at PUT /settings (src/servo-gains.ts, no servo writes yet) #536

Closed opened this
Closed without merging

smith/arm:smith/515-settings-servogains-holds-the-measured-p → main 3 commits fork 1 thread · 1 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
5 files changed +112 −3
Software/README.md +1
@@ -819,6 +819,7 @@ conservative. The owner changes them in the settings panel, or with
819819 | `parkPose` | `park` | where the arm goes, slowly, when its driver's connection is lost (a stream that drops or stops answering pings, not one that is closed); with no pose of that name, the middle of every arm joint's range |
820820 | `parkSpeedDegS` | 20 | how fast the park move goes |
821821 | `gripperTorquePercent` | 35 | the gripper's force limit on the stream, `/grip`, moves and playbacks, and while it holds |
822 | `servoGains` | joints 1, 4, 5: P64 I0 D32; joints 2, 3: P64 I4 D32 | the measured P/I/D per joint id (`"1"`-`"5"`), integers 0-254; a patch keeps the joints and gains it does not name; the gripper (6) is refused, it stays at P16 and torque-capped; held and validated only: nothing writes it to a servo yet, and the settings page and the terminal have no control for it on purpose; a `settings.json` without it loads the defaults |
822823
823824 **The table plane.** The zone's table starts at the bottom of the model's
824825 base, which is rarely where the real table is. `tableOffsetMm` (-100 to 100,
Software/so101/src/server.test.ts +31
@@ -1,5 +1,6 @@
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22 import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3 import { DEFAULT_SERVO_GAINS } from "./servo-gains.ts";
34 import { connect } from "node:net";
45 import { tmpdir } from "node:os";
56 import { join } from "node:path";
@@ -643,6 +644,16 @@ describe("the safe zone, the jam watch, and the model routes", () => {
643644 expect(again.jointOffsetsDeg).toEqual({ wrist_flex: 2 });
644645 });
645646
outdated

Add the companion test: settings.json lacking servoGains loads as DEFAULT_SERVO_GAINS, and a stored servoGains survives a restart.

647 test("servoGains: defaults shown, partial patch merged, bad input a 400 that changes nothing", async () => {
648 const get = async () => ((await (await fetch(`${zurl}/settings`)).json()) as { servoGains: Record<string, { p: number; i: number; d: number }> }).servoGains;
649 expect((await get())["2"]).toEqual({ p: 64, i: 4, d: 32 });
650 for (const bad of [{ "9": { p: 1 } }, { "6": { p: 16 } }, { "1": { p: 1.5 } }, { "1": { p: 255 } }]) expect((await zcall("/settings", { servoGains: bad }, "PUT")).status).toBe(400);
651 expect((await get())["1"]).toEqual({ p: 64, i: 0, d: 32 });
652 expect((await zcall("/settings", { servoGains: { "1": { p: 70 } } }, "PUT")).status).toBe(200);
653 expect((await get())["1"]).toEqual({ p: 70, i: 0, d: 32 });
654 expect((await zcall("/settings", { servoGains: { "1": { p: 64 } } }, "PUT")).status).toBe(200);
655 });
656
646657 test("table-here puts the table under the resting arm; an out-of-range offset is a 400", async () => {
647658 expect((await zcall("/settings", { tableOffsetMm: 101 }, "PUT")).status).toBe(400);
648659 expect((await zcall("/settings", { tableOffsetMm: "up" }, "PUT")).status).toBe(400);
@@ -1055,6 +1066,26 @@ describe("settings on disk", () => {
10551066 });
10561067 });
10571068
1069 describe("servoGains on disk", () => {
1070 test("a settings.json without servoGains loads the defaults; stored gains survive a restart; a bad stored joint is skipped", async () => {
1071 const dir = mkdtempSync(join(tmpdir(), "so101-gains-"));
1072 const boot = () => startServer(new FeetechBus(new FakeBus([1, 2, 6])), { host: "127.0.0.1", port: 0, ids: [1, 2, 6], posesFile: join(dir, "poses.json") });
1073 const gains = async (port: number) => ((await (await fetch(`http://127.0.0.1:${port}/settings`)).json()) as { servoGains: unknown }).servoGains;
1074 writeFileSync(join(dir, "settings.json"), JSON.stringify({ zone: "enforce" }));
1075 const s1 = await boot();
1076 expect(await gains(s1.port)).toEqual(DEFAULT_SERVO_GAINS);
1077 expect((await fetch(`http://127.0.0.1:${s1.port}/settings`, { method: "PUT", body: JSON.stringify({ servoGains: { "3": { i: 6 } } }) })).status).toBe(200);
1078 await Promise.race([s1.close(), sleep(300)]);
1079 const s2 = await boot();
1080 expect(await gains(s2.port)).toEqual({ ...DEFAULT_SERVO_GAINS, "3": { p: 64, i: 6, d: 32 } });
1081 await Promise.race([s2.close(), sleep(300)]);
1082 writeFileSync(join(dir, "settings.json"), JSON.stringify({ servoGains: { "6": { p: 1 } } }));
1083 const s3 = await boot();
1084 expect(await gains(s3.port)).toEqual(DEFAULT_SERVO_GAINS);
1085 await Promise.race([s3.close(), sleep(300)]);
1086 });
1087 });
1088
10581089 describe("per-joint speed and acceleration limits", () => {
10591090 const ids = [1, 2, 6];
10601091 test("PUT /settings merges the maps, null clears, a bad name is 400, and they survive a restart; the servos get each joint's own limits", async () => {
Software/so101/src/server.ts +17 −3
@@ -31,7 +31,7 @@
3131 * PUT /calibration/<kind> store that JSON next to the poses (kind: camera, camera2, handeye, ...)
3232 * GET /settings the safe zone: {"zone": "enforce"|"warn"|"off", "floorMarginMm", "baseMarginMm", "jointOffsetsDeg"},
3333 * and the safety limits of src/safety.ts (speed, acceleration, rate, dead-man, park, gripper force, joint limits)
34 * PUT /settings change any of those
34 * PUT /settings change any of those; "servoGains" takes partial per-joint patches ({"2": {"p": 80}}), joints 1-5, 0-254
3535 * POST /settings/table-here put the table (tableOffsetMm, -100..100) under the arm's lowest link now, the margin below it
3636 * POST /settings/table-touch {"n": 1|2|3} store the gripper tip as touch n; the third fits the table plane and sets tableOffsetMm
3737 * POST /settings/joint-zero {"joint": "shoulder_pan", "referenceDeg": 0} trim that joint's offset so the model reads referenceDeg at the pose it is in now
@@ -96,6 +96,7 @@ import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from
9696 import { MAX_ZERO_OFFSET_DEG, zeroOffsetDeg } from "./joint-zero.ts";
9797 import { JOINTS, registerName, STS3215, ticksToDegrees } from "./sts3215.ts";
9898 import { jointSteps, parseTrajectory, serializeTrajectory, type Trajectory } from "./trajectory.ts";
99 import { DEFAULT_SERVO_GAINS, parseServoGains, type ServoGains } from "./servo-gains.ts";
99100 import { accelRegister, bound, DEFAULT_SAFETY, Governor, jointBounds, jointMotion, motionLimits, SAFETY_KEYS, SafetyError, TICKS_PER_DEGREE, validateSafety, type SafetySettings } from "./safety.ts";
100101
101102 export interface ServerOptions {
@@ -147,9 +148,11 @@ export interface Settings extends SafetySettings {
147148 tableOffsetMm: number;
148149 /** Per joint, degrees added to the model's angle, to trim the model onto the real arm. */
149150 jointOffsetsDeg: Record<string, number>;
151 /** Measured P/I/D per joint id (1-5); held and validated here, not yet written to a servo. */
152 servoGains: ServoGains;
150153 }
151154
152 export const DEFAULT_SETTINGS: Settings = { zone: "warn", floorMarginMm: 15, baseMarginMm: 10, tableOffsetMm: 0, jointOffsetsDeg: {}, ...DEFAULT_SAFETY };
155 export const DEFAULT_SETTINGS: Settings = { zone: "warn", floorMarginMm: 15, baseMarginMm: 10, tableOffsetMm: 0, jointOffsetsDeg: {}, servoGains: DEFAULT_SERVO_GAINS, ...DEFAULT_SAFETY };
153156
154157 /** An emergency stop in force: who hit it and when. It holds until an admin clears it, across restarts. */
155158 export interface EStop {
@@ -598,6 +601,11 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
598601 throw err;
599602 }
600603 const out: Settings = { ...DEFAULT_SETTINGS, ...safety, jointOffsetsDeg: {} };
604 try {
605 out.servoGains = parseServoGains(s.servoGains);
606 } catch (err) {
607 throw new HttpError(400, err instanceof Error ? err.message : String(err));
608 }
601609 if (s.zone !== undefined) {
602610 if (s.zone !== "enforce" && s.zone !== "warn" && s.zone !== "off") throw new HttpError(400, 'zone must be "enforce", "warn" or "off"');
603611 out.zone = s.zone;
@@ -906,7 +914,13 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
906914 const patch = body[key];
907915 return patch && typeof patch === "object" && !Array.isArray(patch) ? { ...settings[key], ...patch } : (patch ?? settings[key]);
908916 };
909 settings = validateSettings({ ...settings, ...body, jointOffsetsDeg: merged("jointOffsetsDeg"), jointLimitsDeg: merged("jointLimitsDeg"), jointSpeedDegS: merged("jointSpeedDegS"), jointAccelDegS2: merged("jointAccelDegS2") } as Partial<Settings>);
917 let servoGains = settings.servoGains;
918 try {
919 servoGains = parseServoGains(body.servoGains, settings.servoGains);
920 } catch (err) {
921 throw new HttpError(400, err instanceof Error ? err.message : String(err));
922 }
923 settings = validateSettings({ ...settings, ...body, servoGains, jointOffsetsDeg: merged("jointOffsetsDeg"), jointLimitsDeg: merged("jointLimitsDeg"), jointSpeedDegS: merged("jointSpeedDegS"), jointAccelDegS2: merged("jointAccelDegS2") } as Partial<Settings>);
910924 writeAtomic(settingsFile, JSON.stringify(settings, null, 1));
911925 return json(res, 200, { ...settings, model: model !== null });
912926 }
Software/so101/src/servo-gains.test.ts added +22
@@ -0,0 +1,22 @@
1 import { describe, expect, test } from "bun:test";
2 import { DEFAULT_SERVO_GAINS, parseServoGains } from "./servo-gains.ts";
3
4 describe("servo gains", () => {
5 test("defaults: P64 everywhere, I4 on 2 and 3, D 32, no gripper", () => {
6 const g = parseServoGains(undefined);
7 expect(g).toEqual(DEFAULT_SERVO_GAINS);
8 expect(g["6"]).toBeUndefined();
9 for (const j of ["1", "4", "5"]) expect(g[j]).toEqual({ p: 64, i: 0, d: 32 });
10 for (const j of ["2", "3"]) expect(g[j]).toEqual({ p: 64, i: 4, d: 32 });
11 });
12 test("a partial patch changes only what it names and leaves the base alone", () => {
13 const g = parseServoGains({ "2": { p: 80 } });
14 expect(g["2"]).toEqual({ p: 80, i: 4, d: 32 });
15 expect(g["1"]).toEqual(DEFAULT_SERVO_GAINS["1"]);
16 expect(DEFAULT_SERVO_GAINS["2"]!.p).toBe(64);
17 });
18 test("refusals", () => {
19 for (const bad of [{ "7": { p: 1 } }, { "6": { p: 16 } }, { "1": { p: 1.5 } }, { "1": { p: 255 } }, { "1": { p: -1 } }, { "1": { p: "9" } }, { "1": { x: 1 } }, { "1": 5 }, [], 3])
20 expect(() => parseServoGains(bad)).toThrow();
21 });
22 });
Software/so101/src/servo-gains.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * The measured per-joint servo gains (P, I, D), keyed by joint id ("1".."5"). The gripper (6) is not here: it stays
3 * at the servo's P16 and torque-capped. Nothing here writes to a servo; this is the setting and its validation.
4 */
5 export interface Gain {
6 p: number;
7 i: number;
8 d: number;
9 }
10
11 export type ServoGains = Record<string, Gain>;
12
13 export const GAIN_JOINTS = ["1", "2", "3", "4", "5"] as const;
14
15 export const DEFAULT_SERVO_GAINS: ServoGains = {
16 "1": { p: 64, i: 0, d: 32 },
17 "2": { p: 64, i: 4, d: 32 },
18 "3": { p: 64, i: 4, d: 32 },
19 "4": { p: 64, i: 0, d: 32 },
20 "5": { p: 64, i: 0, d: 32 },
21 };
22
23 const copy = (g: ServoGains): ServoGains => Object.fromEntries(Object.entries(g).map(([k, v]) => [k, { ...v }]));
24
25 /** The gains with `input` (a partial per-joint patch) laid over `base`; throws an Error naming the first bad value. */
26 export function parseServoGains(input: unknown, base: ServoGains = DEFAULT_SERVO_GAINS): ServoGains {
27 const out = copy(base);
28 if (input === undefined) return out;
29 if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error("servoGains must be an object");
30 for (const [joint, patch] of Object.entries(input)) {
31 if (joint === "6") throw new Error("servoGains: the gripper (6) stays at P16 and torque-capped");
32 if (!(GAIN_JOINTS as readonly string[]).includes(joint)) throw new Error(`servoGains: no joint "${joint}"`);
33 if (typeof patch !== "object" || patch === null || Array.isArray(patch)) throw new Error(`servoGains: joint ${joint} must be an object`);
34 for (const [key, value] of Object.entries(patch)) {
35 if (key !== "p" && key !== "i" && key !== "d") throw new Error(`servoGains: joint ${joint} has no "${key}" (use p, i, d)`);
36 if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 254) throw new Error(`servoGains: ${joint}.${key} must be an integer 0-254`);
37 out[joint]![key] = value;
38 }
39 }
40 return out;
41 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs