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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Dosing: pH/EC probes via ADS1115 with guided calibration and monthly reminder #609

Merged opened this

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

smith/arm:smith/606-dosing-ph-ec-probes-via-ads1115-with-gui → main 4 commits fork 3 threads · 3 unresolved

  • Merged. smith/606-dosing-ph-ec-probes-via-ads1115-with-gui was merged into main as f8a7543.

Review

Sign in to review this change.
17 files changed +756 −19
Software/so101/README.md +6
@@ -145,6 +145,12 @@ The grow box's care app: a plant profile, reminders worked out from it and the l
145145 - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line.
146146 - **Three doors.** The page; JSON: `GET /care/api` (anyone), `POST /care/log`, `PUT` or `DELETE /care/log/<id>` and `POST /care/reminder` (owner, same-origin only, 8 KB at most); and in the terminal `/care` (stage, health, what is due, the last entries with their ids) for anyone, with `/care log ph 6.1`, `/care did topoff`, `/care did harvest 40`, `/care note <text>`, `/care problem algae [detail]`, `/care done <reminder>`, `/care snooze <reminder> [hours]` and `/care delete <id>` for the owner. All three act on the same log, so a write in one shows in the others at once.
147147
148 ### pH and EC probes (on /care)
149
150 An ADS1115 breakout on the Pi's I²C bus (address 0x48; `sudo apt install i2c-tools`, I²C enabled) reads the pH probe board on input A0 and the EC probe board on A1. The arm server serves `GET /probes` and `POST /probes/calibrate`; the care page shows the readings and walks the owner through the calibration: pH 7.0 buffer, then pH 4.0, then the 1413 µS/cm solution, each captured only once the probe has settled. A probe gives no value until it is calibrated. Calibration lives in `probes.json` under the data dir. Values are compensated for the water temperature (the latest `waterTemp` in the log book from the last two hours, else 25 °C), and every `probeEveryMs` (5 min on the forge) a calibrated reading is written into the log book as a sensor reading. Finishing a calibration marks the monthly "calibrate" reminder done.
151
152 The page paints from the last answer (a few seconds old, shown with its age) and refreshes it in the background, so a Pi that is down or slow never delays `/care`. The terminal has the same two doors: `/probes` prints the readings, calibration dates and the next step (anyone), and `/probes calibrate <ph7|ph4|ec1413> [solution tempC]` captures a step (owner). Both go through the same code as the page's buttons, and a calibration made in either shows live for everyone watching `/care`.
153
148154 ## The story page: /story
149155
150156 `/story` on the hosted app is the arm explained for anyone: the hook, how it works (with a diagram of browser → forge → Pi → servos, and what latency, the queue, STOP and the safety zone mean), how to build your own (the [LeRobot SO-101 docs](https://huggingface.co/docs/lerobot/so101), this repository, `bun so101 setup`) and what the crew built. Anyone may read it; it is linked from the Chat tab's footer (hidden while you drive) and the Queue tab.
Software/so101/bench/fake-serve.ts +1 −1
@@ -80,6 +80,6 @@ const facts = new Facts({ file: join(dir, "facts.sqlite") });
8080 const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir);
8181 const grow = { stills: new GrowStills(join(dir, "grow-stills"), { capBytes: Number(process.env.GROW_STILLS_CAP_MB) * 1e6 || undefined }), log: new GrowLog(join(dir, "grow-log.json")), everyMs: parseEvery(process.env.GROW_STILL_MINUTES) };
8282 const careProfile = loadProfiles(BUILT_IN_DIR, join(dir, "care-profiles")).find((p) => p.id === (process.env.CARE_PROFILE ?? "spinach"));
83 const relay = await startRelay({ sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile } : undefined, grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") });
83 const relay = await startRelay({ probeEveryMs: 300_000, sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile } : undefined, grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") });
8484 const { mode, host: armHost } = relay.link();
8585 console.log(`arm on http://${host}:${relay.port} (${mode === "simulated" ? "simulated" : `arm ${armHost}: ${mode}`}; data in ${dir}${local ? "; local: every visitor drives as the owner" : ""})`);
Software/so101/src/ads1115.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * The ADS1115 16-bit ADC on the Pi's I²C bus, read through i2c-tools (`i2cset`, `i2cget`) the way the light's GPIO relay
3 * goes through gpioset: no native module. Single-shot, ±4.096 V (0.125 mV a step), 128 samples a second.
4 * Everything that reaches the machine is the injected `Run`, so the tests use a fake chip.
5 */
6 import { run as defaultRun, type Run } from "./light-switch.ts";
7
8 export class AdcError extends Error {}
9
10 /** Volts on one input (0 to 3, single-ended). */
11 export type Adc = (channel: number) => Promise<number>;
12
13 const LSB = 0.000125;
14 const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
15 const swap = (w: number) => ((w & 0xff) << 8) | (w >> 8);
16
17 /** The config word for one single-shot conversion on AINx: start, mux, ±4.096 V, single-shot, 128 SPS, comparator off. */
18 export const configWord = (channel: number): number => 0x8000 | ((4 + channel) << 12) | (1 << 9) | (1 << 8) | (4 << 5) | 3;
19
20 export function ads1115(opts: { bus?: number; address?: number; run?: Run; wait?: (ms: number) => Promise<void> } = {}): Adc {
21 const { bus = 1, address = 0x48, run = defaultRun, wait = pause } = opts;
22 const a = `0x${address.toString(16)}`;
23 return async (channel) => {
24 if (!Number.isInteger(channel) || channel < 0 || channel > 3) throw new AdcError("the ADS1115 has inputs 0 to 3");
25 // SMBus sends a word's low byte first and the chip wants the high byte first, so both ways swap.
26 const wrote = await run("i2cset", ["-y", String(bus), a, "0x01", `0x${swap(configWord(channel)).toString(16).padStart(4, "0")}`, "w"]);
27 if (wrote.code !== 0) throw new AdcError(`no ADS1115 at ${a} on i2c-${bus} (${wrote.out.trim().slice(0, 120) || "i2cset failed"})`);
28 await wait(12);
29 const read = await run("i2cget", ["-y", String(bus), a, "0x00", "w"]);
30 const word = /^0x([0-9a-f]{1,4})\s*$/i.exec(read.out.trim());
31 if (read.code !== 0 || !word) throw new AdcError("the ADS1115 did not answer a read");
32 const raw = swap(parseInt(word[1]!, 16));
33 return (raw > 0x7fff ? raw - 0x10000 : raw) * LSB;
34 };
35 }
Software/so101/src/care-log.ts +10 −1
@@ -142,6 +142,11 @@ export class CareLog {
142142 return () => this.listeners.delete(fn);
143143 }
144144
145 /** Tell the listeners something changed that is not in the book (a probe was calibrated). */
146 notify() {
147 for (const fn of this.listeners) fn();
148 }
149
145150 private save() {
146151 for (const fn of this.listeners) fn();
147152 if (!this.file) return;
@@ -165,8 +170,12 @@ export class CareLog {
165170 }
166171
167172 add(input: unknown): Entry {
173 return this.push(parseEntry(input, this.clock()));
174 }
175
176 private push(body: EntryBody): Entry {
168177 if (this.data.entries.length >= MAX_ENTRIES) throw new LogError("the log book is full");
169 const entry = { ...parseEntry(input, this.clock()), id: this.data.nextId++ } as Entry;
178 const entry = { ...body, id: this.data.nextId++ } as Entry;
170179 this.data.entries.push(entry);
171180 this.data.entries.sort((a, b) => a.at - b.at || a.id - b.id);
172181 this.save();
Software/so101/src/care-page.ts +5 −2
@@ -21,7 +21,7 @@ const STYLE = `
2121 @media (prefers-color-scheme: light) { :root { --bg: #fbfaf7; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #bc4c00; --on-accent: #fff; --link: #0969da; --ok: #1a7f37; --bad: #cf222e; --band: #1a7f3733; } }
2222 body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; }
2323 main { max-width: 60rem; margin: 0 auto; padding: 1rem; }
24 h1 { margin: .5rem 0; } h2 { margin: 1.2rem 0 .5rem; font-size: 1.1rem; }
24 h1 { margin: .5rem 0; } h2.first { margin-top: 0; } h2 { margin: 1.2rem 0 .5rem; font-size: 1.1rem; }
2525 .top { max-width: 60rem; margin: 0 auto; padding: .5rem 1rem 0; display: flex; justify-content: space-between; gap: 1rem; font-size: .9rem; color: var(--dim); }
2626 .health { border-left: 6px solid var(--ok); background: var(--panel); padding: .6rem .9rem; border-radius: 6px; }
2727 .health[data-ok="false"] { border-color: var(--bad); }
@@ -91,9 +91,11 @@ export interface CareView {
9191 signedIn: SignedIn | null;
9292 /** A refused form's message, shown at the top. */
9393 error?: string | null;
94 /** The probe panel, already rendered (src/care-probes.ts); empty without probes. */
95 probes?: string;
9496 }
9597
96 export function renderCare({ profile, log, now, owner, signedIn, error }: CareView): string {
98 export function renderCare({ profile, log, now, owner, signedIn, error, probes = "" }: CareView): string {
9799 const snap = snapshot(log);
98100 const spans = stageHistory(profile, snap.entries, snap.startedAt);
99101 const cur = currentStage(spans);
@@ -157,6 +159,7 @@ ${error ? `<p class="alert" role="alert">${esc(error)}</p>` : ""}
157159 <section class="health" data-ok="${h.ok}" aria-label="Health">${h.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</section>
158160 ${prompt ? `<p class="panel" data-prompt="transplant">${esc(prompt)}</p>` : ""}${bolt ? `<p class="panel" data-prompt="bolting">${esc(bolt)}</p>` : ""}
159161 ${quick}
162 ${probes}
160163 <h2>Reminders</h2><div class="panel"><ul class="list">${dueRows}</ul></div>
161164 <h2>Metrics</h2>
162165 <p class="dim">Typical ranges, not guaranteed. ${esc(profile.note)} Days since the last water change: ${sum.daysSinceChange ?? "none logged"}. Harvest so far: ${sum.harvestGrams} g. Light target ${profile.light.hours} h, warn above ${profile.light.warnAbove} h (bolting).</p>
Software/so101/src/care-probes.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The pH/EC probes as the care app sees them: what the Pi's GET /probes answered (read field by field), the sampler that
3 * writes them into the log book as sensor readings, and the guided calibration panel. The probes themselves, the
4 * calibration and the ADC are on the Pi (src/probes.ts); nothing here touches hardware.
5 */
6 import { LogError, type CareLog } from "./care-log.ts";
7 import { esc } from "./story-markdown.ts";
8 import type { ProbeView, Step } from "./probes.ts";
9
10 export type { ProbeView } from "./probes.ts";
11
12 export interface ProbeHooks {
13 /** The probes' answer, compensated for this water temperature; null when the Pi does not answer. */
14 read: (tempC?: number) => Promise<ProbeView | null>;
15 /** One calibration step; the error says what to do. */
16 calibrate: (step: string, tempC: number) => Promise<{ view: ProbeView } | { error: string }>;
17 }
18
19 const STEPS: readonly string[] = ["ph7", "ph4", "ec1413"];
20 const obj = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
21 const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
22
23 /** Any JSON → a view, or null when it is not one. */
24 export function readProbeView(body: unknown): ProbeView | null {
25 const b = obj(body);
26 const cal = obj(b.calibratedAt);
27 if (typeof b.present !== "boolean" || typeof b.tempC !== "number") return null;
28 return {
29 present: b.present,
30 error: typeof b.error === "string" ? b.error.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 200) : null,
31 ph: num(b.ph),
32 ec: num(b.ec),
33 tempC: b.tempC,
34 tempSource: b.tempSource === "given" ? "given" : "default",
35 calibratedAt: { ph: num(cal.ph), ec: num(cal.ec) },
36 next: STEPS.includes(b.next as string) ? (b.next as Step) : null,
37 pending: b.pending === true,
38 };
39 }
40
41 const HOUR = 3_600_000;
42
43 /** The newest water temperature in the book from the last two hours (the DS18B20, or a hand), for compensation. */
44 export function waterTemp(log: CareLog, now: number): number | undefined {
45 const last = log.entries().filter((e) => e.type === "measurement" && e.kind === "waterTemp" && e.at <= now && e.at > now - 2 * HOUR).pop();
46 return last?.type === "measurement" ? last.value : undefined;
47 }
48
49 /** What the page and the terminal see of the probes: the Pi's last answer and its age, never a wait on the Pi. */
50 export class ProbeCache {
51 readonly hooks: ProbeHooks;
52 private view: ProbeView | null = null;
53 private at = 0;
54 private inflight: Promise<ProbeView | null> | null = null;
55 private seen = "";
56 private readonly temp: () => number | undefined;
57 private readonly changed: () => void;
58 private readonly ttlMs: number;
59 private readonly clock: () => number;
60 constructor(hooks: ProbeHooks, temp: () => number | undefined, changed: () => void = () => {}, ttlMs = 5000, clock: () => number = Date.now) {
61 this.hooks = hooks;
62 this.temp = temp;
63 this.changed = changed;
64 this.ttlMs = ttlMs;
65 this.clock = clock;
66 }
67
68 /** The last view and how old it is; a stale or missing one starts a refresh in the background and is returned anyway. */
69 peek(): { view: ProbeView; ageMs: number } | null {
70 if (!this.view || this.clock() - this.at > this.ttlMs) void this.refresh();
71 return this.view ? { view: this.view, ageMs: this.clock() - this.at } : null;
72 }
73
74 /** Ask the Pi once (calls share the one question); keeps the answer, and tells the listeners when the news changed. */
75 refresh(): Promise<ProbeView | null> {
76 this.inflight ??= this.hooks
77 .read(this.temp())
78 .then((v) => (v ? this.set(v) : this.view))
79 .catch(() => this.view)
80 .finally(() => (this.inflight = null));
81 return this.inflight;
82 }
83
84 set(view: ProbeView): ProbeView {
85 this.view = view;
86 this.at = this.clock();
87 const key = JSON.stringify([view.present, view.next, view.pending, view.calibratedAt, view.ph === null, view.ec === null]);
outdated

Inline style="margin-top:0" in markup. Use a class named in docs/design/areas/ and run design-lint, in case the CSP blocks inline styles.

88 if (key !== this.seen) {
89 this.seen = key;
90 this.changed();
91 }
92 return view;
93 }
94 }
95
96 /**
97 * One calibration step, for the page and the terminal alike: the Pi captures it, and when both probes are calibrated the
98 * monthly "calibrate" reminder is marked done. Every finished step after that adds a done entry again (a recalibration
99 * is a calibration); a step that leaves the guide unfinished adds none. Viewers on the care event stream hear of it.
100 */
101 export async function calibrateProbe(log: CareLog, cache: ProbeCache, step: string, tempC: number): Promise<{ view: ProbeView } | { error: string }> {
102 const done = await cache.hooks.calibrate(step, tempC);
103 if ("error" in done) return done;
104 cache.set(done.view);
105 if (done.view.next === null) log.add({ type: "action", what: "done", ref: "calibrate" });
106 log.notify();
107 return done;
108 }
109
110 /** Read the probes once and write what is calibrated into the log book as sensor readings; returns what it wrote. */
111 export async function sampleProbes(log: CareLog, cache: ProbeCache, now = Date.now()): Promise<string[]> {
112 const view = await cache.refresh();
113 const wrote: string[] = [];
114 if (!view?.present) return wrote;
115 for (const kind of ["ph", "ec"] as const) {
116 const value = view[kind];
117 if (value === null) continue;
118 try {
119 if (log.addReading({ kind, value, at: now, source: "sensor" })) wrote.push(kind); // the book keeps one sensor reading an hour per kind
120 } catch (err) {
121 if (!(err instanceof LogError)) throw err; // outside physical bounds: a broken probe is not a measurement
122 }
123 }
124 return wrote;
125 }
126
127 const GUIDE: Record<Step, { title: string; how: string }> = {
128 ph7: { title: "pH 7.0 buffer", how: "Rinse the pH probe in clean water, stand it in the pH 7.0 buffer and wait a minute." },
129 ph4: { title: "pH 4.0 buffer", how: "Rinse the pH probe, stand it in the pH 4.0 buffer and wait a minute." },
130 ec1413: { title: "1413 µS/cm solution", how: "Rinse the EC probe, stand it in the 1413 µS/cm solution and wait a minute." },
131 };
132
133 const day = (at: number) => new Date(at).toISOString().slice(0, 10);
134
135 /** The lines the terminal prints for the same view: readings, calibration dates, the next step. */
136 export function probeLines(body: unknown): string[] {
137 const seen = obj(body);
138 const view = readProbeView(seen.view);
139 if (!view) return ["probes: the Pi did not answer"];
140 if (!view.present) return [`No probe board answering${view.error ? `: ${view.error}` : ""}`];
141 const age = typeof seen.ageMs === "number" ? `${Math.round(seen.ageMs / 1000)} s ago` : "now";
142 const cal = (at: number | null) => (at === null ? "never calibrated" : `calibrated ${day(at)}`);
143 return [
144 `pH ${view.ph === null ? "not calibrated" : view.ph} · ${cal(view.calibratedAt.ph)}`,
145 `EC ${view.ec === null ? "not calibrated" : `${view.ec} mS/cm`} · ${cal(view.calibratedAt.ec)}`,
146 `Read ${age}, compensated for ${view.tempC} °C`,
147 view.next ? `Next: /probes calibrate ${view.next} [tempC] (${GUIDE[view.next].title}: ${GUIDE[view.next].how})` : "Both probes are calibrated",
148 ];
149 }
150
151 /** The panel: readings, when each probe was calibrated, and the next step of the guide (owner only gets the button). */
152 export function renderProbes(seen: { view: ProbeView; ageMs: number } | null, owner: boolean): string {
153 if (!seen) return "";
154 const { view, ageMs } = seen;
155 const age = ageMs < 2000 ? "just now" : `${Math.round(ageMs / 1000)} s ago`;
156 if (!view.present) return `<section class="panel" data-probes="absent"><h2 class="first">pH and EC probes</h2><p class="dim">No probe board answering${view.error ? `: ${esc(view.error)}` : ""}. Readings can still be logged by hand.</p></section>`;
157 const read = (v: number | null, unit: string) => (v === null ? "not calibrated" : `${v}${unit}`);
158 const cal = (at: number | null) => (at === null ? "never calibrated" : `calibrated ${day(at)}`);
159 const step = view.next ? GUIDE[view.next] : null;
160 const guide = step
161 ? `<p><strong>${view.pending ? "Next: " : "Calibrate: "}${esc(step.title)}</strong><br><span class="dim">${esc(step.how)}</span></p>${
162 owner
163 ? `<form method="post" action="/care/probes/calibrate"><input type="hidden" name="step" value="${esc(view.next!)}"><label>Solution °C <input type="number" name="tempC" step="any" value="${view.tempC}" required></label><button>Capture ${esc(step.title)}</button></form>`
164 : ""
165 }`
166 : `<p class="dim">Both probes are calibrated.</p>${
167 owner ? `<form method="post" action="/care/probes/calibrate"><input type="hidden" name="step" value="ph7"><input type="hidden" name="tempC" value="${view.tempC}"><button class="quiet">Recalibrate pH</button></form><form method="post" action="/care/probes/calibrate"><input type="hidden" name="step" value="ec1413"><input type="hidden" name="tempC" value="${view.tempC}"><button class="quiet">Recalibrate EC</button></form>` : ""
168 }`;
169 return `<section class="panel" data-probes="present" aria-labelledby="probes"><h2 id="probes" class="first">pH and EC probes</h2>
170 <p>pH ${esc(read(view.ph, ""))} <span class="dim">· ${esc(cal(view.calibratedAt.ph))}</span><br>EC ${esc(read(view.ec, " mS/cm"))} <span class="dim">· ${esc(cal(view.calibratedAt.ec))}</span><br><span class="dim">Read ${age}, compensated for ${view.tempC} °C${view.tempSource === "default" ? " (no recent water temperature, so 25 °C)" : ""}.</span></p>
171 ${guide}</section>`;
172 }
Software/so101/src/care-profiles/spinach.json +2 −1
@@ -27,7 +27,7 @@
2727 { "id": "pump", "label": "Pump ran? Top emitter clear, every level wet", "everyHours": 24, "needs": "pump", "resets": ["action:pumpcheck"] },
2828 { "id": "measure", "label": "Measure pH, EC and water temperature", "everyHours": 60, "tower": 36, "resets": ["measurement:ph", "measurement:ec", "measurement:waterTemp"] },
2929 { "id": "topoff", "label": "Top off with plain water", "everyHours": 60, "tower": 24, "resets": ["action:topoff", "action:change"] },
30 { "id": "calibrate", "label": "Calibrate the pH probe", "everyHours": 168 },
30 { "id": "calibrate", "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)", "everyHours": 720 },
3131 { "id": "air-stone", "label": "Clean the air stone", "everyHours": 168, "resets": ["action:airstone"] },
3232 { "id": "roots", "label": "Check the roots (brown or slimy = Pythium); thin or harvest", "everyHours": 168 },
3333 { "id": "tower", "label": "Tower: top emitter not clogged, every level wet, no algae on lit parts", "everyHours": 168, "needs": "tower" },
@@ -44,6 +44,7 @@
4444 { "id": "pump", "label": "Pump ran?", "everyHours": 24, "needs": "pump", "resets": ["action:pumpcheck"] },
4545 { "id": "measure", "label": "Measure pH, EC and water temperature", "everyHours": 60, "tower": 36, "resets": ["measurement:ph", "measurement:ec", "measurement:waterTemp"] },
4646 { "id": "bolting", "label": "Stalk forming? Look at the side camera (watch when air has been above 24 C)", "everyHours": 48 },
47 { "id": "calibrate", "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)", "everyHours": 720 },
4748 { "id": "harvest", "label": "Cut and come again: harvest outer leaves", "everyHours": 168, "resets": ["action:harvest"] },
4849 { "id": "change", "label": "Full reservoir change and wipe down", "everyHours": 336, "resets": ["action:change"] }
4950 ]
Software/so101/src/care-routes.ts +18 −4
@@ -4,6 +4,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
44 import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory, totals, transplantPrompt } from "./care-rules.ts";
55 import { LogError, type CareLog } from "./care-log.ts";
66 import { renderCare } from "./care-page.ts";
7 import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts";
78 import type { Profile } from "./care-profile.ts";
89 import type { SignedIn } from "./signed-in.ts";
910
@@ -14,9 +15,11 @@ export interface CareContext {
1415 who: (req: IncomingMessage) => { owner: boolean; signedIn: SignedIn | null; known: boolean };
1516 readBody: (req: IncomingMessage, limit?: number) => Promise<Buffer>;
1617 now?: () => number;
18 /** The pH/EC probes on the Pi; without them the page has no probe panel. */
19 probes?: ProbeCache;
1720 }
1821
19 const NUMBERS = new Set(["value", "amount", "litres", "sites", "hours"]);
22 const NUMBERS = new Set(["value", "amount", "litres", "sites", "hours", "tempC"]);
2023
2124 /** A JSON body, or a form's fields with the numbers made numbers. */
2225 function fields(req: IncomingMessage, body: string): Record<string, unknown> {
@@ -42,7 +45,8 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
4245 const json = (status: number, body: unknown) => send(status, "application/json", JSON.stringify(body));
4346
4447 if (req.method === "GET" && pathname === "/care") {
45 send(200, "text/html; charset=utf-8", renderCare({ profile: ctx.profile, log: ctx.log, now, owner: me.owner, signedIn: me.signedIn, error: new URL(req.url ?? "/", "http://x").searchParams.get("error")?.slice(0, 200) }));
48 const probes = renderProbes(ctx.probes?.peek() ?? null, me.owner);
outdated

This awaits the Pi before the first paint (up to 1.5 s when the Pi is down). Serve a cached view instead.

49 send(200, "text/html; charset=utf-8", renderCare({ probes, profile: ctx.profile, log: ctx.log, now, owner: me.owner, signedIn: me.signedIn, error: new URL(req.url ?? "/", "http://x").searchParams.get("error")?.slice(0, 200) }));
4650 return true;
4751 }
4852 if (req.method === "GET" && pathname === "/care/events") {
@@ -53,6 +57,11 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
5357 res.on("close", () => { stop(); clearInterval(beat); });
5458 return true;
5559 }
60 if (req.method === "GET" && pathname === "/care/probes" && ctx.probes) {
61 if (!ctx.probes.peek()) await ctx.probes.refresh(); // the one place that waits: a terminal asked, and has nothing to show yet
62 json(200, ctx.probes.peek() ?? { view: null });
63 return true;
64 }
5665 if (req.method === "GET" && pathname === "/care/api") {
5766 const snap = snapshot(ctx.log);
5867 const spans = stageHistory(ctx.profile, snap.entries, snap.startedAt);
@@ -71,7 +80,8 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
7180
7281 const entry = /^\/care\/log(?:\/(\d+)(\/delete)?)?$/.exec(pathname);
7382 const reminder = pathname === "/care/reminder";
74 if (!entry && !reminder) {
83 const calibrate = pathname === "/care/probes/calibrate" && ctx.probes;
84 if (!entry && !reminder && !calibrate) {
7585 json(404, { error: "no such route" });
7686 return true;
7787 }
@@ -92,7 +102,11 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
92102 const input = fields(req, raw.toString("utf8"));
93103 let result: unknown = { ok: true };
outdated

The done entry is added in the route only. A terminal door needs the same step, so move it into one shared function. Also, finishing a single-probe recalibration adds a done entry each time, which is fine, but say so in a test.

94104 const id = entry?.[1] ? Number(entry[1]) : null;
95 if (reminder) {
105 if (calibrate) {
106 const done = await calibrateProbe(ctx.log, ctx.probes!, String(input.step ?? ""), typeof input.tempC === "number" ? input.tempC : 25);
107 if ("error" in done) throw new LogError(done.error);
108 result = done.view;
109 } else if (reminder) {
96110 const rid = String(input.id ?? "");
97111 if (input.action === "done") result = ctx.log.add({ type: "action", what: "done", ref: rid });
98112 else if (input.action === "snooze") ctx.log.snooze(rid, Number(input.hours ?? 24));
Software/so101/src/care-terminal.test.ts +38 −3
@@ -23,18 +23,17 @@ let dir = "";
2323 let relay: RunningRelay | undefined;
2424 let lobby: Lobby | undefined;
2525 afterEach(async () => {
26 await relay?.close();
2726 lobby?.close();
2827 if (dir) rmSync(dir, { recursive: true, force: true });
2928 dir = "";
3029 relay = lobby = undefined;
3130 });
3231
33 async function start() {
32 async function start(simulation = "http://127.0.0.1:1") {
3433 dir = mkdtempSync(join(tmpdir(), "so101-care-"));
3534 lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), countsFile: join(dir, "visitors.json") });
3635 const log = new CareLog();
37 relay = await startRelay({ port: 0, simulation: "http://127.0.0.1:1", lobby, care: { log, profile } });
36 relay = await startRelay({ port: 0, simulation, lobby, care: { log, profile } });
3837 return { log, port: relay.port, base: `http://127.0.0.1:${relay.port}` };
3938 }
4039
@@ -95,3 +94,39 @@ test("through the relay: a cross-site write is refused, a guest is 401, a plain
9594 expect(log.entries()).toHaveLength(1);
9695 expect(careLines({ stage: "seedling", health: { ok: true, lines: [] }, reminders: [], entries: [] })[0]).toContain("healthy");
9796 });
97
98 test("/probes through the relay: anyone reads, the owner calibrates, a web viewer hears it live, and a Pi that is down never delays /care", async () => {
99 const view = (next: string | null) => ({ present: true, error: null, ph: null, ec: null, tempC: 21, tempSource: "default", calibratedAt: { ph: null, ec: null }, next, pending: next === "ph4" });
100 const pi = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => (new URL(req.url).pathname === "/probes/calibrate" ? Response.json(view("ph4")) : Response.json(view("ph7"))) });
101 try {
102 const { port, base, log } = await start(`http://127.0.0.1:${pi.port}`);
103 const run = async (env: Record<string, string>, line: string) => {
104 const printed: string[] = [];
105 new Door("ws://127.0.0.1:1/live", {}, (l) => printed.push(l), null, null, null, null, null, null, null, null, null, null, null, null, careReader(env)).input(line);
106 for (let i = 0; i < 100 && !printed.length; i++) await Bun.sleep(20);
107 return printed;
108 };
109 const events = await fetch(`${base}/care/events`);
110 const reader = events.body!.getReader();
111 await reader.read(); // the retry line
112 const guest = await run(terminal(port, null), "/probes");
113 expect(guest.join("\n")).toContain("Next: /probes calibrate ph7");
114 expect((await run(terminal(port, "eve"), "/probes calibrate ph7"))[0]).toMatch(/^error: only the owner/);
115 const out = await run(terminal(port, "trevato", "admin"), "/probes calibrate ph7 21");
116 expect(out[0]).toBe("probe captured");
117 expect(out.join("\n")).toContain("Next: /probes calibrate ph4");
118 let heard = "";
119 for (let i = 0; i < 20 && !heard.includes("event: care"); i++) heard += new TextDecoder().decode((await reader.read()).value);
120 expect(heard).toContain("event: care");
121 expect(log.entries()).toHaveLength(0); // unfinished: no done entry yet
122 await reader.cancel();
123 } finally {
124 await pi.stop(true);
125 }
126 const { base } = await start(); // nothing listens on the simulation port: the Pi is down
127 await (await fetch(`${base}/care`)).text(); // starts the background read
128 const began = performance.now();
129 const page = await fetch(`${base}/care`);
130 expect(page.status).toBe(200);
131 expect(performance.now() - began).toBeLessThan(500);
132 });
Software/so101/src/care-terminal.ts +10
@@ -14,6 +14,16 @@ export interface CareAsk {
1414
1515 export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours> <value> · /care did <action> [amount] · /care note <text> · /care problem <bolting|tip burn|root rot|algae|yellowing|leggy> [detail] · /care done <reminder> · /care snooze <reminder> [hours] · /care delete <entry id>";
1616
17 export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)";
18
19 /** `/probes …` → the ask the page's probe panel makes (GET /care/probes, POST /care/probes/calibrate), or the usage. */
20 export function probesCommand(words: string[]): CareAsk | { print: string } {
21 if (!words.length) return { method: "GET", path: "/care/probes" };
22 const [sub, step = "", temp, ...more] = words;
23 if (sub?.toLowerCase() !== "calibrate" || !["ph7", "ph4", "ec1413"].includes(step) || more.length || (temp !== undefined && !/^\d{1,2}(\.\d+)?$/.test(temp))) return { print: PROBES_USAGE };
24 return { method: "POST", path: "/care/probes/calibrate", body: { step, ...(temp ? { tempC: Number(temp) } : {}) } };
25 }
26
1727 const QUICK = ACTIONS.filter((a) => a !== "transplant" && a !== "done");
1828
1929 /** `/care …` (the words after the verb, and the line after the sub-verb) → what to ask the server, or the usage. */
Software/so101/src/care.test.ts +1 −1
@@ -120,7 +120,7 @@ test("topping off resets the top-off reminder; done, snooze and an edited cadenc
120120 log.add({ type: "action", what: "topoff" });
121121 expect(due(log, clock.now, "topoff")!.dueAt).toBe(T0 + 49 * H);
122122 log.add({ type: "action", what: "done", ref: "calibrate" });
123 expect(due(log, clock.now, "calibrate")!.dueAt).toBe(clock.now + 168 * H);
123 expect(due(log, clock.now, "calibrate")!.dueAt).toBe(clock.now + 720 * H);
124124 log.snooze("roots", 48);
125125 expect(due(log, clock.now + 10 * H, "roots")!.snoozedUntil).toBe(clock.now + 48 * H);
126126 log.setEvery("roots", 24);
Software/so101/src/probe-math.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * pH and EC from probe voltages. Pure functions.
3 * pH two buffers (7.0, then 4.0) give volts per pH; the slope is scaled by absolute temperature (Nernst) from the
4 * temperature the buffers were at to the water's now.
5 * EC one solution (1413 µS/cm) gives a cell constant; the reading is brought to 25 °C at 2 % per °C.
6 */
7
8 export interface PhCal {
9 /** Volts at pH 7.0 and the slope in volts per pH unit, both taken at `tempC`. */
10 v7: number;
11 slope: number;
12 tempC: number;
13 at: number;
14 }
15 export interface EcCal {
16 /** mS/cm per volt at 25 °C. */
17 k: number;
18 at: number;
19 }
20
21 export class CalError extends Error {}
22
23 export const BUFFER_LOW = 4.0;
24 export const BUFFER_MID = 7.0;
25 export const EC_SOLUTION = 1.413;
26 export const DEFAULT_TEMP = 25;
27 const EC_ALPHA = 0.02;
28 const kelvin = (c: number) => 273.15 + c;
29
30 export function phCalibration(v7: number, v4: number, tempC: number, at: number): PhCal {
31 const slope = (v4 - v7) / (BUFFER_LOW - BUFFER_MID);
32 if (Math.abs(slope) < 0.01) throw new CalError("the pH 4.0 and 7.0 readings are almost the same: is the probe in the right buffer, and the board powered?");
33 return { v7, slope, tempC, at };
34 }
35 export function ph(volts: number, cal: PhCal, tempC: number): number {
36 const slope = cal.slope * (kelvin(tempC) / kelvin(cal.tempC));
37 return BUFFER_MID + (volts - cal.v7) / slope;
38 }
39
40 export function ecCalibration(volts: number, tempC: number, at: number): EcCal {
41 if (!(volts > 0.01)) throw new CalError("the EC probe reads nothing in the 1413 µS/cm solution: is it in, and the board powered?");
42 return { k: (EC_SOLUTION * (1 + EC_ALPHA * (tempC - 25))) / volts, at };
43 }
44 export const ec = (volts: number, cal: EcCal, tempC: number): number => (cal.k * volts) / (1 + EC_ALPHA * (tempC - 25));
Software/so101/src/probes.test.ts added +230
@@ -0,0 +1,230 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { createServer } from "node:http";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { ads1115, AdcError, configWord } from "./ads1115.ts";
7 import { CareLog } from "./care-log.ts";
8 import { calibrateProbe, ProbeCache, probeLines, readProbeView, renderProbes, sampleProbes, type ProbeHooks } from "./care-probes.ts";
9 import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts";
10 import { handleCare } from "./care-routes.ts";
11 import { PROBES_USAGE, probesCommand } from "./care-terminal.ts";
12 import { emptyRoom, typed } from "./terminal.ts";
13 import { ph, phCalibration } from "./probe-math.ts";
14 import { CalError } from "./probe-math.ts";
15 import { Probes } from "./probes.ts";
16
17 /** A fake board: each input holds a voltage the test sets. */
18 const board = (volts: [number, number]) => async (ch: number) => volts[ch]!;
19
20 test("the ADS1115 driver speaks i2c-tools and converts the word to volts", async () => {
21 const calls: string[] = [];
22 const adc = ads1115({
23 wait: async () => {},
24 run: async (cmd, args) => {
25 calls.push(`${cmd} ${args.join(" ")}`);
26 return cmd === "i2cset" ? { code: 0, out: "" } : { code: 0, out: "0x3412\n" }; // 0x1234 on the wire, swapped
27 },
28 });
29 expect(configWord(0)).toBe(0xc383);
30 expect(await adc(1)).toBeCloseTo(0x1234 * 0.000125, 6);
31 expect(calls[0]).toBe("i2cset -y 1 0x48 0x01 0x83d3 w"); // 0xd383, byte-swapped for SMBus
32 expect(calls[1]).toBe("i2cget -y 1 0x48 0x00 w");
33 await expect(ads1115({ run: async () => ({ code: 1, out: "Error: no device" }) })(0)).rejects.toThrow(AdcError);
34 await expect(ads1115()(7)).rejects.toThrow(/inputs 0 to 3/);
35 const negative = ads1115({ wait: async () => {}, run: async (cmd) => ({ code: 0, out: cmd === "i2cset" ? "" : "0x00ff" }) });
36 expect(await negative(0)).toBeLessThan(0);
37 });
38
39 test("pH is scaled to the water's temperature", () => {
40 const cal = phCalibration(2.5, 3.0, 25, 1); // volts rise 0.5 V over 3 pH units going from 7 to 4: -0.1667 V/pH... slope = 0.5/-3
41 expect(ph(2.5, cal, 25)).toBeCloseTo(7, 6);
42 expect(ph(3.0, cal, 25)).toBeCloseTo(4, 6);
43 expect(ph(2.75, cal, 25)).toBeCloseTo(5.5, 6);
44 // warmer water: the probe moves more per pH, so the same voltage off the 7.0 point is fewer pH units away
45 expect(Math.abs(ph(3.0, cal, 45) - 7)).toBeLessThan(Math.abs(ph(3.0, cal, 25) - 7));
46 expect(() => phCalibration(2.5, 2.5, 25, 1)).toThrow(CalError);
47 });
48
49 test("the guided calibration: no value until calibrated, then pH and EC, kept across a restart", async () => {
50 const dir = mkdtempSync(join(tmpdir(), "probes-"));
51 try {
52 const volts: [number, number] = [2.5, 0.5];
53 const file = join(dir, "probes.json");
54 const probes = new Probes(board(volts), file, () => 1000);
55 let v = await probes.view(25);
56 expect(v).toMatchObject({ present: true, ph: null, ec: null, next: "ph7", tempSource: "given" });
57 await expect(probes.calibrate("ph4")).rejects.toThrow(/pH 7.0 first/);
58 v = await probes.calibrate("ph7");
59 expect(v).toMatchObject({ next: "ph4", pending: true, ph: null });
60 volts[0] = 3.0;
61 v = await probes.calibrate("ph4");
62 expect(v.next).toBe("ec1413");
63 expect(v.calibratedAt.ph).toBe(1000);
64 expect(v.ph).toBeCloseTo(4, 2);
65 v = await probes.calibrate("ec1413");
66 expect(v.ec).toBeCloseTo(1.413, 3);
67 expect(v.next).toBeNull();
68 volts[1] = 1.0;
69 expect((await new Probes(board(volts), file).view(25)).ec).toBeCloseTo(2.826, 3);
70 } finally {
71 rmSync(dir, { recursive: true, force: true });
72 }
73 });
74
75 test("a probe still settling, a dead board and a bad step are refused with what to do", async () => {
76 let n = 0;
77 const probes = new Probes(async () => 2.5 + (n++ % 2) * 0.05);
78 await expect(probes.calibrate("ph7")).rejects.toThrow(/settling/);
79 await expect(probes.calibrate("ph9")).rejects.toThrow(/step is one of/);
80 await expect(probes.calibrate("ph7", 90)).rejects.toThrow(/temperature/);
81 const dead = new Probes(async () => { throw new AdcError("no ADS1115 at 0x48"); });
82 expect(await dead.view()).toMatchObject({ present: false, ph: null, error: expect.stringContaining("no ADS1115") });
83 await expect(dead.calibrate("ph7")).rejects.toThrow(/no ADS1115/);
84 await expect(new Probes(board([0.5, 0.001])).calibrate("ec1413")).rejects.toThrow(/reads nothing/);
85 });
86
87 const T0 = Date.parse("2026-10-27T08:00:00Z");
88 const fakeHooks = (probes: Probes): ProbeHooks => ({
89 read: async (t) => readProbeView(JSON.parse(JSON.stringify(await probes.view(t)))),
90 calibrate: async (step, tempC) => {
91 try {
92 return { view: await probes.calibrate(step, tempC) };
93 } catch (err) {
94 return { error: (err as Error).message };
95 }
96 },
97 });
98
99 test("the sampler logs calibrated readings as sensor readings, compensated by the latest water temperature", async () => {
100 const volts: [number, number] = [2.5, 0.5];
101 const probes = new Probes(board(volts), undefined, () => T0);
102 let clock = T0;
103 const log = new CareLog(undefined, () => clock);
104 const hooks = new ProbeCache(fakeHooks(probes), () => undefined);
105 expect(await sampleProbes(log, hooks, T0)).toEqual([]); // uncalibrated: nothing
106 await probes.calibrate("ph7");
107 volts[0] = 3.0;
108 await probes.calibrate("ph4");
109 await probes.calibrate("ec1413");
110 log.add({ type: "measurement", kind: "waterTemp", value: 19 });
111 expect(await sampleProbes(log, hooks, T0)).toEqual(["ph", "ec"]);
112 const sensor = log.entries().filter((e) => e.type === "measurement" && e.source === "sensor");
113 expect(sensor.map((e) => e.type === "measurement" && e.kind)).toEqual(["ph", "ec"]);
114 expect(await sampleProbes(log, hooks, T0 + 60_000)).toEqual([]); // the book keeps one sensor reading an hour per kind
115 clock = T0 + 2 * 3_600_000;
116 volts[0] = 9; // far off the scale: a broken probe is not a measurement
117 expect(await sampleProbes(log, hooks, clock)).toEqual(["ec"]);
118 expect(await sampleProbes(log, new ProbeCache({ read: async () => null, calibrate: async () => ({ error: "x" }) }, () => undefined), T0)).toEqual([]);
119 });
120
121 test("the care routes walk the calibration for the owner only, and finishing it marks the monthly reminder done", async () => {
122 const volts: [number, number] = [2.5, 0.5];
123 const probes = new Probes(board(volts), undefined, () => T0);
124 const log = new CareLog(undefined, () => T0);
125 const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!;
126 let owner = true;
127 const cache = new ProbeCache(fakeHooks(probes), () => undefined, () => log.notify());
128 await cache.refresh();
129 const server = createServer((req, res) => {
130 void handleCare(req, res, new URL(req.url!, "http://x").pathname, {
131 profile, log, probes: cache, now: () => T0,
132 who: () => ({ owner, signedIn: null, known: owner }),
133 readBody: async (r) => Buffer.concat(await Array.fromAsync(r)),
134 });
135 });
136 await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
137 const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
138 const step = (s: string, tempC = 21) => fetch(`${base}/care/probes/calibrate`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ step: s, tempC }) });
139 try {
140 const page = await (await fetch(`${base}/care`)).text();
141 expect(page).toContain('data-probes="present"');
142 expect(page).toContain("pH 7.0 buffer");
143 expect(page).toContain('action="/care/probes/calibrate"');
144 owner = false;
145 expect((await step("ph7")).status).toBe(401);
146 expect(await (await fetch(`${base}/care`)).text()).not.toContain('action="/care/probes/calibrate"');
147 owner = true;
148 expect((await (await step("ph4")).json()) as unknown).toMatchObject({ error: expect.stringContaining("pH 7.0 first") });
149 expect((await step("ph7")).status).toBe(200);
150 volts[0] = 3.0;
151 expect((await step("ph4")).status).toBe(200);
152 expect(log.entries().some((e) => e.type === "action" && e.what === "done")).toBe(false);
153 const last = await step("ec1413");
154 expect(last.status).toBe(200);
155 expect(log.entries().find((e) => e.type === "action" && e.what === "done")).toMatchObject({ ref: "calibrate" });
156 // a recalibration that finishes the guide again is a calibration again: one more done entry each time
157 expect(log.entries().filter((e) => e.type === "action" && e.what === "done")).toHaveLength(1);
158 expect((await step("ec1413")).status).toBe(200);
159 expect(log.entries().filter((e) => e.type === "action" && e.what === "done")).toHaveLength(2);
160 const seen = (await (await fetch(`${base}/care/probes`)).json()) as { view: { ph: number }; ageMs: number };
161 expect(seen.view.ph).toBeCloseTo(4, 1);
162 expect(seen.ageMs).toBeGreaterThanOrEqual(0);
163 } finally {
164 server.close();
165 }
166 });
167
168 test("the panel says so when no board answers, and what the Pi sends is read field by field", () => {
169 expect(renderProbes(null, true)).toBe("");
170 const absent = readProbeView({ present: false, error: "<b>no board</b>", tempC: 25, tempSource: "default", calibratedAt: {}, next: "ph7" })!;
171 const html = renderProbes({ view: absent, ageMs: 0 }, true);
172 expect(html).toContain("No probe board answering");
173 expect(html).not.toContain("<b>");
174 expect(readProbeView({ present: "yes" })).toBeNull();
175 expect(readProbeView({ present: true, tempC: 25, next: "rm -rf", calibratedAt: { ph: "x" } })).toMatchObject({ next: null, calibratedAt: { ph: null, ec: null } });
176 });
177
178 test("the care page never waits on the Pi: a slow Pi shows the cached view with its age, and a refresh follows", async () => {
179 let t = 0;
180 let calls = 0;
181 const view = readProbeView({ present: true, error: null, ph: 6.1, ec: 1.9, tempC: 25, tempSource: "default", calibratedAt: { ph: T0, ec: T0 }, next: null, pending: false })!;
182 const slow: ProbeHooks = { read: async () => { calls++; await Bun.sleep(300); return view; }, calibrate: async () => ({ error: "x" }) };
183 let changes = 0;
184 const cache = new ProbeCache(slow, () => undefined, () => changes++, 5000, () => t);
185 const began = performance.now();
186 expect(cache.peek()).toBeNull(); // nothing yet: the page paints without the panel
187 expect(performance.now() - began).toBeLessThan(50);
188 await cache.refresh();
189 expect(changes).toBe(1);
190 t = 3000;
191 expect(cache.peek()!.ageMs).toBe(3000);
192 expect(calls).toBe(1); // inside the TTL: the Pi is not asked
193 expect(renderProbes(cache.peek(), false)).toContain("Read 3 s ago");
194 t = 9000;
195 cache.peek();
196 cache.peek();
197 expect(calls).toBe(2); // stale: one background refresh, shared
198 await cache.refresh();
199 expect(changes).toBe(1); // the same news does not wake every viewer
200 const down = new ProbeCache({ read: async () => null, calibrate: async () => ({ error: "x" }) }, () => undefined);
201 await down.refresh();
202 expect(down.peek()).toBeNull();
203 });
204
205 test("the terminal door: /probes reads, /probes calibrate asks what the page's button asks, and viewers hear of it", async () => {
206 expect(typed(emptyRoom(), "/probes")).toEqual({ care: { method: "GET", path: "/care/probes" } });
207 expect(typed(emptyRoom(), "/probes calibrate ph7 21.5")).toEqual({ care: { method: "POST", path: "/care/probes/calibrate", body: { step: "ph7", tempC: 21.5 } } });
208 expect(typed(emptyRoom(), "/probes calibrate ec1413")).toEqual({ care: { method: "POST", path: "/care/probes/calibrate", body: { step: "ec1413" } } });
209 for (const bad of ["/probes calibrate", "/probes calibrate ph9", "/probes calibrate ph7 hot", "/probes frob"]) expect(typed(emptyRoom(), bad)).toEqual({ print: PROBES_USAGE });
210 expect(probesCommand([])).toEqual({ method: "GET", path: "/care/probes" });
211
212 const volts: [number, number] = [2.5, 0.5];
213 const probes = new Probes(board(volts), undefined, () => T0);
214 const log = new CareLog(undefined, () => T0);
215 const heard: number[] = [];
216 log.onChange(() => heard.push(1));
217 const cache = new ProbeCache(fakeHooks(probes), () => undefined, () => log.notify());
218 expect(await calibrateProbe(log, cache, "ph7", 21)).toHaveProperty("view");
219 expect(heard.length).toBeGreaterThan(0); // an unfinished step still reaches the event stream
220 expect(log.entries().some((e) => e.type === "action" && e.what === "done")).toBe(false);
221 expect(await calibrateProbe(log, cache, "ph9", 21)).toHaveProperty("error");
222 volts[0] = 3.0;
223 await calibrateProbe(log, cache, "ph4", 21);
224 await calibrateProbe(log, cache, "ec1413", 21);
225 expect(log.entries().filter((e) => e.type === "action" && e.what === "done")).toHaveLength(1);
226 const lines = probeLines({ view: await probes.view(21), ageMs: 4000 });
227 expect(lines.join("\n")).toContain("Both probes are calibrated");
228 expect(lines[2]).toContain("4 s ago");
229 expect(probeLines({ view: null })).toEqual(["probes: the Pi did not answer"]);
230 });
Software/so101/src/probes.ts added +121
@@ -0,0 +1,121 @@
1 /**
2 * The pH and EC probes on the ADS1115 (input 0 and 1): live readings from a calibration kept in the data dir
3 * (probes.json), and the guided calibration that makes it. Runs on the Pi beside the ADC; the care app reads it over
4 * GET /probes and walks the owner through POST /probes/calibrate.
5 *
6 * An uncalibrated probe gives no value at all, never a guess: a reading nobody can trust must not reach a dose.
7 */
8 import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9 import { dirname } from "node:path";
10 import { AdcError, type Adc } from "./ads1115.ts";
11 import { CalError, DEFAULT_TEMP, ec, ecCalibration, ph, phCalibration, type EcCal, type PhCal } from "./probe-math.ts";
12
13 export const CHANNELS = { ph: 0, ec: 1 } as const;
14 /** The calibration steps in the order the guide gives them. */
15 export const STEPS = ["ph7", "ph4", "ec1413"] as const;
16 export type Step = (typeof STEPS)[number];
17
18 const SAMPLES = 12;
19 /** A probe still settling wanders more than this between samples (volts). */
20 const SETTLED = 0.006;
21
22 interface Saved {
23 ph?: PhCal;
24 ec?: EcCal;
25 /** The pH 7.0 capture waiting for its pH 4.0. */
26 pending?: { v7: number; tempC: number; at: number };
27 }
28
29 export interface ProbeView {
30 present: boolean;
31 error: string | null;
32 ph: number | null;
33 ec: number | null;
34 /** Which temperature compensated the values; "default" when none was given. */
35 tempC: number;
36 tempSource: "given" | "default";
37 calibratedAt: { ph: number | null; ec: number | null };
38 /** What the guide asks next. */
39 next: Step | null;
40 pending: boolean;
41 }
42
43 export class Probes {
44 private data: Saved = {};
45 private readonly adc: Adc;
46 private readonly file: string | undefined;
47 private readonly clock: () => number;
48 constructor(adc: Adc, file?: string, clock: () => number = Date.now) {
49 this.adc = adc;
50 this.file = file;
51 this.clock = clock;
52 if (file && existsSync(file)) {
53 try {
54 this.data = JSON.parse(readFileSync(file, "utf8"));
55 } catch (err) {
56 console.error(`probes: ${file} unreadable (${(err as Error).message}); starting uncalibrated`);
57 }
58 }
59 }
60
61 private save() {
62 if (!this.file) return;
63 mkdirSync(dirname(this.file), { recursive: true });
64 writeFileSync(`${this.file}.tmp`, JSON.stringify(this.data));
65 renameSync(`${this.file}.tmp`, this.file);
66 }
67
68 /** The mean of several samples; refuses while the probe is still settling. */
69 private async steady(channel: number): Promise<number> {
70 const v: number[] = [];
71 for (let i = 0; i < SAMPLES; i++) v.push(await this.adc(channel));
72 const mean = v.reduce((a, b) => a + b, 0) / v.length;
73 if (Math.max(...v) - Math.min(...v) > SETTLED) throw new CalError("the probe is still settling: give it another minute in the solution, then try again");
74 return mean;
75 }
76
77 next(): Step | null {
78 if (this.data.pending) return "ph4";
79 if (!this.data.ph) return "ph7";
80 return this.data.ec ? null : "ec1413";
81 }
82
83 async view(tempC?: number): Promise<ProbeView> {
84 const temp = tempC !== undefined && tempC > 0 && tempC < 45 ? tempC : DEFAULT_TEMP;
85 const base = { tempC: temp, tempSource: temp === tempC ? ("given" as const) : ("default" as const), calibratedAt: { ph: this.data.ph?.at ?? null, ec: this.data.ec?.at ?? null }, next: this.next(), pending: !!this.data.pending };
86 try {
87 // Both inputs are read even when uncalibrated: that is how an absent board shows.
88 const [vp, ve] = [await this.adc(CHANNELS.ph), await this.adc(CHANNELS.ec)];
89 const p = this.data.ph ? ph(vp, this.data.ph, temp) : null;
90 const e = this.data.ec ? ec(ve, this.data.ec, temp) : null;
91 return { ...base, present: true, error: null, ph: p === null ? null : Math.round(p * 100) / 100, ec: e === null ? null : Math.round(e * 1000) / 1000 };
92 } catch (err) {
93 if (!(err instanceof AdcError)) throw err;
94 return { ...base, present: false, error: err.message, ph: null, ec: null };
95 }
96 }
97
98 /** One guided step, with the buffer's temperature; throws CalError saying what to do. */
99 async calibrate(step: unknown, tempC: unknown = DEFAULT_TEMP): Promise<ProbeView> {
100 if (!STEPS.includes(step as Step)) throw new CalError(`the step is one of ${STEPS.join(", ")}`);
101 if (typeof tempC !== "number" || !(tempC > 0 && tempC < 45)) throw new CalError("the solution's temperature is 0 to 45 °C");
102 const now = this.clock();
103 try {
104 if (step === "ph7") {
105 this.data.pending = { v7: await this.steady(CHANNELS.ph), tempC, at: now };
106 } else if (step === "ph4") {
107 if (!this.data.pending) throw new CalError("do pH 7.0 first");
108 const v4 = await this.steady(CHANNELS.ph);
109 this.data.ph = phCalibration(this.data.pending.v7, v4, (this.data.pending.tempC + tempC) / 2, now);
110 delete this.data.pending;
111 } else {
112 this.data.ec = ecCalibration(await this.steady(CHANNELS.ec), tempC, now);
113 }
114 } catch (err) {
115 if (err instanceof AdcError) throw new CalError(err.message);
116 throw err;
117 }
118 this.save();
119 return this.view(tempC);
120 }
121 }
Software/so101/src/relay.ts +32 −3
@@ -108,6 +108,7 @@ import { CameraError, CameraHub } from "./cameras.ts";
108108 import { ARM_ONLY, StationError, StationHub } from "./stations.ts";
109109 import { CareLog } from "./care-log.ts";
110110 import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts";
111 import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts";
111112 import { handleCare } from "./care-routes.ts";
112113 import { renderWorkshop, type StationView } from "./workshop-page.ts";
113114 import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts";
@@ -164,6 +165,8 @@ export interface RelayOptions {
164165 stations?: StationHub;
165166 /** The plant care app's log book and plant profile (src/care-routes.ts); by default in memory, with the built-in spinach profile. */
166167 care?: { log: CareLog; profile: Profile };
168 /** How often the Pi's pH/EC probes are read into the care log, ms; unset, they are not sampled (the page still shows them and walks the calibration). */
169 probeEveryMs?: number;
167170 /** The grow box's stills, log and how often a still is taken (src/grow-stills.ts); without it nothing is taken and the card shows no timelapse. */
168171 grow?: { stills: GrowStills; log: GrowLog; everyMs: number };
169172 /** How often the arm's sensors are pulled into the care log book, ms; unset, never. */
@@ -239,9 +242,9 @@ const PAGE = /^\/(?:index\.html|arm)?$/;
239242 const STATIC = /^\/(?:index\.html)?$|^\/(?:model|vendor|ui)\//;
240243 const LEADER = "/leader/calibration";
241244 /** Admins only: clearing an emergency stop and changing the settings (the safety limits among them). */
242 const ADMIN_ONLY = new Set(["POST /estop/clear", "PUT /settings", "POST /settings/table-here", "POST /settings/table-touch", "POST /settings/joint-zero", "PUT /servo-params", "POST /servo-params/factory", "POST /tuning/p", "POST /grow/light", "POST /grow/pump"]);
245 const ADMIN_ONLY = new Set(["POST /estop/clear", "PUT /settings", "POST /settings/table-here", "POST /settings/table-touch", "POST /settings/joint-zero", "PUT /servo-params", "POST /servo-params/factory", "POST /tuning/p", "POST /grow/light", "POST /grow/pump", "POST /probes/calibrate"]);
243246 /** Routes only the owner may reach; anyone else is told there is no such page. */
244 const OWNER_READS = new Set(["GET /servo-params", "PUT /servo-params", "POST /servo-params/factory", "GET /tuning", "POST /tuning/p", "GET /grow", "POST /grow/light", "POST /grow/pump"]);
247 const OWNER_READS = new Set(["GET /servo-params", "PUT /servo-params", "POST /servo-params/factory", "GET /tuning", "POST /tuning/p", "GET /grow", "POST /grow/light", "POST /grow/pump", "GET /probes", "POST /probes/calibrate"]);
245248 /** The driver's, and any admin's at any time. */
246249 const ADMIN_TOO = new Set(["POST /stop", "POST /resume", "POST /estop"]);
247250 /** The forge's own paths (/__forge/signin and the like): the forge answers them in front of the app. */
@@ -612,6 +615,29 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
612615 };
613616 /** The grow light lives on the arm server (the Pi runs its schedule); this reads and changes it for the admin page. */
614617 const armGrow = (path = "") => `${remote && onArm() ? remote.http : simulation}/grow${path}`;
618 /** The pH/EC probes live on the arm server beside the ADC (src/probes.ts); the care page reads and calibrates them through this. */
619 const probeHooks: ProbeHooks = {
620 read: async (tempC) => {
621 try {
622 const answer = await fetch(`${remote && onArm() ? remote.http : simulation}/probes${tempC === undefined ? "" : `?temp=${tempC}`}`, { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1500)) });
623 return answer.ok ? readProbeView(await answer.json().catch(() => null)) : null;
624 } catch {
625 return null;
626 }
627 },
628 calibrate: async (step, tempC) => {
629 try {
630 const answer = await fetch(`${remote && onArm() ? remote.http : simulation}/probes/calibrate`, { method: "POST", body: JSON.stringify({ step, tempC }), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 8000)) });
631 const body = (await answer.json().catch(() => null)) as { error?: unknown } | null;
632 const view = answer.ok ? readProbeView(body) : null;
633 if (view) return { view };
634 return { error: typeof body?.error === "string" ? body.error.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 200) : `the arm server answered ${answer.status}` };
635 } catch (err) {
636 return { error: `the arm server did not answer (${reason(err)})` };
637 }
638 },
639 };
640 const probeCache = new ProbeCache(probeHooks, () => waterTemp(care.log, Date.now()), () => care.log.notify());
615641 const lightHooks: LightHooks = {
616642 read: async () => {
617643 try {
@@ -1073,6 +1099,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10731099 sensorTimer?.unref?.();
10741100 const stillTimer = options.grow ? setInterval(() => void takeStill(), options.grow.everyMs) : undefined;
10751101 stillTimer?.unref?.();
1102 const probeTimer = options.probeEveryMs ? setInterval(() => void sampleProbes(care.log, probeCache).catch((err) => console.error(`probes: ${reason(err)}`)), options.probeEveryMs) : undefined;
1103 probeTimer?.unref?.();
10761104
10771105 const http = createServer(async (req, res) => {
10781106 for (const [name, value] of Object.entries(FLOOR)) res.setHeader(name, value);
@@ -1096,7 +1124,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10961124 const person = lobby ? lobby.who(r) : LOCAL_PERSON;
10971125 return { owner: Boolean(person?.owner), known: Boolean(person), signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null };
10981126 };
1099 if (await handleCare(req, res, url.pathname, { ...care, who, readBody })) return;
1127 if (await handleCare(req, res, url.pathname, { ...care, probes: probeCache, who, readBody })) return;
11001128 }
11011129 if (reading && url.pathname === "/stations") return json(res, 200, stationViews().map(({ station, present, resting, cameras: c }) => ({ id: station.id, name: station.name, kind: station.kind, present, ...(resting ? { resting } : {}), cameras: c })));
11021130 const cameraAdmin = ADMIN_CAMERA.exec(url.pathname);
@@ -1572,6 +1600,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
15721600 chatFacts?.();
15731601 clearInterval(sampleTimer);
15741602 clearInterval(stillTimer);
1603 clearInterval(probeTimer);
15751604 clearInterval(sensorTimer);
15761605 readBacks?.();
15771606 arrived?.();
Software/so101/src/server.ts +22
@@ -51,6 +51,8 @@
5151 * GET /grow the grow light and the pump (src/light.ts): schedule, switch driver, "On until 22:00", override and the last transitions
5252 * GET /grow/sensors the sensors listed in sensors.json (src/sensors.ts: ds18b20, sht31, bme280, float): each one's last value, when, and why it failed; and their Readings
5353 * POST /grow/light {"schedule"?: {"onAt": "06:00", "hours": 12}|null, "switch"?: {"driver": "shelly"|"gpio"|"uhubctl", ...}|null} or {"override": "on"|"off"|"clear"}; 400 says why not
54 * GET /probes the pH and EC probes on the ADS1115 (src/probes.ts): values (null until calibrated), ?temp= compensates, and the next calibration step
55 * POST /probes/calibrate {"step": "ph7"|"ph4"|"ec1413", "tempC"?: number}; 400 says what to do
5456 * POST /grow/pump the same, with an interval schedule {"onMinutes": 5, "offMinutes": 25}
5557 * GET /tuning the run's phase while one is under way, and the last outcome
5658 * GET /precision/trace?session=<id> a kept session's 5 Hz intent and read-back per joint, ticks: {session, t0, hz, joints} (400 bad id, 404 unknown or evicted; the last 20 sessions)
@@ -101,6 +103,9 @@ import { MachineLog, perServo } from "./machine-log.ts";
101103 import { Metrics } from "./metrics.ts";
102104 import { wallMs } from "./clock.ts";
103105 import { readPiHealth } from "./pi-health.ts";
106 import { ads1115 } from "./ads1115.ts";
107 import { CalError } from "./probe-math.ts";
108 import { Probes } from "./probes.ts";
104109 import { loadSensors, Sensors } from "./sensors.ts";
105110 import { DEVICES, Light, LightError, type DeviceName } from "./light.ts";
106111 import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts";
@@ -141,6 +146,8 @@ export interface ServerOptions {
141146 lightMs?: number;
142147 /** The grow light and the pump, for tests that give them a fake switch. */
143148 lights?: Record<DeviceName, Light>;
149 /** The pH/EC probes on the ADS1115, for tests that give them a fake ADC. */
150 probes?: Probes;
144151 /** How often the sensors (src/sensors.ts, listed in sensors.json) are read, ms; unset, they are not run. */
145152 sensorsMs?: number;
146153 /** The sensors, for tests that give them fake sources; by default those listed in sensors.json under the data dir. */
@@ -247,6 +254,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
247254 }
248255 const metrics = new Metrics(join(dataDir, "metrics.jsonl"));
249256 const lights = options.lights ?? { light: new Light({ name: "light", file: join(dataDir, "light.json") }), pump: new Light({ name: "pump", file: join(dataDir, "pump.json") }) };
257 const probes = options.probes ?? new Probes(ads1115(), join(dataDir, "probes.json"));
250258 const sensors = options.sensors ?? loadSensors(join(dataDir, "sensors.json"));
251259 const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit });
252260 /** The stream's precision tracker while one drives; every Goal_Position write starts its bus-to-motion clock. */
@@ -988,6 +996,20 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
988996 }
989997 return json(res, 200, device.view());
990998 }
999 case "GET /probes": {
1000 const t = url.searchParams.get("temp");
1001 return json(res, 200, await probes.view(t !== null && /^\d{1,2}(\.\d{1,3})?$/.test(t) ? Number(t) : undefined));
1002 }
1003 case "POST /probes/calibrate": {
1004 const { step, tempC, ...rest } = await readBody(req);
1005 if (Object.keys(rest).length) throw new HttpError(400, `no field "${Object.keys(rest)[0]!.slice(0, 40)}"`);
1006 try {
1007 return json(res, 200, await probes.calibrate(step, tempC));
1008 } catch (err) {
1009 if (err instanceof CalError) throw new HttpError(400, err.message);
1010 throw err;
1011 }
1012 }
9911013 case "GET /tuning":
9921014 return json(res, 200, tuning.state);
9931015 case "POST /tuning/p": {
Software/so101/src/terminal.ts +9 −3
@@ -35,6 +35,7 @@
3535 * /align [joint] admin: list each joint's reference pose and offset; with a joint, record it at its reference (POST /settings/joint-zero) and say Done or record again
3636 * /health owner or moderator: each open page's link as it last reported it (GET /admin/health, the page's Admin -> Health)
3737 * /stations [set <json>] the workshop's stations (GET /stations); the owner replaces them with a JSON list (PUT /admin/stations)
38 * /probes [calibrate <ph7|ph4|ec1413> [tempC]] the pH/EC probes (GET /care/probes); anyone reads, the owner calibrates (POST /care/probes/calibrate)
3839 * /care [log|did|note|problem|done|snooze|delete …] the plant care app (GET /care/api); anyone reads, the owner writes (POST /care/log, /care/reminder)
3940 * /versions owner or moderator: the app's and the Pi's commits and the last sync (GET /admin/versions)
4041 * /trace <session> owner or moderator: a kept session's 5 Hz trace, per joint (GET /precision/trace)
@@ -72,7 +73,8 @@ import type { DeviceName, LightView } from "./light.ts";
7273 import { readTuning, tuningSentence, type TuningView } from "./tuning-panel.ts";
7374 import { JOINTS } from "./sts3215.ts";
7475 import { headlineValue, hopLines, NO_HOPS, P95_RULE, readPrecision, sampleSize, sparkline, type PrecisionPanel } from "./precision-panel.ts";
75 import { careCommand, careLines, type CareAsk } from "./care-terminal.ts";
76 import { probeLines } from "./care-probes.ts";
77 import { careCommand, careLines, probesCommand, type CareAsk } from "./care-terminal.ts";
7678 import { presenceText } from "./ui/lobby-panel.js";
7779 import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js";
7880
@@ -193,7 +195,7 @@ export function careReader(env: Env, fetcher: typeof fetch = fetch): ReadCare {
193195 const r = await fetcher(`${appUrl(env)}${ask.path}`, { method: ask.method, headers: { ...terminalHeaders(env), ...(write ? { "content-type": "application/json" } : {}) }, ...(write ? { body: JSON.stringify(ask.body ?? {}) } : {}), signal: AbortSignal.timeout(5000) });
194196 const body = (await r.json().catch(() => null)) as unknown;
195197 if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] };
196 return { error: null, lines: write ? ["care saved"] : careLines(body).map(plain) };
198 return { error: null, lines: ask.path === "/care/probes/calibrate" ? ["probe captured", ...probeLines({ view: body }).map(plain)] : write ? ["care saved"] : ask.path === "/care/probes" ? probeLines(body).map(plain) : careLines(body).map(plain) };
197199 } catch (err) {
198200 return { error: plain((err as Error).message), lines: [] };
199201 }
@@ -545,7 +547,7 @@ export function whoami(room: Room): string {
545547 /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */
546548 export const SIGNOUT_NOTE = "the terminal keeps no session: you are whoever your SSH key signs in as, each time you connect; /quit to leave, and connect with another key to be someone else";
547549
548 export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner) /grow log|planted <date>|note <text> · /care [log|did|note|problem|done|snooze|delete …] · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit";
550 export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner) /grow log|planted <date>|note <text> · /care [log|did|note|problem|done|snooze|delete …] · /probes [calibrate <ph7|ph4|ec1413> [tempC]] (calibrate: owner) · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit";
549551
550552 /** The Machine feed's socket beside the lobby's: same app, same port. */
551553 export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine");
@@ -718,6 +720,10 @@ export function typed(room: Room, input: string): Typed {
718720 const r = careCommand([arg, ...rest].filter(Boolean), line.replace(/^\/\S+\s+\S+\s*/, "").trim());
719721 return "method" in r ? { care: r } : r;
720722 }
723 if (/^probes$/i.test(verb)) {
724 const r = probesCommand([arg, ...rest].filter(Boolean));
725 return "method" in r ? { care: r } : r;
726 }
721727 if (/^align$/i.test(verb)) {
722728 if (rest.length || (arg && !REFERENCES.some((r) => r.joint === arg))) return { print: `usage: /align [${REFERENCES.map((r) => r.joint).join("|")}]` };
723729 return { align: arg || null };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs