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
smith/arm:smith/515-settings-servogains-holds-the-measured-p → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +112
−3
Software/README.md +1
| @@ -819,6 +819,7 @@ conservative. The owner changes them in the settings panel, or with | ||
| 819 | 819 | | `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 | |
| 820 | 820 | | `parkSpeedDegS` | 20 | how fast the park move goes | |
| 821 | 821 | | `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 | | |
| 822 | 823 | |
| 823 | 824 | **The table plane.** The zone's table starts at the bottom of the model's |
| 824 | 825 | base, which is rarely where the real table is. `tableOffsetMm` (-100 to 100, |
Software/so101/src/server.test.ts +31
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | 2 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; |
| 3 | import { DEFAULT_SERVO_GAINS } from "./servo-gains.ts"; | |
| 3 | 4 | import { connect } from "node:net"; |
| 4 | 5 | import { tmpdir } from "node:os"; |
| 5 | 6 | import { join } from "node:path"; |
| @@ -643,6 +644,16 @@ describe("the safe zone, the jam watch, and the model routes", () => { | ||
| 643 | 644 | expect(again.jointOffsetsDeg).toEqual({ wrist_flex: 2 }); |
| 644 | 645 | }); |
| 645 | 646 | |
| 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 | ||
| 646 | 657 | test("table-here puts the table under the resting arm; an out-of-range offset is a 400", async () => { |
| 647 | 658 | expect((await zcall("/settings", { tableOffsetMm: 101 }, "PUT")).status).toBe(400); |
| 648 | 659 | expect((await zcall("/settings", { tableOffsetMm: "up" }, "PUT")).status).toBe(400); |
| @@ -1055,6 +1066,26 @@ describe("settings on disk", () => { | ||
| 1055 | 1066 | }); |
| 1056 | 1067 | }); |
| 1057 | 1068 | |
| 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 | ||
| 1058 | 1089 | describe("per-joint speed and acceleration limits", () => { |
| 1059 | 1090 | const ids = [1, 2, 6]; |
| 1060 | 1091 | 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 @@ | ||
| 31 | 31 | * PUT /calibration/<kind> store that JSON next to the poses (kind: camera, camera2, handeye, ...) |
| 32 | 32 | * GET /settings the safe zone: {"zone": "enforce"|"warn"|"off", "floorMarginMm", "baseMarginMm", "jointOffsetsDeg"}, |
| 33 | 33 | * 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 | |
| 35 | 35 | * POST /settings/table-here put the table (tableOffsetMm, -100..100) under the arm's lowest link now, the margin below it |
| 36 | 36 | * POST /settings/table-touch {"n": 1|2|3} store the gripper tip as touch n; the third fits the table plane and sets tableOffsetMm |
| 37 | 37 | * 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 | ||
| 96 | 96 | import { MAX_ZERO_OFFSET_DEG, zeroOffsetDeg } from "./joint-zero.ts"; |
| 97 | 97 | import { JOINTS, registerName, STS3215, ticksToDegrees } from "./sts3215.ts"; |
| 98 | 98 | import { jointSteps, parseTrajectory, serializeTrajectory, type Trajectory } from "./trajectory.ts"; |
| 99 | import { DEFAULT_SERVO_GAINS, parseServoGains, type ServoGains } from "./servo-gains.ts"; | |
| 99 | 100 | import { accelRegister, bound, DEFAULT_SAFETY, Governor, jointBounds, jointMotion, motionLimits, SAFETY_KEYS, SafetyError, TICKS_PER_DEGREE, validateSafety, type SafetySettings } from "./safety.ts"; |
| 100 | 101 | |
| 101 | 102 | export interface ServerOptions { |
| @@ -147,9 +148,11 @@ export interface Settings extends SafetySettings { | ||
| 147 | 148 | tableOffsetMm: number; |
| 148 | 149 | /** Per joint, degrees added to the model's angle, to trim the model onto the real arm. */ |
| 149 | 150 | 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; | |
| 150 | 153 | } |
| 151 | 154 | |
| 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 }; | |
| 153 | 156 | |
| 154 | 157 | /** An emergency stop in force: who hit it and when. It holds until an admin clears it, across restarts. */ |
| 155 | 158 | export interface EStop { |
| @@ -598,6 +601,11 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 598 | 601 | throw err; |
| 599 | 602 | } |
| 600 | 603 | 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 | } | |
| 601 | 609 | if (s.zone !== undefined) { |
| 602 | 610 | if (s.zone !== "enforce" && s.zone !== "warn" && s.zone !== "off") throw new HttpError(400, 'zone must be "enforce", "warn" or "off"'); |
| 603 | 611 | out.zone = s.zone; |
| @@ -906,7 +914,13 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 906 | 914 | const patch = body[key]; |
| 907 | 915 | return patch && typeof patch === "object" && !Array.isArray(patch) ? { ...settings[key], ...patch } : (patch ?? settings[key]); |
| 908 | 916 | }; |
| 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>); | |
| 910 | 924 | writeAtomic(settingsFile, JSON.stringify(settings, null, 1)); |
| 911 | 925 | return json(res, 200, { ...settings, model: model !== null }); |
| 912 | 926 | } |
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 | } |
new above
offline
Add the companion test: settings.json lacking servoGains loads as DEFAULT_SERVO_GAINS, and a stored servoGains survives a restart.