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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Dosing: temperature and level sensor drivers (DS18B20, SHT31/BME280, float switches) as Reading sources #608

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/605-dosing-temperature-and-level-sensor-driv → main 2 commits fork 3 threads · 3 unresolved

  • Merged. smith/605-dosing-temperature-and-level-sensor-driv was merged into main as d2bae2e.

Review

Sign in to review this change.
14 files changed +590 −9
Software/so101/README.md +28
@@ -80,6 +80,34 @@ both cameras. The switch drivers:
8080 `/grow`, `POST /grow/light` and `POST /grow/pump` through the app are the owner's: to anyone else they answer 404.
8181 `bench/fake-serve.ts` (what production and the previews run) runs the schedules too, on its simulated arm.
8282
83 ## Sensors (`sensors.json`)
84
85 Temperature and level sensors are read on the Pi by the arm server every minute and shown on /admin → Grow ("Sensors":
86 the value and how long ago it was read, or why it could not be) and by `/grow` in the terminal (both the owner's, like the
87 rest of Grow). The relay pulls the last good reading of each into the care log book (`/care`), at most one per kind an
88 hour, marked as a sensor's. They only feed that book and the card; nothing dosing-related reads them yet.
89
90 List them in `sensors.json` in the arm's data directory (`FORGE_APP_DATA`), a JSON array of up to 8; a wrong file is
91 ignored and said once on the arm server's stderr. The arm server reads it at start, so restart it after an edit.
92
93 ```json
94 [
95 { "driver": "ds18b20", "id": "28-0316a2791234" },
96 { "driver": "sht31", "bus": 1, "addr": 68 },
97 { "driver": "bme280", "bus": 1, "addr": 118 },
98 { "driver": "float", "chip": "gpiochip0", "line": 17, "activeLow": false }
99 ]
100 ```
101
102 - `ds18b20`: a 1-Wire probe in the water, read as water temperature. Enable the overlay in `/boot/firmware/config.txt`
103 (`dtoverlay=w1-gpio`, default data pin GPIO 4, with a 4.7 kΩ pull-up to 3.3 V) and reboot; the probe's id is the
104 `28-…` folder in `/sys/bus/w1/devices/`. A probe that says exactly 85 °C has only just powered up and is refused.
105 - `sht31` (address 68 = 0x44 or 69 = 0x45) and `bme280` (118 = 0x76 or 119 = 0x77): air temperature over I²C. Turn I²C
106 on (`raspi-config`) and install `i2c-tools` (`i2ctransfer` must be on the Pi's path). `bus` is 1 on a Pi's header.
107 - `float`: a float switch on a GPIO line, read as water level: 100 when it reads wet, 0 when dry (a coarse two-state
108 level, not a percentage); `activeLow` flips it. Install `gpiod` (`gpioget`); wire the switch to the line and ground and
109 pull the line up.
110
83111 ## The workshop: stations on /
84112
85113 The app is a workshop: one Pi, several **stations**, each with its cameras and controls. Today there are two, **the arm** and **the grow box** (spinach under a UV light). `/` is the workshop home, a card per station with its live camera snapshot first and its controls after, and the arm's own page is at `/arm` (its URL is unchanged for anyone who has it). The page and its title say "Workshop"; it shows who is looking ("Signed in as …", or "Watching as a guest · Sign in"), has a skip link, and keeps working while the arm is closed.
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({ 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({ 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/admin-page.ts +1
@@ -71,6 +71,7 @@ th { color: var(--dim); font-weight: 600; }
7171 .strip { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .8rem; font-variant-numeric: tabular-nums; }
7272 .strip li { display: block; padding: .15rem .5rem; border: 1px solid var(--line); border-radius: 6px; }
7373 .strip li.on { border-color: var(--ok); }
74 .sensors li.bad { color: #ffb3ad; }
7475 .strip small { color: var(--dim); }
7576 details { border: 1px solid var(--line); border-radius: 8px; padding: 0 .75rem; margin: .5rem 0; }
7677 details[open] { padding-bottom: .75rem; }
Software/so101/src/admin-routes.ts +2 −1
@@ -1,4 +1,5 @@
11 /** /admin: the owner's page, the refusals, and the form actions. */
2 import type { SensorView } from "./sensors.ts";
23 import type { IncomingMessage, ServerResponse } from "node:http";
34 import type { Person } from "./identity.ts";
45 import type { Lobby } from "./lobby.ts";
@@ -26,7 +27,7 @@ export type TuneP = (joint: number, p: number) => Promise<string | null>;
2627
2728 /** The grow light on the arm server: read it for the page, and send it a change (answers why not as a sentence, or null when it took). */
2829 export interface LightHooks {
29 read: () => Promise<{ light: LightView; pump: LightView | null } | null>;
30 read: () => Promise<{ light: LightView; pump: LightView | null; sensors?: SensorView[] } | null>;
3031 write: (device: DeviceName, body: Record<string, unknown>) => Promise<string | null>;
3132 }
3233
Software/so101/src/care-log.ts +23
@@ -8,6 +8,7 @@ import { dirname } from "node:path";
88 import { MEASURES, type Measure } from "./care-profile.ts";
99
1010 export const MAX_ENTRIES = 5000;
11 export const SENSOR_EVERY_MS = 3_600_000;
1112 export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done"] as const;
1213 export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const;
1314 export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const;
@@ -172,6 +173,28 @@ export class CareLog {
172173 return entry;
173174 }
174175
176 /**
177 * A sensor's Reading, taken on the hour at most per kind (a probe asked every minute would fill the book in a day). When
178 * the book is full the oldest sensor reading makes room, so a hand's entries are never the ones lost. Returns null when
179 * this kind was logged less than an hour before, or at or after `r.at`.
180 */
181 addReading(r: Reading): Entry | null {
182 const { lo, hi } = { lo: LIMITS[r.kind][0], hi: LIMITS[r.kind][1] };
183 if (!Number.isFinite(r.value) || r.value < lo || r.value > hi || !Number.isFinite(r.at) || r.at > this.clock() + 60_000) throw new LogError(`${r.kind} is a number from ${lo} to ${hi}`);
184 const last = this.data.entries.findLast((e) => e.type === "measurement" && e.kind === r.kind && e.source === "sensor");
185 if (last && r.at - last.at < SENSOR_EVERY_MS) return null;
186 if (this.data.entries.length >= MAX_ENTRIES) {
187 const old = this.data.entries.findIndex((e) => e.type === "measurement" && e.source === "sensor");

The full-book eviction branch has no test. Fill the book with manual entries plus one sensor entry and assert that only the sensor entry goes, and that it throws when there is none.

188 if (old < 0) throw new LogError("the log book is full");
189 this.data.entries.splice(old, 1);
190 }
191 const entry: Entry = { id: this.data.nextId++, at: r.at, type: "measurement", kind: r.kind, value: r.value, source: "sensor" };
192 this.data.entries.push(entry);
193 this.data.entries.sort((a, b) => a.at - b.at || a.id - b.id);
194 this.save();
195 return entry;
196 }
197
175198 update(id: number, input: unknown): Entry {
176199 const i = this.data.entries.findIndex((e) => e.id === id);
177200 if (i < 0) throw new LogError("no such entry");
Software/so101/src/cli.ts +1 −1
@@ -536,7 +536,7 @@ const COMMANDS: Record<string, Command> = {
536536 const { startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
537537 const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile);
538538 const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit));
539 const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000 });
539 const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 });
540540 console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`);
541541 console.log("ctrl-c to stop");
542542 await new Promise<void>((resolve) => process.once("SIGINT", () => resolve()));
Software/so101/src/light-panel.ts +6 −2
@@ -5,6 +5,7 @@
55 * checks every field, this only turns form text into the JSON it takes. Everything shown is escaped.
66 */
77 import { DEVICES, type DeviceName, type LightView } from "./light.ts";
8 import { sensorText, type SensorView } from "./sensors.ts";
89 import { isCycle } from "./light-schedule.ts";
910
1011 /** Spinach bolts on long days: modern lines at about 16 h, older ones at 14 h or more. */
@@ -55,11 +56,14 @@ const post = (device: DeviceName, action: string, extra: string, text: string, c
5556 `<form method="post" action="/admin" class="inline"><input type="hidden" name="action" value="${action}"><input type="hidden" name="device" value="${device}">${extra}<button${cls ? ` class="${cls}"` : ""}>${text}</button></form>`;
5657
5758 /** The grow card: the light, then the pump. null is the arm server not answering. */
58 export function lightSections(grow: { light: LightView; pump: LightView | null } | null, canEdit: boolean, esc: (s: string) => string): string {
59 export function lightSections(grow: { light: LightView; pump: LightView | null; sensors?: SensorView[] } | null, canEdit: boolean, esc: (s: string) => string, now = Date.now()): string {
5960 const head = `<section aria-labelledby="light"><h2 id="light">Grow</h2>`;
6061 if (!grow) return `${head}<p class="empty">The arm server did not answer, so the schedules cannot be read. They run on the Pi and do not need this page.</p></section>`;
6162 const pump = grow.pump ? deviceCard(grow.pump, canEdit, esc) : `<h3>Pump</h3><p class="empty">The arm server is older than the pump: it will appear after the Pi updates.</p>`;
62 return `${head}${deviceCard(grow.light, canEdit, esc)}${pump}</section>`;
63 const sensors = grow.sensors?.length
64 ? `<h3>Sensors</h3><ul class="sensors">${grow.sensors.map((v) => `<li${v.error ? ` class="bad" role="alert"` : ""}>${esc(sensorText(v, now))}</li>`).join("")}</ul>`
65 : "";
66 return `${head}${deviceCard(grow.light, canEdit, esc)}${pump}${sensors}</section>`;
6367 }
6468
6569 /** One driver's own form in a details element (open when it is the one in use), so a phone shows only the fields it needs and no script is wanted. */
Software/so101/src/light-read.ts +14 −2
@@ -1,14 +1,26 @@
11 /** The arm server's GET /light, read field by field so a wrong answer shows as less, never as markup or a crash. */
2 import { MEASURES, type Measure } from "./care-profile.ts";
3 import type { SensorView } from "./sensors.ts";
24 import type { DeviceName, LightView, Transition } from "./light.ts";
35
46 const obj = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
57 const text = (v: unknown, max: number): string => (typeof v === "string" ? v.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, max) : "");
68
79 /** GET /grow: the light and the pump; null when the light's answer is not one. */
8 export function readGrow(body: unknown): { light: LightView; pump: LightView | null } | null {
10 export function readGrow(body: unknown): { light: LightView; pump: LightView | null; sensors: SensorView[] } | null {
911 const b = obj(body);
1012 const light = readLight(b.light, "light");
11 return light ? { light, pump: readLight(b.pump, "pump") } : null;
13 return light ? { light, pump: readLight(b.pump, "pump"), sensors: readSensors(b.sensors) } : null;
14 }
15
16 /** The sensors' views, field by field; an arm server older than the sensors has none. */
17 export function readSensors(body: unknown): SensorView[] {
18 return (Array.isArray(body) ? body : []).slice(0, 16).flatMap((e: unknown) => {
19 const s = obj(e);
20 if (!MEASURES.includes(s.kind as Measure) || typeof s.label !== "string") return [];
21 const good = typeof s.value === "number" && Number.isFinite(s.value) && typeof s.at === "number" && Number.isFinite(s.at);
22 return [{ label: text(s.label, 60), kind: s.kind as Measure, value: good ? (s.value as number) : null, at: good ? (s.at as number) : null, error: text(s.error, 200) || null }];
23 });
1224 }
1325
1426 export function readLight(body: unknown, name: DeviceName): LightView | null {
Software/so101/src/relay.ts +23
@@ -166,6 +166,8 @@ export interface RelayOptions {
166166 care?: { log: CareLog; profile: Profile };
167167 /** 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. */
168168 grow?: { stills: GrowStills; log: GrowLog; everyMs: number };
169 /** How often the arm's sensors are pulled into the care log book, ms; unset, never. */
170 sensorsMs?: number;
169171 /** A setting that could not be used (shown on /link while simulating). */
170172 problem?: string | null;
171173 /** The link's retries and its patience (src/arm-link.ts). */
@@ -1049,6 +1051,26 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10491051 /* the camera or the Pi did not answer: no still this time */
10501052 }
10511053 };
1054 /** Pull the arm's sensor Readings (src/sensors.ts) into the log book, an hour apart per kind (CareLog.addReading). Quiet when the arm does not answer. */

This pull is the only writer of sensor entries and it has no test: a fake arm answering /grow/sensors, bad entries skipped, an arm that fails logs nothing.

1055 const pullSensors = async () => {
1056 if (!(remote && onArm())) return;
1057 try {
1058 const answer = await fetch(armGrow("/sensors"), { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 2000)) });
1059 const body = (await answer.json()) as { readings?: unknown };
1060 if (!answer.ok || !Array.isArray(body.readings)) return;
1061 for (const r of body.readings.slice(0, 16)) {
1062 try {
1063 care.log.addReading({ kind: r?.kind, value: r?.value, at: r?.at, source: "sensor" });
1064 } catch {
1065 /* an implausible value is not logged */
1066 }
1067 }
1068 } catch {
1069 /* the arm did not answer: no reading this time */
1070 }
1071 };
1072 const sensorTimer = options.sensorsMs ? setInterval(() => void pullSensors(), options.sensorsMs) : undefined;
1073 sensorTimer?.unref?.();
10521074 const stillTimer = options.grow ? setInterval(() => void takeStill(), options.grow.everyMs) : undefined;
10531075 stillTimer?.unref?.();
10541076
@@ -1550,6 +1572,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
15501572 chatFacts?.();
15511573 clearInterval(sampleTimer);
15521574 clearInterval(stillTimer);
1575 clearInterval(sensorTimer);
15531576 readBacks?.();
15541577 arrived?.();
15551578 armLink?.close();
Software/so101/src/sensors-relay.test.ts added +118
@@ -0,0 +1,118 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { FeetechBus } from "./bus.ts";
6 import { CareLog, MAX_ENTRIES } from "./care-log.ts";
7 import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts";
8 import { FakeBus } from "./fake-bus.ts";
9 import { esc } from "./admin-page.ts";
10 import { readGrow } from "./light-read.ts";
11 import { lightSections } from "./light-panel.ts";
12 import { startRelay, parseRemote, type RunningRelay } from "./relay.ts";
13 import { Sensors, sensorText, type SensorView } from "./sensors.ts";
14 import { startServer, type RunningServer } from "./server.ts";
15 import { growLines } from "./terminal.ts";
16
17 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
18 const shut = (s: { close(): unknown } | undefined) => Promise.race([Promise.resolve(s?.close()), sleep(500)]);
19 const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!;
20
21 let servers: RunningServer[] = [];
22 let relay: RunningRelay | undefined;
23 afterEach(async () => {
24 await shut(relay);
25 for (const s of servers) await shut(s);
26 servers = [];
27 relay = undefined;
28 });
29
30 const arm = async (ids: number[], sensors?: unknown) => {
31 const s = await startServer(new FeetechBus(new FakeBus(ids)), { host: "127.0.0.1", port: 0, ids, posesFile: join(mkdtempSync(join(tmpdir(), "so101-sr-")), "poses.json"), watchdogMs: 60, watchEveryMs: 10, watchQuietMs: 20, ...(sensors ? { sensors: sensors as Sensors } : {}) });
32 servers.push(s);
33 return s;
34 };
35
36 /** A relay on a fake arm whose /grow/sensors says `readings`; resolves with the book once it has been given `wait` ms. */
37 async function pulled(readings: unknown, fail = false): Promise<CareLog> {
38 const stub = { views: () => [], readings: () => readings, tick: async () => {} };
39 const real = await arm([1, 2, 3], stub);
40 const sim = await arm([1, 2, 3, 4, 5, 6]);
41 const log = new CareLog();
42 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${sim.port}`, remote: parseRemote({ ARM_SERVER: `ws://127.0.0.1:${real.port}` })!, care: { log, profile }, sensorsMs: 30 });
43 const base = `http://127.0.0.1:${relay.port}`;
44 for (let i = 0; i < 100 && ((await (await fetch(`${base}/link`)).json()) as { mode: string }).mode !== "live"; i++) await sleep(20);
45 if (fail) await shut(real);
46 await sleep(300);
47 return log;
48 }
49
50 test("the relay pulls the arm's readings into the book, skipping bad entries", async () => {
51 const now = Date.now();
52 const log = await pulled([
53 { kind: "waterTemp", value: 19.5, at: now },
54 { kind: "ph", value: 99, at: now },
55 { kind: "airTemp", value: "hot", at: now },
56 { kind: "nonsense", value: 1, at: now },
57 null,
58 { kind: "level", value: 100, at: now + 10 * 3_600_000 },
59 { kind: "airTemp", value: 22, at: now },
60 ]);
61 expect(log.entries().map((e) => e.type === "measurement" && [e.kind, e.value, e.source])).toEqual([["waterTemp", 19.5, "sensor"], ["airTemp", 22, "sensor"]]);
62 });
63
64 test("an arm that fails logs nothing", async () => {
65 const log = await pulled([{ kind: "waterTemp", value: 19.5, at: Date.now() }], true);
66 expect(log.entries().length).toBe(0);
67 });
68
69 test("a full book gives up its oldest sensor reading, never a hand's entry", () => {
70 const t0 = 1_700_000_000_000;
71 let t = t0 + 100 * 3_600_000;
72 const log = new CareLog(undefined, () => t);
73 log.addReading({ kind: "waterTemp", value: 18, at: t0, source: "sensor" });
74 for (let i = 1; i < MAX_ENTRIES; i++) log.add({ type: "observation", text: `n${i}`, at: t0 + i });
75 expect(log.entries().length).toBe(MAX_ENTRIES);
76 log.addReading({ kind: "waterTemp", value: 19, at: t, source: "sensor" });
77 expect(log.entries().length).toBe(MAX_ENTRIES);
78 expect(log.entries().filter((e) => e.type === "observation").length).toBe(MAX_ENTRIES - 1);
79 expect(log.entries().filter((e) => e.type === "measurement").map((e) => (e as { value: number }).value)).toEqual([19]);
80 // no sensor reading left to give up: a hand's entries are still not touched
81 t += 3_600_000 * 2;
82 expect(() => log.addReading({ kind: "airTemp", value: 20, at: t, source: "sensor" })).not.toThrow(); // evicts the 19
83 log.remove(log.entries().find((e) => e.type === "measurement")!.id);
84 log.add({ type: "observation", text: "fill" });
85 expect(() => log.addReading({ kind: "level", value: 100, at: t, source: "sensor" })).toThrow(/full/);
86 expect(log.entries().every((e) => e.type === "observation")).toBe(true);
87 });
88
89 const view = (over: Partial<SensorView>): SensorView => ({ label: "ds18b20 28-0316a2791234", kind: "waterTemp", value: 18.25, at: 1_000_000, error: null, ...over });
90
91 test("the sensors line: a value and its age, or the error", () => {
92 const now = 1_000_000 + 3 * 60_000;
93 expect(sensorText(view({}), now)).toBe("Water temperature: 18.25 °C, read 3 min ago (ds18b20 28-0316a2791234)");
94 expect(sensorText(view({ value: null, at: null, error: "the probe 28-0316a2791234 is not on the 1-Wire bus" }), now)).toContain("no reading yet");
95 expect(sensorText(view({ error: "the probe's CRC failed" }), now)).toContain("last good 18.25 °C, read 3 min ago");
96 });
97
98 test("the grow card and the terminal show the sensors, escaped; a wrong answer shows as less", () => {
99 const light = { configured: false, text: "Light: always on (no switch set up)", zone: "UTC", log: [] };
100 const grow = readGrow({ light, sensors: [{ kind: "waterTemp", label: "<b>x</b>", value: null, at: null, error: "no answer" }, { kind: "bogus", label: "y" }, "z"] })!;
101 expect(grow.sensors.length).toBe(1);
102 const html = lightSections(grow, false, esc, 0);
103 expect(html).toContain("<h3>Sensors</h3>");
104 expect(html).toContain("&#60;b&#62;x&#60;/b&#62;");
105 expect(html).not.toContain("<b>x</b>");
106 expect(html).toContain('role="alert"');
107 expect(growLines(grow, 0).some((l) => l.includes("Water temperature") && l.includes("no answer"))).toBe(true);
108 expect(readGrow({ light })!.sensors).toEqual([]);
109 expect(lightSections({ ...grow, sensors: [] }, false, esc)).not.toContain("Sensors");
110 });
111
112 test("GET /grow carries the sensors for the gated card", async () => {
113 const sensors = new Sensors([{ kind: "airTemp", label: "sht31 0x44", read: async () => 21.5 }]);
114 await sensors.tick();
115 const s = await arm([1, 2], sensors);
116 const body = (await (await fetch(`http://127.0.0.1:${s.port}/grow`)).json()) as { sensors: { value: number }[] };
117 expect(body.sensors[0]!.value).toBe(21.5);
118 });
Software/so101/src/sensors.test.ts added +106
@@ -0,0 +1,106 @@
1 import { expect, test } from "bun:test";
2 import { CareLog } from "./care-log.ts";
3 import { bme280Celsius, crc8, makeSource, parseDs18b20, parseSensors, parseSht31, SensorError, Sensors, type Source } from "./sensors.ts";
4 import type { Run } from "./light-switch.ts";
5
6 const nap = async () => {};
7
8 test("a DS18B20 file: temperature, bad CRC and the power-on 85 °C", async () => {
9 expect(parseDs18b20("72 01 4b 46 7f ff 0e 10 57 : crc=57 YES\n72 01 4b 46 7f ff 0e 10 57 t=23125\n")).toBe(23.125);
10 expect(() => parseDs18b20("72 01 : crc=00 NO\n72 01 t=23125\n")).toThrow(SensorError);
11 expect(() => parseDs18b20("x : crc=57 YES\nx t=85000\n")).toThrow(/85/);
12 const src = makeSource({ driver: "ds18b20", id: "28-0316a2791234" }, { readText: async (p) => (p.endsWith("28-0316a2791234/w1_slave") ? "a YES\nb t=19500\n" : "") });
13 expect(src.kind).toBe("waterTemp");
14 expect(await src.read()).toBe(19.5);
15 const gone = makeSource({ driver: "ds18b20", id: "28-0316a2791234" }, { readText: async () => { throw new Error("ENOENT"); } });
16 await expect(gone.read()).rejects.toThrow(/not on the 1-Wire bus/);
17 });
18
19 test("an SHT31 reading is checked by its CRC (datasheet example 0xBE 0xEF → 0x92)", async () => {
20 expect(crc8(0xbe, 0xef)).toBe(0x92);
21 const hi = 0x6666; // -45 + 175 * 0x6666/65535 = 25 °C
22 const b = [hi >> 8, hi & 255, crc8(hi >> 8, hi & 255), 0x80, 0x00, crc8(0x80, 0)];
23 expect(parseSht31(b)).toBeCloseTo(25, 1);
24 expect(() => parseSht31([...b.slice(0, 2), 0, ...b.slice(3)])).toThrow(/CRC/);
25 const calls: string[][] = [];
26 const run: Run = async (_c, args) => {
27 calls.push([...args]);
28 return { code: 0, out: args.some((a) => a.startsWith("r6")) ? b.map((x) => `0x${x.toString(16).padStart(2, "0")}`).join(" ") + "\n" : "" };
29 };
30 const src = makeSource({ driver: "sht31", bus: 1, addr: 0x44 }, { run, sleep: nap });
31 expect(await src.read()).toBeCloseTo(25, 1);
32 expect(calls[0]).toEqual(["-y", "1", "w2@0x44", "0x24", "0x00"]);
33 });
34
35 test("a BME280 temperature with the datasheet's example calibration", () => {
36 // dig_T1 = 27504, dig_T2 = 26435, dig_T3 = -1000, raw 519888 → 25.08 °C (datasheet §4.2.3)
37 const cal = [27504 & 255, 27504 >> 8, 26435 & 255, 26435 >> 8, -1000 & 255, (-1000 >> 8) & 255];
38 const raw = [519888 >> 12, (519888 >> 4) & 255, (519888 & 15) << 4];
39 expect(bme280Celsius(cal, raw)).toBeCloseTo(25.08, 2);
40 expect(() => bme280Celsius(cal, [0x80, 0, 0])).toThrow(/not measured/);
41 });
42
43 test("a float switch reads 100 when wet, 0 when dry, flipped by activeLow", async () => {
44 const at = (out: string, code = 0): Run => async () => ({ code, out });
45 const mk = (activeLow: boolean, out: string, code?: number) => makeSource({ driver: "float", chip: "gpiochip0", line: 17, activeLow }, { run: at(out, code) });
46 expect(await mk(false, '"17"=active\n').read()).toBe(100);
47 expect(await mk(false, '"17"=inactive\n').read()).toBe(0);
48 expect(await mk(true, '"17"=active\n').read()).toBe(0);
49 await expect(mk(false, "no such chip", 1).read()).rejects.toThrow(/gpioget failed/);
50 });
51
52 test("sensors.json is validated", () => {
53 expect(parseSensors([{ driver: "ds18b20", id: "28-0316A2791234" }, { driver: "sht31" }, { driver: "bme280", addr: 0x77 }, { driver: "float", line: 4 }])).toEqual([
54 { driver: "ds18b20", id: "28-0316a2791234" },
55 { driver: "sht31", bus: 1, addr: 0x44 },
56 { driver: "bme280", bus: 1, addr: 0x77 },
57 { driver: "float", chip: "gpiochip0", line: 4, activeLow: false },
58 ]);
59 for (const bad of ["x", [{ driver: "ds18b20", id: "../../etc" }], [{ driver: "sht31", addr: 0x76 }], [{ driver: "float", line: 99 }], [{ driver: "dht22" }], [{ driver: "float", line: 1, x: 1 }], Array(9).fill({ driver: "float", line: 1 })])
60 expect(() => parseSensors(bad)).toThrow(SensorError);
61 });
62
63 test("Sensors keeps the last good reading, one failure stops nothing, and the care log takes one an hour", async () => {
64 let t = 1_000_000_000_000;
65 let water = 20;
66 const sources: Source[] = [
67 { kind: "waterTemp", label: "water", read: async () => water },
68 { kind: "airTemp", label: "air", read: async () => { throw new SensorError("no answer"); } },
69 ];
70 const sensors = new Sensors(sources, () => t);
71 await sensors.tick();
72 expect(sensors.views()[1]).toMatchObject({ value: null, error: "no answer" });
73 expect(sensors.readings()).toEqual([{ kind: "waterTemp", value: 20, at: t, source: "sensor" }]);
74
75 const log = new CareLog(undefined, () => t);
76 expect(log.addReading(sensors.readings()[0]!)).not.toBeNull();
77 t += 600_000;
78 water = 21;
79 await sensors.tick();
80 expect(log.addReading(sensors.readings()[0]!)).toBeNull(); // ten minutes later: too soon
81 t += 3_600_000;
82 await sensors.tick();
83 expect(log.addReading(sensors.readings()[0]!)).not.toBeNull();
84 expect(log.entries().map((e) => e.type === "measurement" && [e.kind, e.value, e.source])).toEqual([["waterTemp", 20, "sensor"], ["waterTemp", 21, "sensor"]]);
85 expect(() => log.addReading({ kind: "waterTemp", value: 99, at: t, source: "sensor" })).toThrow();
86 });
87
88 test("GET /grow/sensors on the arm server answers with the sensors' last values", async () => {
89 const { mkdtempSync } = await import("node:fs");
90 const { tmpdir } = await import("node:os");
91 const { join } = await import("node:path");
92 const { FeetechBus } = await import("./bus.ts");
93 const { FakeBus } = await import("./fake-bus.ts");
94 const { startServer } = await import("./server.ts");
95 const dir = mkdtempSync(join(tmpdir(), "so101-sensors-"));
96 const sensors = new Sensors([{ kind: "waterTemp", label: "water", read: async () => 18.25 }]);
97 await sensors.tick();
98 const server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), sensors });
99 try {
100 const body = (await (await fetch(`http://127.0.0.1:${server.port}/grow/sensors`)).json()) as { sensors: { value: number }[]; readings: { kind: string; source: string }[] };
101 expect(body.sensors[0]!.value).toBe(18.25);
102 expect(body.readings[0]).toMatchObject({ kind: "waterTemp", source: "sensor" });
103 } finally {
104 server.close();
105 }
106 });
Software/so101/src/sensors.ts added +251
@@ -0,0 +1,251 @@
1 /**
2 * Temperature, air and level sensors as Reading sources, run by the arm server on the Pi. A source is `{ kind, read() }`
3 * and `read()` gives a number or throws SensorError; `Sensors.tick()` reads each and keeps the last good Reading.
4 * ds18b20 a 1-Wire probe in the water: /sys/bus/w1/devices/28-…/w1_slave (waterTemp)
5 * sht31 an I²C air sensor at 0x44/0x45, through i2ctransfer (airTemp)
6 * bme280 an I²C air sensor at 0x76/0x77, through i2ctransfer (airTemp)
7 * float a float switch on a GPIO line, through gpioget (level: 100 when it reads wet, else 0; coarse on purpose)
8 * Everything that reaches the machine is injected (`Run`, `ReadText`), so the tests use fake commands.
9 */
10 import { existsSync, readFileSync } from "node:fs";
11 import { readFile } from "node:fs/promises";
12 import { run, type Run } from "./light-switch.ts";
13 import type { Measure } from "./care-profile.ts";
14 import type { Reading } from "./care-log.ts";
15
16 export class SensorError extends Error {}
17
18 export type SensorConfig =
19 | { driver: "ds18b20"; id: string }
20 | { driver: "sht31"; bus: number; addr: 0x44 | 0x45 }
21 | { driver: "bme280"; bus: number; addr: 0x76 | 0x77 }
22 | { driver: "float"; chip: string; line: number; activeLow: boolean };
23
24 export interface Source {
25 kind: Measure;
26 /** Where it reads from, for the page and the errors. */
27 label: string;
28 read(): Promise<number>;
29 }
30
31 export type ReadText = (path: string) => Promise<string>;
32 export interface Deps {
33 run?: Run;
34 readText?: ReadText;
35 sleep?: (ms: number) => Promise<void>;
36 }
37
38 const MAX_SENSORS = 8;
39
40 /** Check the list of sensors from outside; throws SensorError naming the field. */
41 export function parseSensors(input: unknown): SensorConfig[] {
42 if (!Array.isArray(input) || input.length > MAX_SENSORS) throw new SensorError(`sensors are a list of up to ${MAX_SENSORS}`);
43 return input.map((raw): SensorConfig => {
44 if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new SensorError("a sensor is an object");
45 const c = raw as Record<string, unknown>;
46 const only = (...keys: string[]) => {
47 const extra = Object.keys(c).find((k) => k !== "driver" && !keys.includes(k));
48 if (extra) throw new SensorError(`no sensor field "${extra.slice(0, 40)}"`);
49 };
50 const bus = (): number => {
51 const b = c.bus ?? 1;
52 if (typeof b !== "number" || !Number.isInteger(b) || b < 0 || b > 20) throw new SensorError("the I²C bus is a whole number from 0 to 20");
53 return b;
54 };
55 switch (c.driver) {
56 case "ds18b20":
57 only("id");
58 if (typeof c.id !== "string" || !/^28-[0-9a-f]{12}$/i.test(c.id)) throw new SensorError("the probe id is like 28-0316a2791234");
59 return { driver: "ds18b20", id: c.id.toLowerCase() };
60 case "sht31":
61 case "bme280": {
62 only("bus", "addr");
63 const [a, b] = c.driver === "sht31" ? [0x44, 0x45] : [0x76, 0x77];
64 const addr = c.addr ?? a;
65 if (addr !== a && addr !== b) throw new SensorError(`the ${c.driver} address is ${a} or ${b} (0x${a.toString(16)} or 0x${b.toString(16)})`);
66 return { driver: c.driver, bus: bus(), addr } as SensorConfig;
67 }
68 case "float": {
69 only("chip", "line", "activeLow");
70 const chip = c.chip ?? "gpiochip0";
71 if (typeof chip !== "string" || !/^gpiochip\d{1,2}$/.test(chip)) throw new SensorError("the chip is like gpiochip0");
72 if (typeof c.line !== "number" || !Number.isInteger(c.line) || c.line < 0 || c.line > 63) throw new SensorError("the GPIO line is a whole number from 0 to 63");
73 if (c.activeLow !== undefined && typeof c.activeLow !== "boolean") throw new SensorError("active low is true or false");
74 return { driver: "float", chip, line: c.line, activeLow: c.activeLow === true };
75 }
76 default:
77 throw new SensorError("the driver is ds18b20, sht31, bme280 or float");
78 }
79 });
80 }
81
82 /** DS18B20's w1_slave: two lines, the first ending YES when the CRC held, the second `t=` in thousandths of °C. */
83 export function parseDs18b20(text: string): number {
84 const [crc, data] = text.trim().split("\n");
85 if (!crc?.trim().endsWith("YES")) throw new SensorError("the probe's CRC failed");
86 const t = data?.match(/\bt=(-?\d+)/);
87 if (!t) throw new SensorError("the probe gave no temperature");
88 const raw = Number(t[1]);
89 if (raw === 85000) throw new SensorError("the probe still says its power-on 85 °C"); // the reset value, not a measurement
90 return raw / 1000;
91 }
92
93 const bytesOf = (out: string): number[] => {
94 const b = out.trim().split(/\s+/).filter(Boolean).map((x) => (/^0x[0-9a-f]{1,2}$/i.test(x) ? Number(x) : NaN));
95 if (!b.length || b.some(Number.isNaN)) throw new SensorError("the I²C read gave no bytes");
96 return b;
97 };
98
99 /** Sensirion's CRC-8 (poly 0x31, init 0xFF) over one 16-bit word. */
100 export function crc8(a: number, b: number): number {
101 let crc = 0xff;
102 for (const byte of [a, b]) {
103 crc ^= byte;
104 for (let i = 0; i < 8; i++) crc = crc & 0x80 ? ((crc << 1) ^ 0x31) & 0xff : (crc << 1) & 0xff;
105 }
106 return crc;
107 }
108
109 /** The six bytes of an SHT31 measurement: temperature word, its CRC, humidity word, its CRC → °C. */
110 export function parseSht31(b: number[]): number {
111 if (b.length !== 6 || crc8(b[0]!, b[1]!) !== b[2] || crc8(b[3]!, b[4]!) !== b[5]) throw new SensorError("the SHT31 reading failed its CRC");
112 return -45 + (175 * ((b[0]! << 8) | b[1]!)) / 65535;
113 }
114
115 /** BME280 temperature from its calibration (dig_T1..3, bytes 0x88–0x8D) and the 20-bit raw word (0xFA–0xFC), datasheet §4.2.3. */
116 export function bme280Celsius(cal: number[], raw: number[]): number {
117 if (cal.length !== 6 || raw.length !== 3) throw new SensorError("the BME280 gave the wrong number of bytes");
118 const u16 = (i: number) => cal[i]! | (cal[i + 1]! << 8);
119 const s16 = (i: number) => (u16(i) << 16) >> 16;
120 const [t1, t2, t3] = [u16(0), s16(2), s16(4)];
121 const adc = (raw[0]! << 12) | (raw[1]! << 4) | (raw[2]! >> 4);
122 if (adc === 0x80000) throw new SensorError("the BME280 has not measured yet");
123 const v1 = (((adc >> 3) - (t1 << 1)) * t2) >> 11;
124 const v2 = (((((adc >> 4) - t1) * ((adc >> 4) - t1)) >> 12) * t3) >> 14;
125 return (((v1 + v2) * 5 + 128) >> 8) / 100;
126 }
127
128 const hex = (n: number) => `0x${n.toString(16)}`;
129
130 export function makeSource(c: SensorConfig, deps: Deps = {}): Source {
131 const exec = deps.run ?? run;
132 const readText = deps.readText ?? ((p: string) => readFile(p, "utf8"));
133 const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
134 const i2c = async (bus: number, args: string[]) => {
135 const r = await exec("i2ctransfer", ["-y", String(bus), ...args]);
136 if (r.code !== 0) throw new SensorError(`i2ctransfer failed: ${r.out.trim().slice(0, 200)}`);
137 return args.some((a) => a.startsWith("r")) ? bytesOf(r.out) : [];
138 };
139 switch (c.driver) {
140 case "ds18b20":
141 return {
142 kind: "waterTemp",
143 label: `ds18b20 ${c.id}`,
144 read: async () => parseDs18b20(await readText(`/sys/bus/w1/devices/${c.id}/w1_slave`).catch(() => { throw new SensorError(`the probe ${c.id} is not on the 1-Wire bus`); })),
145 };
146 case "sht31":
147 return {
148 kind: "airTemp",
149 label: `sht31 ${hex(c.addr)}`,
150 read: async () => {
151 // single shot, high repeatability, no clock stretching: ask, wait out the 15 ms, read six bytes
152 await i2c(c.bus, [`w2@${hex(c.addr)}`, "0x24", "0x00"]);
153 await sleep(20);
154 return parseSht31(await i2c(c.bus, [`r6@${hex(c.addr)}`]));
155 },
156 };
157 case "bme280":
158 return {
159 kind: "airTemp",
160 label: `bme280 ${hex(c.addr)}`,
161 read: async () => {
162 const at = hex(c.addr);
163 await i2c(c.bus, [`w2@${at}`, "0xf4", "0x21"]); // forced mode, temperature x1
164 await sleep(15);
165 const cal = await i2c(c.bus, [`w1@${at}`, "0x88", `r6@${at}`]);
166 const raw = await i2c(c.bus, [`w1@${at}`, "0xfa", `r3@${at}`]);
167 return bme280Celsius(cal, raw);
168 },
169 };
170 case "float":
171 return {
172 kind: "level",
173 label: `float switch ${c.chip} line ${c.line}`,
174 read: async () => {
175 const r = await exec("gpioget", ["-c", c.chip, String(c.line)]);
176 if (r.code !== 0) throw new SensorError(`gpioget failed: ${r.out.trim().slice(0, 200)}`);
177 const high = /=\s*active|=\s*1|^1$/m.test(r.out.trim());
178 return high !== c.activeLow ? 100 : 0;
179 },
180 };
181 }
182 }
183
184 export interface SensorView {
185 label: string;
186 kind: Measure;
187 /** The last good reading, null before one. */
188 value: number | null;
189 at: number | null;
190 /** Why the last read failed; cleared by the next good one. */
191 error: string | null;
192 }
193
194 export class Sensors {
195 private readonly rows: { source: Source; view: SensorView }[];
196 private readonly now: () => number;
197
198 constructor(sources: readonly Source[], now: () => number = Date.now) {
199 this.now = now;
200 this.rows = sources.map((source) => ({ source, view: { label: source.label, kind: source.kind, value: null, at: null, error: null } }));
201 }
202
203 /** Read every sensor once; one failing never stops the rest. */
204 async tick(): Promise<void> {
205 await Promise.all(
206 this.rows.map(async ({ source, view }) => {
207 try {
208 const v = await source.read();
209 if (!Number.isFinite(v)) throw new SensorError("not a number");
210 view.value = Math.round(v * 100) / 100;
211 view.at = this.now();
212 view.error = null;
213 } catch (err) {
214 view.error = err instanceof Error ? err.message.slice(0, 200) : "unreadable";
215 }
216 }),
217 );
218 }
219
220 views(): SensorView[] {
221 return this.rows.map((r) => ({ ...r.view }));
222 }
223
224 /** The last good Reading of each sensor, as a sensor's. */
225 readings(): Reading[] {
226 return this.rows.flatMap(({ view }) => (view.value === null || view.at === null ? [] : [{ kind: view.kind, value: view.value, at: view.at, source: "sensor" as const }]));
227 }
228 }
229
230 /** The sensors listed in `file` (a JSON array, see parseSensors); none when it is absent or wrong, said once on stderr. */
231 export function loadSensors(file: string, deps: Deps = {}): Sensors {
232 if (!existsSync(file)) return new Sensors([]);
233 try {
234 return new Sensors(parseSensors(JSON.parse(readFileSync(file, "utf8"))).map((c) => makeSource(c, deps)));
235 } catch (err) {
236 console.error(`sensors: ${file} ignored (${(err as Error).message})`);
237 return new Sensors([]);
238 }
239 }
240
241 const NAMES: Partial<Record<Measure, [string, string]>> = { waterTemp: ["Water temperature", " °C"], airTemp: ["Air temperature", " °C"], level: ["Water level", " %"] };
242
243 /** One sensor for a person: "Water temperature: 18.25 °C (ds18b20 28-…), read 3 min ago", or why it has not read. Plain text; the page escapes it. */
244 export function sensorText(v: SensorView, now: number): string {
245 const [name, unit] = NAMES[v.kind] ?? [v.kind, ""];
246 const mins = v.at === null ? 0 : Math.max(0, Math.round((now - v.at) / 60_000));
247 const when = mins < 1 ? "just now" : mins < 120 ? `${mins} min ago` : `${Math.round(mins / 60)} h ago`;
248 const last = v.value === null ? "" : `${v.value}${unit}, read ${when}`;
249 if (v.error) return `${name} (${v.label}): ${v.error}${last ? `; last good ${last}` : "; no reading yet"}`;
250 return `${name}: ${last || "no reading yet"} (${v.label})`;
251 }
Software/so101/src/server.ts +13 −1
@@ -49,6 +49,7 @@
4949 * POST /tuning/p {"joint": id, "p": 1-64} run the P experiment (src/tuning-run.ts) on that joint while nobody drives: sweep, write, sweep, keep or put back; 202 at once,
5050 * 409 while a run is under way or anything drives, 423 on E-stop or hold; the result joins GET /precision's tunings
5151 * GET /grow the grow light and the pump (src/light.ts): schedule, switch driver, "On until 22:00", override and the last transitions
52 * 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
5253 * 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
5354 * POST /grow/pump the same, with an interval schedule {"onMinutes": 5, "offMinutes": 25}
5455 * GET /tuning the run's phase while one is under way, and the last outcome
@@ -100,6 +101,7 @@ import { MachineLog, perServo } from "./machine-log.ts";
100101 import { Metrics } from "./metrics.ts";
101102 import { wallMs } from "./clock.ts";
102103 import { readPiHealth } from "./pi-health.ts";
104 import { loadSensors, Sensors } from "./sensors.ts";
103105 import { DEVICES, Light, LightError, type DeviceName } from "./light.ts";
104106 import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts";
105107 import { deployedCommit, PrecisionHistory, TRACE_HZ } from "./precision-history.ts";
@@ -139,6 +141,10 @@ export interface ServerOptions {
139141 lightMs?: number;
140142 /** The grow light and the pump, for tests that give them a fake switch. */
141143 lights?: Record<DeviceName, Light>;
144 /** How often the sensors (src/sensors.ts, listed in sensors.json) are read, ms; unset, they are not run. */
145 sensorsMs?: number;
146 /** The sensors, for tests that give them fake sources; by default those listed in sensors.json under the data dir. */
147 sensors?: Sensors;
142148 /** How long each sweep target is held, ms (1500); tests shorten it. */
143149 tuningHoldMs?: number;
144150 /** Replaces the stream sweep of the P experiment; tests give it the numbers to decide on. */
@@ -241,6 +247,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
241247 }
242248 const metrics = new Metrics(join(dataDir, "metrics.jsonl"));
243249 const lights = options.lights ?? { light: new Light({ name: "light", file: join(dataDir, "light.json") }), pump: new Light({ name: "pump", file: join(dataDir, "pump.json") }) };
250 const sensors = options.sensors ?? loadSensors(join(dataDir, "sensors.json"));
244251 const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit });
245252 /** The stream's precision tracker while one drives; every Goal_Position write starts its bus-to-motion clock. */
246253 let tracking: Tracker | null = null;
@@ -962,7 +969,9 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
962969 }
963970 }
964971 case "GET /grow":
965 return json(res, 200, { light: lights.light.view(), pump: lights.pump.view() });
972 return json(res, 200, { light: lights.light.view(), pump: lights.pump.view(), sensors: sensors.views() });
973 case "GET /grow/sensors":

