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
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/605-dosing-temperature-and-level-sensor-driv → main 2 commits fork 3 threads · 3 unresolved
14 files
changed +590
−9
Software/so101/README.md +28
| @@ -80,6 +80,34 @@ both cameras. The switch drivers: | ||
| 80 | 80 | `/grow`, `POST /grow/light` and `POST /grow/pump` through the app are the owner's: to anyone else they answer 404. |
| 81 | 81 | `bench/fake-serve.ts` (what production and the previews run) runs the schedules too, on its simulated arm. |
| 82 | 82 | |
| 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 | ||
| 83 | 111 | ## The workshop: stations on / |
| 84 | 112 | |
| 85 | 113 | 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") }); | ||
| 80 | 80 | const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir); |
| 81 | 81 | 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) }; |
| 82 | 82 | 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") }); | |
| 84 | 84 | const { mode, host: armHost } = relay.link(); |
| 85 | 85 | 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; } | ||
| 71 | 71 | .strip { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .8rem; font-variant-numeric: tabular-nums; } |
| 72 | 72 | .strip li { display: block; padding: .15rem .5rem; border: 1px solid var(--line); border-radius: 6px; } |
| 73 | 73 | .strip li.on { border-color: var(--ok); } |
| 74 | .sensors li.bad { color: #ffb3ad; } | |
| 74 | 75 | .strip small { color: var(--dim); } |
| 75 | 76 | details { border: 1px solid var(--line); border-radius: 8px; padding: 0 .75rem; margin: .5rem 0; } |
| 76 | 77 | details[open] { padding-bottom: .75rem; } |
Software/so101/src/admin-routes.ts +2 −1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | /** /admin: the owner's page, the refusals, and the form actions. */ |
| 2 | import type { SensorView } from "./sensors.ts"; | |
| 2 | 3 | import type { IncomingMessage, ServerResponse } from "node:http"; |
| 3 | 4 | import type { Person } from "./identity.ts"; |
| 4 | 5 | import type { Lobby } from "./lobby.ts"; |
| @@ -26,7 +27,7 @@ export type TuneP = (joint: number, p: number) => Promise<string | null>; | ||
| 26 | 27 | |
| 27 | 28 | /** 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). */ |
| 28 | 29 | export interface LightHooks { |
| 29 | read: () => Promise<{ light: LightView; pump: LightView | null } | null>; | |
| 30 | read: () => Promise<{ light: LightView; pump: LightView | null; sensors?: SensorView[] } | null>; | |
| 30 | 31 | write: (device: DeviceName, body: Record<string, unknown>) => Promise<string | null>; |
| 31 | 32 | } |
| 32 | 33 |
Software/so101/src/care-log.ts +23
| @@ -8,6 +8,7 @@ import { dirname } from "node:path"; | ||
| 8 | 8 | import { MEASURES, type Measure } from "./care-profile.ts"; |
| 9 | 9 | |
| 10 | 10 | export const MAX_ENTRIES = 5000; |
| 11 | export const SENSOR_EVERY_MS = 3_600_000; | |
| 11 | 12 | export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done"] as const; |
| 12 | 13 | export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const; |
| 13 | 14 | export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const; |
| @@ -172,6 +173,28 @@ export class CareLog { | ||
| 172 | 173 | return entry; |
| 173 | 174 | } |
| 174 | 175 | |
| 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"); | |
| 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 | ||
| 175 | 198 | update(id: number, input: unknown): Entry { |
| 176 | 199 | const i = this.data.entries.findIndex((e) => e.id === id); |
| 177 | 200 | 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> = { | ||
| 536 | 536 | const { startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks |
| 537 | 537 | const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile); |
| 538 | 538 | 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 }); | |
| 540 | 540 | console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`); |
| 541 | 541 | console.log("ctrl-c to stop"); |
| 542 | 542 | await new Promise<void>((resolve) => process.once("SIGINT", () => resolve())); |
Software/so101/src/light-panel.ts +6 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * checks every field, this only turns form text into the JSON it takes. Everything shown is escaped. |
| 6 | 6 | */ |
| 7 | 7 | import { DEVICES, type DeviceName, type LightView } from "./light.ts"; |
| 8 | import { sensorText, type SensorView } from "./sensors.ts"; | |
| 8 | 9 | import { isCycle } from "./light-schedule.ts"; |
| 9 | 10 | |
| 10 | 11 | /** 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 | ||
| 55 | 56 | `<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>`; |
| 56 | 57 | |
| 57 | 58 | /** 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 { | |
| 59 | 60 | const head = `<section aria-labelledby="light"><h2 id="light">Grow</h2>`; |
| 60 | 61 | 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>`; |
| 61 | 62 | 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>`; | |
| 63 | 67 | } |
| 64 | 68 | |
| 65 | 69 | /** 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 @@ | ||
| 1 | 1 | /** 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"; | |
| 2 | 4 | import type { DeviceName, LightView, Transition } from "./light.ts"; |
| 3 | 5 | |
| 4 | 6 | const obj = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}); |
| 5 | 7 | const text = (v: unknown, max: number): string => (typeof v === "string" ? v.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, max) : ""); |
| 6 | 8 | |
| 7 | 9 | /** 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 { | |
| 9 | 11 | const b = obj(body); |
| 10 | 12 | 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 | }); | |
| 12 | 24 | } |
| 13 | 25 | |
| 14 | 26 | export function readLight(body: unknown, name: DeviceName): LightView | null { |
Software/so101/src/relay.ts +23
| @@ -166,6 +166,8 @@ export interface RelayOptions { | ||
| 166 | 166 | care?: { log: CareLog; profile: Profile }; |
| 167 | 167 | /** 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. */ |
| 168 | 168 | 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; | |
| 169 | 171 | /** A setting that could not be used (shown on /link while simulating). */ |
| 170 | 172 | problem?: string | null; |
| 171 | 173 | /** The link's retries and its patience (src/arm-link.ts). */ |
| @@ -1049,6 +1051,26 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1049 | 1051 | /* the camera or the Pi did not answer: no still this time */ |
| 1050 | 1052 | } |
| 1051 | 1053 | }; |
| 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?.(); | |
| 1052 | 1074 | const stillTimer = options.grow ? setInterval(() => void takeStill(), options.grow.everyMs) : undefined; |
| 1053 | 1075 | stillTimer?.unref?.(); |
| 1054 | 1076 | |
| @@ -1550,6 +1572,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1550 | 1572 | chatFacts?.(); |
| 1551 | 1573 | clearInterval(sampleTimer); |
| 1552 | 1574 | clearInterval(stillTimer); |
| 1575 | clearInterval(sensorTimer); | |
| 1553 | 1576 | readBacks?.(); |
| 1554 | 1577 | arrived?.(); |
| 1555 | 1578 | 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("<b>x</b>"); | |
| 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 @@ | ||
| 49 | 49 | * 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, |
| 50 | 50 | * 409 while a run is under way or anything drives, 423 on E-stop or hold; the result joins GET /precision's tunings |
| 51 | 51 | * 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 | |
| 52 | 53 | * 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 |
| 53 | 54 | * POST /grow/pump the same, with an interval schedule {"onMinutes": 5, "offMinutes": 25} |
| 54 | 55 | * 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"; | ||
| 100 | 101 | import { Metrics } from "./metrics.ts"; |
| 101 | 102 | import { wallMs } from "./clock.ts"; |
| 102 | 103 | import { readPiHealth } from "./pi-health.ts"; |
| 104 | import { loadSensors, Sensors } from "./sensors.ts"; | |
| 103 | 105 | import { DEVICES, Light, LightError, type DeviceName } from "./light.ts"; |
| 104 | 106 | import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts"; |
| 105 | 107 | import { deployedCommit, PrecisionHistory, TRACE_HZ } from "./precision-history.ts"; |
| @@ -139,6 +141,10 @@ export interface ServerOptions { | ||
| 139 | 141 | lightMs?: number; |
| 140 | 142 | /** The grow light and the pump, for tests that give them a fake switch. */ |
| 141 | 143 | 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; | |
| 142 | 148 | /** How long each sweep target is held, ms (1500); tests shorten it. */ |
| 143 | 149 | tuningHoldMs?: number; |
| 144 | 150 | /** 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 = {}): | ||
| 241 | 247 | } |
| 242 | 248 | const metrics = new Metrics(join(dataDir, "metrics.jsonl")); |
| 243 | 249 | 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")); | |
| 244 | 251 | const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit }); |
| 245 | 252 | /** The stream's precision tracker while one drives; every Goal_Position write starts its bus-to-motion clock. */ |
| 246 | 253 | let tracking: Tracker | null = null; |
| @@ -962,7 +969,9 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 962 | 969 | } |
| 963 | 970 | } |
| 964 | 971 | 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() }); | |
| 966 | 975 | case "POST /grow/light": |
| 967 | 976 | case "POST /grow/pump": { |
| 968 | 977 | const device = lights[route.slice("POST /grow/".length) as DeviceName]; |
| @@ -1781,6 +1790,8 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1781 | 1790 | if (gainTimer) void gainSync.tick(); |
| 1782 | 1791 | const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined; |
| 1783 | 1792 | 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(); | |
| 1784 | 1795 | const address = http.address(); |
| 1785 | 1796 | const port = typeof address === "object" && address ? address.port : options.port ?? 8101; |
| 1786 | 1797 | return { |
| @@ -1790,6 +1801,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1790 | 1801 | clearInterval(watching); |
| 1791 | 1802 | clearInterval(gainTimer); |
| 1792 | 1803 | clearInterval(lightTimer); |
| 1804 | clearInterval(sensorTimer); | |
| 1793 | 1805 | machine.close(); |
| 1794 | 1806 | precision.close(); |
| 1795 | 1807 | bus.onSyncWrite = null; |
Software/so101/src/terminal.ts +3 −1
| @@ -66,6 +66,7 @@ import { ID, NAME, sign } from "./identity.ts"; | ||
| 66 | 66 | import { readVia, signedInText, type Via } from "./signed-in.ts"; |
| 67 | 67 | import { readServos, type ServoPanel } from "./servo-panel.ts"; |
| 68 | 68 | import { readGrow } from "./light-read.ts"; |
| 69 | import { sensorText, type SensorView } from "./sensors.ts"; | |
| 69 | 70 | import { isCycle } from "./light-schedule.ts"; |
| 70 | 71 | import type { DeviceName, LightView } from "./light.ts"; |
| 71 | 72 | import { readTuning, tuningSentence, type TuningView } from "./tuning-panel.ts"; |
| @@ -307,7 +308,7 @@ export type Grow = { error: string | null; lines: string[] }; | ||
| 307 | 308 | export type ReadGrow = () => Promise<Grow>; |
| 308 | 309 | |
| 309 | 310 | /** 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[] { | |
| 311 | 312 | const lines: string[] = []; |
| 312 | 313 | for (const v of [grow.light, grow.pump]) { |
| 313 | 314 | if (!v) continue; |
| @@ -318,6 +319,7 @@ export function growLines(grow: { light: LightView; pump: LightView | null }): s | ||
| 318 | 319 | if (v.error) lines.push(` the switch failed: ${plain(v.error)}`); |
| 319 | 320 | 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})`); |
| 320 | 321 | } |
| 322 | for (const v of grow.sensors ?? []) lines.push(plain(sensorText(v, now))); | |
| 321 | 323 | return lines; |
| 322 | 324 | } |
| 323 | 325 |
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.