Nothing proxies this to the relay or the page, so it is unreachable for a person. Surface it on the grow card and in tty.ts, gated like GET /grow.

974 return json(res, 200, { sensors: sensors.views(), readings: sensors.readings() });
966975 case "POST /grow/light":
967976 case "POST /grow/pump": {
968977 const device = lights[route.slice("POST /grow/".length) as DeviceName];
@@ -1781,6 +1790,8 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
17811790 if (gainTimer) void gainSync.tick();
17821791 const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined;
17831792 if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot());
1793 const sensorTimer = options.sensorsMs ? setInterval(() => void sensors.tick(), options.sensorsMs) : undefined;
1794 if (options.sensorsMs) void sensors.tick();
17841795 const address = http.address();
17851796 const port = typeof address === "object" && address ? address.port : options.port ?? 8101;
17861797 return {
@@ -1790,6 +1801,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
17901801 clearInterval(watching);
17911802 clearInterval(gainTimer);
17921803 clearInterval(lightTimer);
1804 clearInterval(sensorTimer);
17931805 machine.close();
17941806 precision.close();
17951807 bus.onSyncWrite = null;
Software/so101/src/terminal.ts +3 −1
@@ -66,6 +66,7 @@ import { ID, NAME, sign } from "./identity.ts";
6666 import { readVia, signedInText, type Via } from "./signed-in.ts";
6767 import { readServos, type ServoPanel } from "./servo-panel.ts";
6868 import { readGrow } from "./light-read.ts";
69 import { sensorText, type SensorView } from "./sensors.ts";
6970 import { isCycle } from "./light-schedule.ts";
7071 import type { DeviceName, LightView } from "./light.ts";
7172 import { readTuning, tuningSentence, type TuningView } from "./tuning-panel.ts";
@@ -307,7 +308,7 @@ export type Grow = { error: string | null; lines: string[] };
307308 export type ReadGrow = () => Promise<Grow>;
308309
309310 /** The light and the pump as the grow card says them: what each does now, its schedule and switch, the dark-period caution, the last changes. */
310 export function growLines(grow: { light: LightView; pump: LightView | null }): string[] {
311 export function growLines(grow: { light: LightView; pump: LightView | null; sensors?: SensorView[] }, now = Date.now()): string[] {
311312 const lines: string[] = [];
312313 for (const v of [grow.light, grow.pump]) {
313314 if (!v) continue;
@@ -318,6 +319,7 @@ export function growLines(grow: { light: LightView; pump: LightView | null }): s
318319 if (v.error) lines.push(` the switch failed: ${plain(v.error)}`);
319320 for (const e of v.log.slice(-3)) lines.push(` ${new Date(e.at).toISOString().slice(5, 16).replace("T", " ")}Z ${e.on ? "on" : "off"} (${e.cause})`);
320321 }
322 for (const v of grow.sensors ?? []) lines.push(plain(sensorText(v, now)));
321323 return lines;
322324 }
323325

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs