Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Grow stills at 1920×1080, cropped to the camera's region and colour-corrected (split from #584) #603
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
vesper/arm:vesper/589-grow-stills-at-1920-1080-cropped-to-the → main 1 commit fork
13 files
changed +585
−33
Software/README.md +3 −1
| @@ -1407,4 +1407,6 @@ The methods behind the vision kit, so you can read the originals: | ||
| 1407 | 1407 | model follows; [](https://threejs.org) and |
| 1408 | 1408 | [](https://github.com/gkjohnson/urdf-loaders) for the 3D view; |
| 1409 | 1409 | the [](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) |
| 1410 | for the leader arm in the browser; [](https://github.com/pikvm/ustreamer) for the cameras. | |
| 1410 | for the leader arm in the browser; [](https://github.com/pikvm/ustreamer) for the cameras; | |
| 1411 | [](https://github.com/jpeg-js/jpeg-js) (pure JavaScript, no dependencies of its own) to crop and | |
| 1412 | colour-correct the grow box's stills on the relay, which has no other way to decode a JPEG. |
Software/so101/README.md +10 −1
| @@ -93,7 +93,16 @@ The app is a workshop: one Pi, several **stations**, each with its cameras and c | ||
| 93 | 93 | |
| 94 | 94 | The hosted app takes a still from the grow station's first camera every 10 minutes (`GROW_STILL_MINUTES`, 1 to 1440) and keeps it under `FORGE_APP_DATA/grow-stills`: 30 days, and no more than `GROW_STILLS_CAP_MB` (500) in all, the oldest going first. A still taken while the light is off is flagged dark and left out of the timelapse. The card plays today, the last 7 days or all of it, scrubbable, built in the browser from the stills (nothing is encoded). Under it: day N since the planted date, the owner's notes, light hours per day from the light's transitions, and "canopy (green area)" per day, the share of green pixels inside the box drawn in Tune. That is a measure of green area, not of health; the owner's browser works it out for stills that have none and sends it back. |
| 95 | 95 | |
| 96 | `GET /grow/stills?from=&to=&after=&limit=&dark=1` pages stills (`{stills, next, count, bytes, capBytes}`), `GET /grow/stills/<ms>.jpg` one still, `GET /grow/log` the log and chart numbers; `POST /grow/log {planted?, note?}` and `POST /grow/canopy {readings}` are the owner's, and same-origin only. The chart is empty until the owner has opened the card once, since only that browser works out the readings. Without JavaScript the card shows the latest still and a link to `/grow/log`. From the terminal: `/grow log` reads the log, `/grow planted <YYYY-MM-DD>|none` and `/grow note <text>` (owner) write it through the same routes; the card shows a note on its next load. | |
| 96 | **Full size, cropped, corrected.** When the grow camera is one of the Pi's (Tune shows its unit, `so101-camera@<n>`) and the arm is online, each still is grabbed at 1920×1080 (or the camera's nearest size), not taken from the stream. ustreamer holds the camera, so the arm server pauses that camera's stream for the grab: `POST /camera-still/<n>` stops `so101-camera@<n>`, takes one MJPEG frame with `v4l2-ctl` after 15 frames to settle, and starts the stream again whatever happened (src/camera-still.ts). The stream comes back at its own resolution, 640×360 on the grow camera, after a gap of about two seconds every ten minutes. The relay then crops the still to the box drawn on Tune, with `cropRect(look.region, width, height)` on the still's own size, and keeps that crop. When the camera has colour gains (*Measure colour* on Tune), a copy with the gains applied is kept beside it, and the card shows that copy with "corrected" beside its time. The **Corrected** button switches back to the raw colours. The raw still is never corrected; with no region it is the camera's JPEG byte for byte. Decoding a 1080p JPEG takes about half a second, so the relay does it in a worker (src/grow-frame.ts) and pages are not held up. The codec is jpeg-js, the one dependency this added: pure JavaScript with none of its own, since neither Bun nor Node can decode a JPEG. If the Pi cannot grab a still (no unit, arm offline, no sudo rule), the stream's own snapshot is used and cropped the same way. The pause needs the arm server's user to be allowed to stop and start the camera units, and nothing else: | |
| 97 | ||
| 98 | sh | |
| 99 | echo 'so101 ALL=(root) NOPASSWD: /usr/bin/systemctl stop [email protected], /usr/bin/systemctl start [email protected], /usr/bin/systemctl stop [email protected], /usr/bin/systemctl start [email protected]' \ | |
| 100 | | sudo tee /etc/sudoers.d/so101-camera-still && sudo chmod 440 /etc/sudoers.d/so101-camera-still && sudo visudo -c | |
| 101 | ||
| 102 | ||
| 103 | (Replace `so101` with the user `so101.service` runs as.) The white photo light around each still (#589's optional part) is not done: the grow light's switch has no photo light yet. | |
| 104 | ||
| 105 | `GET /grow/stills?from=&to=&after=&limit=&dark=1` pages stills (`{stills, next, count, bytes, capBytes}`; each still says `cropped` and `corrected`), `GET /grow/stills/<ms>.jpg` one still, `GET /grow/stills/<ms>-corrected.jpg` its corrected copy, `GET /grow/log` the log and chart numbers; `POST /grow/log {planted?, note?}` and `POST /grow/canopy {readings}` are the owner's, and same-origin only. The chart is empty until the owner has opened the card once, since only that browser works out the readings. Without JavaScript the card shows the latest still and a link to `/grow/log`. From the terminal: `/grow log` reads the log, `/grow planted <YYYY-MM-DD>|none` and `/grow note <text>` (owner) write it through the same routes; the card shows a note on its next load. | |
| 97 | 106 | |
| 98 | 107 | ## The story page: /story |
| 99 | 108 |
Software/so101/bun.lock +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | "": { |
| 6 | 6 | "name": "so101", |
| 7 | 7 | "dependencies": { |
| 8 | "jpeg-js": "^0.4.4", | |
| 8 | 9 | "serialport": "^13", |
| 9 | 10 | "three": "^0.186.1", |
| 10 | 11 | "urdf-loader": "^0.13.1", |
| @@ -96,6 +97,8 @@ | ||
| 96 | 97 | |
| 97 | 98 | "debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], |
| 98 | 99 | |
| 100 | "jpeg-js": ["[email protected]", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], | |
| 101 | ||
| 99 | 102 | "ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], |
| 100 | 103 | |
| 101 | 104 | "node-addon-api": ["[email protected]", "", {}, "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg=="], |
Software/so101/package.json +1
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | "test": "bun test" |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| 16 | "jpeg-js": "^0.4.4", | |
| 16 | 17 | "serialport": "^13", |
| 17 | 18 | "three": "^0.186.1", |
| 18 | 19 | "urdf-loader": "^0.13.1", |
Software/so101/src/camera-still.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * The Pi's side of a grow still at full size. ustreamer holds the camera, so a 1920×1080 still pauses the stream: | |
| 3 | * stop so101-camera@n, grab one MJPEG frame at 1920×1080 with `v4l2-ctl`, start the stream again. The stream comes | |
| 4 | * back at its own --resolution (640×360 on the grow camera), and it is started again however the grab went. | |
| 5 | * | |
| 6 | * A camera without 1920×1080 gets its nearest size from the driver; the relay reads the size from the JPEG, so the | |
| 7 | * crop still lands on the region. Every command is an argument list built from a checked unit number and the device | |
| 8 | * path from /etc/so101/camera<n>, never a shell. Stopping and starting the unit needs sudo for exactly those two | |
| 9 | * commands (the README has the sudoers lines); `sudo -n` fails at once rather than waiting for a password. | |
| 10 | */ | |
| 11 | import { execFile } from "node:child_process"; | |
| 12 | import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { parseUnit } from "./camera-control.ts"; | |
| 16 | ||
| 17 | export const STILL_WIDTH = 1920; | |
| 18 | export const STILL_HEIGHT = 1080; | |
| 19 | /** Frames thrown away while the camera settles after it opens: the first ones of a webcam are dark or soft. */ | |
| 20 | export const SETTLE_FRAMES = 15; | |
| 21 | ||
| 22 | /** Runs one command; resolves with its error text, or "" when it worked. */ | |
| 23 | export type Run = (command: string, args: string[], timeoutMs: number) => Promise<string>; | |
| 24 | ||
| 25 | export const run: Run = (command, args, timeoutMs) => | |
| 26 | new Promise((resolve) => { | |
| 27 | execFile(command, args, { timeout: timeoutMs }, (err, _stdout, stderr) => { | |
| 28 | if (!err) return resolve(""); | |
| 29 | const code = (err as NodeJS.ErrnoException).code; | |
| 30 | resolve(code === "ENOENT" ? `${command} is not installed` : (stderr || err.message).trim().split("\n")[0]!.slice(0, 200)); | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 34 | export class StillError extends Error { | |
| 35 | /** Another still was being taken: try again later, nothing is wrong with the camera. */ | |
| 36 | readonly busy: boolean; | |
| 37 | constructor(message: string, busy = false) { | |
| 38 | super(message); | |
| 39 | this.busy = busy; | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | /** The commands, in order: pause the stream, grab into `file`, start the stream. */ | |
| 44 | export function stillCommands(unit: number, device: string, file: string): { stop: [string, string[]]; grab: [string, string[]]; start: [string, string[]] } { | |
| 45 | const service = `so101-camera@${parseUnit(unit)}.service`; | |
| 46 | return { | |
| 47 | stop: ["sudo", ["-n", "systemctl", "stop", service]], | |
| 48 | grab: ["v4l2-ctl", ["-d", device, `--set-fmt-video=width=${STILL_WIDTH},height=${STILL_HEIGHT},pixelformat=MJPG`, "--stream-mmap=3", `--stream-skip=${SETTLE_FRAMES}`, "--stream-count=1", `--stream-to=${file}`]], | |
| 49 | start: ["sudo", ["-n", "systemctl", "start", service]], | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | let busy = false; | |
| 54 | ||
| 55 | /** | |
| 56 | * One 1920×1080 JPEG from camera `unit` at `device`, the stream paused around it. Throws a StillError saying what | |
| 57 | * went wrong; the stream is started again either way. One at a time: a second call while one runs is refused. | |
| 58 | */ | |
| 59 | export async function grabStill(unit: number, device: string, exec: Run = run): Promise<Uint8Array> { | |
| 60 | if (busy) throw new StillError("a still is already being taken", true); | |
| 61 | busy = true; | |
| 62 | const dir = mkdtempSync(join(tmpdir(), "so101-still-")); | |
| 63 | const c = stillCommands(unit, device, join(dir, "still.jpg")); | |
| 64 | try { | |
| 65 | const stopped = await exec(...c.stop, 10_000); | |
| 66 | if (stopped) throw new StillError(`could not pause the stream: ${stopped}`); | |
| 67 | try { | |
| 68 | const grabbed = await exec(...c.grab, 10_000); | |
| 69 | if (grabbed) throw new StillError(`could not grab a still: ${grabbed}`); | |
| 70 | const bytes = new Uint8Array(readFileSync(join(dir, "still.jpg"))); | |
| 71 | if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new StillError("the camera gave no JPEG (is it set to MJPEG?)"); | |
| 72 | return bytes; | |
| 73 | } finally { | |
| 74 | const started = await exec(...c.start, 10_000); | |
| 75 | if (started) console.error(`camera still: could not start so101-camera@${unit} again: ${started}`); | |
| 76 | } | |
| 77 | } finally { | |
| 78 | busy = false; | |
| 79 | rmSync(dir, { recursive: true, force: true }); | |
| 80 | } | |
| 81 | } |
Software/so101/src/cli.ts +3 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { closeSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 2 | 2 | import { dirname } from "node:path"; |
| 3 | 3 | import { applyProfile, cameraDevice, loadProfile, parseUnit, profileFile } from "./camera-control.ts"; |
| 4 | import { grabStill } from "./camera-still.ts"; | |
| 4 | 5 | import { defaultProfile, parseDevice } from "./camera-profile.ts"; |
| 5 | 6 | import { parseArgs } from "node:util"; |
| 6 | 7 | import { SerialPort } from "serialport"; |
| @@ -534,7 +535,8 @@ const COMMANDS: Record<string, Command> = { | ||
| 534 | 535 | return withBus(values, async (bus) => { |
| 535 | 536 | const { startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks |
| 536 | 537 | const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile); |
| 537 | const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, lightMs: 15_000 }); | |
| 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 }); | |
| 538 | 540 | console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`); |
| 539 | 541 | console.log("ctrl-c to stop"); |
| 540 | 542 | await new Promise<void>((resolve) => process.once("SIGINT", () => resolve())); |
Software/so101/src/grow-frame-worker.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | /** The worker src/grow-frame.ts runs `finishStill` in, so a 1080p decode never holds up the relay's pages. */ | |
| 2 | import { parentPort } from "node:worker_threads"; | |
| 3 | import { finishStill, type StillLook } from "./grow-frame.ts"; | |
| 4 | ||
| 5 | parentPort?.once("message", ({ bytes, look }: { bytes: Uint8Array; look: StillLook }) => { | |
| 6 | try { | |
| 7 | parentPort!.postMessage({ ok: true, out: finishStill(bytes, look) }); | |
| 8 | } catch (err) { | |
| 9 | parentPort!.postMessage({ ok: false, error: (err as Error).message }); | |
| 10 | } | |
| 11 | }); |
Software/so101/src/grow-frame.test.ts added +235
| @@ -0,0 +1,235 @@ | ||
| 1 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { createServer, type Server } from "node:http"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import jpeg from "jpeg-js"; | |
| 7 | import { FeetechBus } from "./bus.ts"; | |
| 8 | import { CameraHub } from "./cameras.ts"; | |
| 9 | import { grabStill, STILL_HEIGHT, STILL_WIDTH, StillError, stillCommands, type Run } from "./camera-still.ts"; | |
| 10 | import { FakeBus } from "./fake-bus.ts"; | |
| 11 | import { finishStill, finishStillApart, jpegSize } from "./grow-frame.ts"; | |
| 12 | import { GrowLog } from "./grow-log.ts"; | |
| 13 | import { GrowStills } from "./grow-stills.ts"; | |
| 14 | import { startRelay, type RunningRelay } from "./relay.ts"; | |
| 15 | import { startServer, type RunningServer } from "./server.ts"; | |
| 16 | import { StationHub } from "./stations.ts"; | |
| 17 | import { canopyRegion, stillView } from "./ui/grow-timelapse.js"; | |
| 18 | ||
| 19 | const dirs: string[] = []; | |
| 20 | const tmp = () => { | |
| 21 | const d = mkdtempSync(join(tmpdir(), "grow-frame-")); | |
| 22 | dirs.push(d); | |
| 23 | return d; | |
| 24 | }; | |
| 25 | let relay: RunningRelay | undefined; | |
| 26 | let server: RunningServer | undefined; | |
| 27 | let camera: Server | undefined; | |
| 28 | afterEach(async () => { | |
| 29 | await relay?.close(); | |
| 30 | await server?.close(); | |
| 31 | camera?.close(); | |
| 32 | relay = server = camera = undefined; | |
| 33 | dirs.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); | |
| 34 | }); | |
| 35 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 36 | ||
| 37 | /** | |
| 38 | * The fixture still: 192×108 (a tenth of 1920×1080). The left half is grey, the right half is the box: plants | |
| 39 | * (green) on soil, under a grow light that leaves the red channel weak. | |
| 40 | */ | |
| 41 | const W = 192; | |
| 42 | const H = 108; | |
| 43 | function fixture(): Uint8Array<ArrayBuffer> { | |
| 44 | const data = new Uint8Array(W * H * 4); | |
| 45 | for (let y = 0; y < H; y++) { | |
| 46 | for (let x = 0; x < W; x++) { | |
| 47 | const i = (y * W + x) * 4; | |
| 48 | const box = x >= W / 2; | |
| 49 | data.set(box ? (y < H / 2 ? [30, 120, 90, 255] : [40, 60, 100, 255]) : [128, 128, 128, 255], i); | |
| 50 | } | |
| 51 | } | |
| 52 | return Uint8Array.from(jpeg.encode({ data, width: W, height: H }, 95).data); | |
| 53 | } | |
| 54 | /** The mean of each channel of a JPEG. */ | |
| 55 | function means(bytes: Uint8Array): { r: number; g: number; b: number; width: number; height: number } { | |
| 56 | const { data, width, height } = jpeg.decode(bytes, { useTArray: true, formatAsRGBA: true }); | |
| 57 | let [r, g, b] = [0, 0, 0]; | |
| 58 | for (let i = 0; i < data.length; i += 4) (r += data[i]!), (g += data[i + 1]!), (b += data[i + 2]!); | |
| 59 | const n = data.length / 4; | |
| 60 | return { r: r / n, g: g / n, b: b / n, width, height }; | |
| 61 | } | |
| 62 | const RIGHT_HALF = { x: 0.5, y: 0, w: 0.5, h: 1 }; | |
| 63 | const GAINS = { r: 2, g: 1, b: 0.8 }; | |
| 64 | ||
| 65 | describe("a still is cropped and corrected", () => { | |
| 66 | test("cropped to the region by cropRect on its own size; the corrected copy has the gains, the raw one does not", () => { | |
| 67 | const still = fixture(); | |
| 68 | const before = still.slice(); | |
| 69 | const out = finishStill(still, { region: RIGHT_HALF, gains: GAINS }); | |
| 70 | expect(out).toMatchObject({ cropped: true, width: W, height: H }); | |
| 71 | const raw = means(out.raw); | |
| 72 | const fixed = means(out.corrected!); | |
| 73 | expect([raw.width, raw.height]).toEqual([W / 2, H]); | |
| 74 | expect([fixed.width, fixed.height]).toEqual([W / 2, H]); | |
| 75 | // The box alone: none of the grey half is in the crop (its red would lift the mean past 50). | |
| 76 | expect(raw.r).toBeGreaterThan(30); | |
| 77 | expect(raw.r).toBeLessThan(40); | |
| 78 | expect(fixed.r / raw.r).toBeCloseTo(2, 1); | |
| 79 | expect(fixed.g / raw.g).toBeCloseTo(1, 1); | |
| 80 | expect(fixed.b / raw.b).toBeCloseTo(0.8, 1); | |
| 81 | expect(still).toEqual(before); | |
| 82 | }); | |
| 83 | ||
| 84 | test("with no region the raw still is the camera's bytes, untouched; gains alone still give a corrected copy", () => { | |
| 85 | const still = fixture(); | |
| 86 | const plain = finishStill(still, { region: null, gains: null }); | |
| 87 | expect(plain.raw).toBe(still); | |
| 88 | expect(plain).toMatchObject({ corrected: null, cropped: false, width: W, height: H }); | |
| 89 | const gained = finishStill(still, { region: null, gains: GAINS }); | |
| 90 | expect(gained.raw).toBe(still); | |
| 91 | expect(gained.cropped).toBe(false); | |
| 92 | expect(means(gained.corrected!).r / means(still).r).toBeCloseTo(2, 1); | |
| 93 | }); | |
| 94 | ||
| 95 | test("the size comes from the JPEG's header, and a still that is not one is refused", () => { | |
| 96 | expect(jpegSize(fixture())).toEqual({ width: W, height: H }); | |
| 97 | expect(jpegSize(new Uint8Array([0xff, 0xd8, 0xff, 0xd9]))).toBeNull(); | |
| 98 | expect(finishStill(new Uint8Array([0xff, 0xd8, 1, 2, 3]), { region: null, gains: null })).toMatchObject({ width: 0, height: 0, cropped: false }); | |
| 99 | expect(() => finishStill(new Uint8Array([0xff, 0xd8, 1, 2, 3]), { region: RIGHT_HALF, gains: null })).toThrow(); | |
| 100 | }); | |
| 101 | ||
| 102 | test("in a worker it gives the same still, and a bad one is an error, not a hang", async () => { | |
| 103 | const still = fixture(); | |
| 104 | const apart = await finishStillApart(still, { region: RIGHT_HALF, gains: GAINS }); | |
| 105 | const here = finishStill(still, { region: RIGHT_HALF, gains: GAINS }); | |
| 106 | expect(new Uint8Array(apart.raw)).toEqual(Uint8Array.from(here.raw)); | |
| 107 | expect(new Uint8Array(apart.corrected!)).toEqual(Uint8Array.from(here.corrected!)); | |
| 108 | await expect(finishStillApart(new Uint8Array([0xff, 0xd8, 1, 2]), { region: RIGHT_HALF, gains: null })).rejects.toThrow(); | |
| 109 | }); | |
| 110 | }); | |
| 111 | ||
| 112 | describe("the store keeps the corrected copy beside the raw still", () => { | |
| 113 | test("both are read back as given, counted together, flagged, and go together", () => { | |
| 114 | const dir = tmp(); | |
| 115 | const s = new GrowStills(dir, { keepMs: 60_000 }); | |
| 116 | const out = finishStill(fixture(), { region: RIGHT_HALF, gains: GAINS }); | |
| 117 | const T0 = 1_760_000_000_000; | |
| 118 | s.add(out.raw, T0, false, { cropped: true, corrected: out.corrected }); | |
| 119 | expect(s.read(T0)).toEqual(out.raw); | |
| 120 | expect(s.read(T0, true)).toEqual(out.corrected!); | |
| 121 | expect(s.page({}).stills).toEqual([{ at: T0, dark: false, cropped: true, corrected: true, bytes: out.raw.length + out.corrected!.length }]); | |
| 122 | s.add(fixture(), T0 + 120_000, false); | |
| 123 | expect(s.read(T0 + 120_000, true)).toBeNull(); | |
| 124 | expect(s.page({}).stills.map((x) => [x.at, x.cropped, x.corrected])).toEqual([[T0 + 120_000, false, false]]); | |
| 125 | expect(readdirSync(dir).filter((f) => f.endsWith(".jpg"))).toEqual([`${T0 + 120_000}.jpg`]); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a corrected copy that is not a JPEG is refused, and nothing is written", () => { | |
| 129 | const dir = tmp(); | |
| 130 | expect(() => new GrowStills(dir).add(fixture(), 1_760_000_000_000, false, { corrected: new Uint8Array([1, 2, 3, 4]) })).toThrow(); | |
| 131 | expect(readdirSync(dir)).toEqual([]); | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 135 | describe("the card", () => { | |
| 136 | test("shows the corrected copy labelled so, unless turned off; a cropped still's canopy is not cropped again", () => { | |
| 137 | expect(stillView({ at: 5, corrected: true }, true)).toEqual({ src: "/grow/stills/5-corrected.jpg", label: "corrected" }); | |
| 138 | expect(stillView({ at: 5, corrected: true }, false)).toEqual({ src: "/grow/stills/5.jpg", label: "" }); | |
| 139 | expect(stillView({ at: 5 }, true)).toEqual({ src: "/grow/stills/5.jpg", label: "" }); | |
| 140 | expect(canopyRegion({ cropped: true }, RIGHT_HALF)).toBeNull(); | |
| 141 | expect(canopyRegion({ cropped: false }, RIGHT_HALF)).toBe(RIGHT_HALF); | |
| 142 | }); | |
| 143 | }); | |
| 144 | ||
| 145 | describe("a 1920×1080 still on the Pi, the stream paused around it", () => { | |
| 146 | const DEV = "/dev/v4l/by-id/usb-XWF-1080P-video-index0"; | |
| 147 | /** A fake runner: records each command, and writes `still` where v4l2-ctl was told to. */ | |
| 148 | const runner = (still: Uint8Array | null, fail: Partial<Record<"stop" | "grab" | "start", string>> = {}) => { | |
| 149 | const ran: string[] = []; | |
| 150 | const exec: Run = async (command, args) => { | |
| 151 | const step = command === "v4l2-ctl" ? "grab" : (args[2] as "stop" | "start"); | |
| 152 | ran.push(step); | |
| 153 | if (step === "grab" && still && !fail.grab) writeFileSync(args.find((a) => a.startsWith("--stream-to="))!.slice("--stream-to=".length), still); | |
| 154 | return fail[step] ?? ""; | |
| 155 | }; | |
| 156 | return { ran, exec }; | |
| 157 | }; | |
| 158 | ||
| 159 | test("stops the camera's service, grabs one MJPEG frame at 1920×1080 after it settles, and starts it again", async () => { | |
| 160 | const c = stillCommands(2, DEV, "/x/still.jpg"); | |
| 161 | expect(c.stop).toEqual(["sudo", ["-n", "systemctl", "stop", "[email protected]"]]); | |
| 162 | expect(c.start).toEqual(["sudo", ["-n", "systemctl", "start", "[email protected]"]]); | |
| 163 | expect(c.grab[1]).toContain(`--set-fmt-video=width=${STILL_WIDTH},height=${STILL_HEIGHT},pixelformat=MJPG`); | |
| 164 | expect(c.grab[1]).toContain("--stream-count=1"); | |
| 165 | expect(() => stillCommands(9, DEV, "/x")).toThrow(); | |
| 166 | const still = fixture(); | |
| 167 | const { ran, exec } = runner(still); | |
| 168 | expect(await grabStill(2, DEV, exec)).toEqual(still); | |
| 169 | expect(ran).toEqual(["stop", "grab", "start"]); | |
| 170 | }); | |
| 171 | ||
| 172 | test("the stream is started again when the grab fails or gives no JPEG; one still at a time", async () => { | |
| 173 | const failed = runner(null, { grab: "Device or resource busy" }); | |
| 174 | await expect(grabStill(1, DEV, failed.exec)).rejects.toThrow(/could not grab a still: Device or resource busy/); | |
| 175 | expect(failed.ran).toEqual(["stop", "grab", "start"]); | |
| 176 | const junk = runner(new Uint8Array([1, 2, 3, 4, 5])); | |
| 177 | await expect(grabStill(1, DEV, junk.exec)).rejects.toThrow(/no JPEG/); | |
| 178 | expect(junk.ran).toEqual(["stop", "grab", "start"]); | |
| 179 | const noSudo = runner(fixture(), { stop: "sudo: a password is required" }); | |
| 180 | await expect(grabStill(1, DEV, noSudo.exec)).rejects.toThrow(/could not pause the stream/); | |
| 181 | expect(noSudo.ran).toEqual(["stop"]); | |
| 182 | let release = () => {}; | |
| 183 | const slow: Run = (_c, args) => (args[2] === "stop" ? new Promise((r) => (release = () => r(""))) : Promise.resolve("")); | |
| 184 | const first = grabStill(1, DEV, slow).catch((e: Error) => e); | |
| 185 | const second = await grabStill(1, DEV, slow).catch((e: StillError) => e); | |
| 186 | expect(second).toBeInstanceOf(StillError); | |
| 187 | expect((second as StillError).busy).toBe(true); | |
| 188 | release(); | |
| 189 | await first; | |
| 190 | }); | |
| 191 | ||
| 192 | test("the arm server answers POST /camera-still/<n> with the JPEG; the simulation says it has no camera", async () => { | |
| 193 | const still = fixture(); | |
| 194 | const units: number[] = []; | |
| 195 | const data = tmp(); | |
| 196 | server = await startServer(new FeetechBus(new FakeBus([1])), { host: "127.0.0.1", port: 0, ids: [1], posesFile: join(data, "poses.json"), cameraStill: async (unit) => (units.push(unit), still) }); | |
| 197 | const base = `http://127.0.0.1:${server.port}`; | |
| 198 | const got = await fetch(`${base}/camera-still/2`, { method: "POST" }); | |
| 199 | expect(got.headers.get("content-type")).toBe("image/jpeg"); | |
| 200 | expect(new Uint8Array(await got.arrayBuffer())).toEqual(still); | |
| 201 | expect(units).toEqual([2]); | |
| 202 | expect((await fetch(`${base}/camera-still/2`)).status).toBe(405); | |
| 203 | expect((await fetch(`${base}/camera-still/9`, { method: "POST" })).status).toBe(400); | |
| 204 | await server.close(); | |
| 205 | server = await startServer(new FeetechBus(new FakeBus([1])), { host: "127.0.0.1", port: 0, ids: [1], posesFile: join(data, "poses.json") }); | |
| 206 | expect((await fetch(`http://127.0.0.1:${server.port}/camera-still/1`, { method: "POST" })).status).toBe(501); | |
| 207 | }); | |
| 208 | }); | |
| 209 | ||
| 210 | test("the relay keeps each still cropped to the camera's region, with a corrected copy at -corrected.jpg", async () => { | |
| 211 | const dir = tmp(); | |
| 212 | const still = fixture(); | |
| 213 | camera = createServer((_req, res) => { | |
| 214 | res.writeHead(200, { "content-type": "image/jpeg" }); | |
| 215 | res.end(still); | |
| 216 | }); | |
| 217 | await new Promise<void>((r) => camera!.listen(0, "127.0.0.1", r)); | |
| 218 | const port = (camera.address() as { port: number }).port; | |
| 219 | const stations = new StationHub(); | |
| 220 | stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [1], controls: [] }]); | |
| 221 | const cameras = new CameraHub({ seed: [{ name: "Grow cam", source: `http://127.0.0.1:${port}/stream` }] }); | |
| 222 | cameras.setLook(1, { region: RIGHT_HALF, gains: GAINS }); | |
| 223 | const stills = new GrowStills(join(dir, "stills")); | |
| 224 | relay = await startRelay({ port: 0, simulation: "http://127.0.0.1:1", cameras, stations, grow: { stills, log: new GrowLog(join(dir, "log.json")), everyMs: 60 }, env: {} }); | |
| 225 | for (const end = Date.now() + 5000; stills.usage().count < 1 && Date.now() < end; ) await sleep(20); | |
| 226 | const base = `http://127.0.0.1:${relay.port}`; | |
| 227 | const page = (await (await fetch(`${base}/grow/stills`)).json()) as { stills: { at: number; cropped: boolean; corrected: boolean }[] }; | |
| 228 | expect(page.stills[0]).toMatchObject({ cropped: true, corrected: true }); | |
| 229 | const at = page.stills[0]!.at; | |
| 230 | const raw = means(new Uint8Array(await (await fetch(`${base}/grow/stills/${at}.jpg`)).arrayBuffer())); | |
| 231 | const corrected = means(new Uint8Array(await (await fetch(`${base}/grow/stills/${at}-corrected.jpg`)).arrayBuffer())); | |
| 232 | expect([raw.width, raw.height, corrected.width, corrected.height]).toEqual([W / 2, H, W / 2, H]); | |
| 233 | expect(corrected.r / raw.r).toBeCloseTo(2, 1); | |
| 234 | expect((await fetch(`${base}/grow/stills/1234567890-corrected.jpg`)).status).toBe(404); | |
| 235 | }); |
Software/so101/src/grow-frame.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * A grow still made ready to keep: cropped to the camera's region (`cropRect` on the still's own size, 1920×1080 when | |
| 3 | * the Pi could grab one) and, when the camera has gains, a colour-corrected copy beside it (`applyGains`). The raw | |
| 4 | * still is never corrected; with no region it is the camera's bytes untouched. The maths is src/ui/still-frame.js; | |
| 5 | * the JPEG codec is jpeg-js (pure JS, no dependencies of its own). | |
| 6 | * | |
| 7 | * Decoding and encoding a 1080p frame takes about half a second, so the relay runs `finishStill` in a worker | |
| 8 | * (src/grow-frame-worker.ts) through `finishStillApart`, and the pages it serves meanwhile are not held up. | |
| 9 | */ | |
| 10 | import { Worker } from "node:worker_threads"; | |
| 11 | import jpeg from "jpeg-js"; | |
| 12 | import { applyGains, cropRect, type Gains, type Rect } from "./ui/still-frame.js"; | |
| 13 | ||
| 14 | /** What the still is cropped and corrected with: the camera's look (src/camera-look.ts). */ | |
| 15 | export interface StillLook { | |
| 16 | region: Rect | null; | |
| 17 | gains: Gains | null; | |
| 18 | } | |
| 19 | ||
| 20 | export interface Finished { | |
| 21 | /** The still to keep: cropped when the camera has a region, its colours as the camera gave them. */ | |
| 22 | raw: Uint8Array; | |
| 23 | /** The same pixels with the camera's gains applied, labelled "corrected"; null when the camera has none. */ | |
| 24 | corrected: Uint8Array | null; | |
| 25 | /** True when `raw` is the region alone, so nothing downstream crops it again. */ | |
| 26 | cropped: boolean; | |
| 27 | /** The size the camera gave, before the crop (0×0 when it was kept as it came and its header names none). */ | |
| 28 | width: number; | |
| 29 | height: number; | |
| 30 | } | |
| 31 | ||
| 32 | /** Quality of a re-encoded still: high, since a timelapse is a still looked at many times. */ | |
| 33 | export const QUALITY = 90; | |
| 34 | /** Bigger than any webcam still: a decode is refused past this rather than allowed to eat the relay's memory. */ | |
| 35 | const MAX_MP = 16; | |
| 36 | ||
| 37 | /** The width and height in a JPEG's frame header, without decoding it; null when there is none. */ | |
| 38 | export function jpegSize(bytes: Uint8Array): { width: number; height: number } | null { | |
| 39 | let i = 2; | |
| 40 | while (i + 9 < bytes.length) { | |
| 41 | if (bytes[i] !== 0xff) return null; | |
| 42 | const marker = bytes[i + 1]!; | |
| 43 | if (marker === 0xff) { | |
| 44 | i++; | |
| 45 | continue; | |
| 46 | } | |
| 47 | const length = (bytes[i + 2]! << 8) | bytes[i + 3]!; | |
| 48 | // SOF0 to SOF15, leaving out DHT (C4), JPG (C8) and DAC (CC), which share the range. | |
| 49 | if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { | |
| 50 | return { height: (bytes[i + 5]! << 8) | bytes[i + 6]!, width: (bytes[i + 7]! << 8) | bytes[i + 8]! }; | |
| 51 | } | |
| 52 | i += 2 + length; | |
| 53 | } | |
| 54 | return null; | |
| 55 | } | |
| 56 | ||
| 57 | /** One JPEG still and the camera's look → the still to keep and, with gains, its corrected copy. Throws on a JPEG it cannot read. */ | |
| 58 | export function finishStill(bytes: Uint8Array, look: StillLook): Finished { | |
| 59 | if (!look.region && !look.gains) return { raw: bytes, corrected: null, cropped: false, ...(jpegSize(bytes) ?? { width: 0, height: 0 }) }; | |
| 60 | const frame = jpeg.decode(bytes, { useTArray: true, formatAsRGBA: true, maxResolutionInMP: MAX_MP, maxMemoryUsageInMB: 256 }); | |
| 61 | const { width, height } = frame; | |
| 62 | const rect = cropRect(look.region, width, height); | |
| 63 | const whole = rect.x === 0 && rect.y === 0 && rect.w === width && rect.h === height; | |
| 64 | const pixels = whole ? frame.data : crop(frame.data, width, rect); | |
| 65 | const raw = whole ? bytes : new Uint8Array(jpeg.encode({ data: pixels, width: rect.w, height: rect.h }, QUALITY).data); | |
| 66 | let corrected: Uint8Array | null = null; | |
| 67 | if (look.gains) { | |
| 68 | const copy = new Uint8ClampedArray(pixels); | |
| 69 | applyGains(copy, look.gains); | |
| 70 | corrected = new Uint8Array(jpeg.encode({ data: copy, width: rect.w, height: rect.h }, QUALITY).data); | |
| 71 | } | |
| 72 | return { raw, corrected, cropped: !whole, width, height }; | |
| 73 | } | |
| 74 | ||
| 75 | /** The rows of `rect` out of an RGBA frame `width` pixels wide. */ | |
| 76 | function crop(data: Uint8Array, width: number, rect: Rect): Uint8Array { | |
| 77 | const out = new Uint8Array(rect.w * rect.h * 4); | |
| 78 | for (let row = 0; row < rect.h; row++) { | |
| 79 | const from = ((rect.y + row) * width + rect.x) * 4; | |
| 80 | out.set(data.subarray(from, from + rect.w * 4), row * rect.w * 4); | |
| 81 | } | |
| 82 | return out; | |
| 83 | } | |
| 84 | ||
| 85 | /** `finishStill` in a worker of its own, ended when it answers or after `timeoutMs`. */ | |
| 86 | export function finishStillApart(bytes: Uint8Array, look: StillLook, timeoutMs = 20_000): Promise<Finished> { | |
| 87 | if (!look.region && !look.gains) return Promise.resolve(finishStill(bytes, look)); | |
| 88 | return new Promise((resolve, reject) => { | |
| 89 | const worker = new Worker(new URL("./grow-frame-worker.ts", import.meta.url)); | |
| 90 | const timer = setTimeout(() => done(new Error("finishing the still took too long")), timeoutMs); | |
| 91 | let settled = false; | |
| 92 | const done = (err: Error | null, out?: Finished) => { | |
| 93 | if (settled) return; | |
| 94 | settled = true; | |
| 95 | clearTimeout(timer); | |
| 96 | void worker.terminate(); | |
| 97 | if (err) reject(err); | |
| 98 | else resolve(out!); | |
| 99 | }; | |
| 100 | worker.once("message", (m: { ok: true; out: Finished } | { ok: false; error: string }) => (m.ok ? done(null, m.out) : done(new Error(m.error)))); | |
| 101 | worker.once("error", (err: unknown) => done(err instanceof Error ? err : new Error(String(err)))); | |
| 102 | worker.postMessage({ bytes, look }); | |
| 103 | }); | |
| 104 | } |
Software/so101/src/grow-stills.ts +51 −19
| @@ -2,6 +2,10 @@ | ||
| 2 | 2 | * The grow box's stills: one JPEG per capture, kept for 30 days under a byte cap (the oldest goes first), each flagged |
| 3 | 3 | * when the light was off. A still's name is its time, so the store is its own index and survives any restart. |
| 4 | 4 | * The canopy reading of each still (src/ui/grow-canopy.js, worked out in the browser) is kept beside them. |
| 5 | * | |
| 6 | * Names: `<ms>.jpg`, with `d` before the dot when it is dark and `k` when it is cropped to the camera's region | |
| 7 | * (src/grow-frame.ts), so the browser does not crop it again. A colour-corrected copy is the same name with `c` | |
| 8 | * before the dot; it goes with its still. The raw still is never overwritten by the corrected one. | |
| 5 | 9 | */ |
| 6 | 10 | import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; |
| 7 | 11 | import { join } from "node:path"; |
| @@ -19,7 +23,12 @@ export interface Still { | ||
| 19 | 23 | at: number; |
| 20 | 24 | /** The light was off: the picture is of a dark box. */ |
| 21 | 25 | dark: boolean; |
| 26 | /** Both files' bytes, the corrected copy's too. */ | |
| 22 | 27 | bytes: number; |
| 28 | /** Cropped to the camera's region when it was taken; older stills are the whole frame. */ | |
| 29 | cropped: boolean; | |
| 30 | /** A colour-corrected copy is kept beside it, at /grow/stills/<at>-corrected.jpg. */ | |
| 31 | corrected: boolean; | |
| 23 | 32 | /** Green-pixel fraction of the owner's region, 0 to 1, once the browser has worked it out. */ |
| 24 | 33 | green?: number; |
| 25 | 34 | } |
| @@ -30,7 +39,9 @@ export interface Page { | ||
| 30 | 39 | next: number | null; |
| 31 | 40 | } |
| 32 | 41 | |
| 33 | const NAME = /^(\d{10,15})(d?)\.jpg$/; | |
| 42 | const NAME = /^(\d{10,15})(d?)(k?)(c?)\.jpg$/; | |
| 43 | ||
| 44 | type Kept = { at: number; dark: boolean; cropped: boolean }; | |
| 34 | 45 | |
| 35 | 46 | /** "TEN_MIN" env text → ms between stills, within bounds; the default for anything else. */ |
| 36 | 47 | export function parseEvery(text: string | undefined): number { |
| @@ -64,30 +75,46 @@ export class GrowStills { | ||
| 64 | 75 | } |
| 65 | 76 | |
| 66 | 77 | private files(): Still[] { |
| 67 | const out: Still[] = []; | |
| 78 | const raw = new Map<number, Still>(); | |
| 79 | const copies = new Map<number, number>(); | |
| 68 | 80 | for (const name of readdirSync(this.dir)) { |
| 69 | 81 | const m = NAME.exec(name); |
| 70 | 82 | if (!m) continue; |
| 71 | 83 | try { |
| 72 | 84 | const at = Number(m[1]); |
| 73 | const green = this.green.get(at); | |
| 74 | out.push({ at, dark: m[2] === "d", bytes: statSync(join(this.dir, name)).size, ...(green === undefined ? {} : { green }) }); | |
| 85 | const bytes = statSync(join(this.dir, name)).size; | |
| 86 | if (m[4] === "c") copies.set(at, bytes); | |
| 87 | else { | |
| 88 | const green = this.green.get(at); | |
| 89 | raw.set(at, { at, dark: m[2] === "d", bytes, cropped: m[3] === "k", corrected: false, ...(green === undefined ? {} : { green }) }); | |
| 90 | } | |
| 75 | 91 | } catch { |
| 76 | 92 | /* removed while we looked */ |
| 77 | 93 | } |
| 78 | 94 | } |
| 79 | return out.sort((a, b) => a.at - b.at); | |
| 95 | for (const [at, bytes] of copies) { | |
| 96 | const s = raw.get(at); | |
| 97 | if (s) (s.bytes += bytes), (s.corrected = true); | |
| 98 | } | |
| 99 | return [...raw.values()].sort((a, b) => a.at - b.at); | |
| 80 | 100 | } |
| 81 | 101 | |
| 82 | private path(s: { at: number; dark: boolean }): string { | |
| 83 | return join(this.dir, `${s.at}${s.dark ? "d" : ""}.jpg`); | |
| 102 | private path(s: Kept, corrected = false): string { | |
| 103 | return join(this.dir, `${s.at}${s.dark ? "d" : ""}${s.cropped ? "k" : ""}${corrected ? "c" : ""}.jpg`); | |
| 84 | 104 | } |
| 85 | 105 | |
| 86 | /** Keep a still, then drop what is past its days and, oldest first, what is past the cap. */ | |
| 87 | add(jpeg: Uint8Array, at: number, dark: boolean): void { | |
| 88 | if (!Number.isSafeInteger(at) || at < 1e9 || jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) throw new Error("not a JPEG still"); | |
| 89 | writeFileSync(`${this.path({ at, dark })}.tmp`, jpeg); | |
| 90 | renameSync(`${this.path({ at, dark })}.tmp`, this.path({ at, dark })); | |
| 106 | /** | |
| 107 | * Keep a still (and, when given, its colour-corrected copy), then drop what is past its days and, oldest first, | |
| 108 | * what is past the cap. `cropped` says the still is the camera's region alone. | |
| 109 | */ | |
| 110 | add(jpeg: Uint8Array, at: number, dark: boolean, o: { cropped?: boolean; corrected?: Uint8Array | null } = {}): void { | |
| 111 | const isJpeg = (b: Uint8Array) => b.length >= 4 && b[0] === 0xff && b[1] === 0xd8; | |
| 112 | if (!Number.isSafeInteger(at) || at < 1e9 || !isJpeg(jpeg) || (o.corrected && !isJpeg(o.corrected))) throw new Error("not a JPEG still"); | |
| 113 | const kept = { at, dark, cropped: o.cropped === true }; | |
| 114 | const write = (file: string, bytes: Uint8Array) => (writeFileSync(`${file}.tmp`, bytes), renameSync(`${file}.tmp`, file)); | |
| 115 | // The copy first: a still never shows as corrected before its copy is there. | |
| 116 | if (o.corrected) write(this.path(kept, true), o.corrected); | |
| 117 | write(this.path(kept), jpeg); | |
| 91 | 118 | this.prune(at); |
| 92 | 119 | } |
| 93 | 120 | |
| @@ -99,10 +126,12 @@ export class GrowStills { | ||
| 99 | 126 | const gone: Still[] = []; |
| 100 | 127 | for (const s of all) { |
| 101 | 128 | if (s.at >= cutoff && total <= this.capBytes) break; |
| 102 | try { | |
| 103 | unlinkSync(this.path(s)); | |
| 104 | } catch { | |
| 105 | /* already gone */ | |
| 129 | for (const file of s.corrected ? [this.path(s, true), this.path(s)] : [this.path(s)]) { | |
| 130 | try { | |
| 131 | unlinkSync(file); | |
| 132 | } catch { | |
| 133 | /* already gone */ | |
| 134 | } | |
| 106 | 135 | } |
| 107 | 136 | total -= s.bytes; |
| 108 | 137 | gone.push(s); |
| @@ -137,10 +166,13 @@ export class GrowStills { | ||
| 137 | 166 | return { count: all.length, bytes: all.reduce((n, s) => n + s.bytes, 0), capBytes: this.capBytes }; |
| 138 | 167 | } |
| 139 | 168 | |
| 140 | read(at: number): Uint8Array | null { | |
| 169 | /** A still's bytes, or its colour-corrected copy's; null when there is none. */ | |
| 170 | read(at: number, corrected = false): Uint8Array | null { | |
| 141 | 171 | for (const dark of [false, true]) { |
| 142 | const file = this.path({ at, dark }); | |
| 143 | if (existsSync(file)) return readFileSync(file); | |
| 172 | for (const cropped of [true, false]) { | |
| 173 | const file = this.path({ at, dark, cropped }, corrected); | |
| 174 | if (existsSync(file)) return readFileSync(file); | |
| 175 | } | |
| 144 | 176 | } |
| 145 | 177 | return null; |
| 146 | 178 | } |
Software/so101/src/relay.ts +29 −7
| @@ -108,6 +108,7 @@ import { CameraError, CameraHub } from "./cameras.ts"; | ||
| 108 | 108 | import { ARM_ONLY, StationError, StationHub } from "./stations.ts"; |
| 109 | 109 | import { renderWorkshop, type StationView } from "./workshop-page.ts"; |
| 110 | 110 | import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts"; |
| 111 | import { finishStillApart } from "./grow-frame.ts"; | |
| 111 | 112 | import { canopyByDay, dayNumber, GrowLog, GrowLogError, lightHours } from "./grow-log.ts"; |
| 112 | 113 | import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts"; |
| 113 | 114 | import { VideoFeed, type VideoSources } from "./video-feed.ts"; |
| @@ -960,9 +961,9 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 960 | 961 | const v = url.searchParams.get(name); |
| 961 | 962 | return v === null || !/^\d{1,15}$/.test(v) ? undefined : Number(v); |
| 962 | 963 | }; |
| 963 | const still = /^\/grow\/stills\/(\d{10,15})\.jpg$/.exec(url.pathname); | |
| 964 | const still = /^\/grow\/stills\/(\d{10,15})(-corrected)?\.jpg$/.exec(url.pathname); | |
| 964 | 965 | if (req.method === "GET" && still) { |
| 965 | const bytes = grow.stills.read(Number(still[1])); | |
| 966 | const bytes = grow.stills.read(Number(still[1]), still[2] !== undefined); | |
| 966 | 967 | if (!bytes) return json(res, 404, { error: "no such still" }), true; |
| 967 | 968 | res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "public, max-age=31536000, immutable", "content-length": String(bytes.length) }); |
| 968 | 969 | res.end(bytes); |
| @@ -1005,18 +1006,39 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1005 | 1006 | return false; |
| 1006 | 1007 | }; |
| 1007 | 1008 | |
| 1008 | /** Take one still of the grow box's camera, flagged dark when the light is off. Quiet on failure: a missed still is a gap, not an error. */ | |
| 1009 | /** | |
| 1010 | * Take one still of the grow box's camera, flagged dark when the light is off. A camera that is one of the Pi's is | |
| 1011 | * grabbed at 1920×1080 by the arm server (its stream paused for it, src/camera-still.ts); otherwise, or when that | |
| 1012 | * fails, the stream's own snapshot is taken. The still is cropped to the camera's region and, with gains, a corrected | |
| 1013 | * copy kept beside it (src/grow-frame.ts, in a worker). Quiet on failure: a missed still is a gap, not an error. | |
| 1014 | */ | |
| 1009 | 1015 | const takeStill = async () => { |
| 1010 | 1016 | const grow = options.grow; |
| 1011 | 1017 | const growStation = stations.list().find((st) => st.kind === "grow"); |
| 1012 | 1018 | const id = growStation?.cameras[0]; |
| 1013 | 1019 | const source = id === undefined ? null : cameras.source(id); |
| 1014 | if (!grow || source === null || restingNow()) return; | |
| 1020 | if (!grow || id === undefined || source === null || restingNow()) return; | |
| 1021 | const at = Date.now(); | |
| 1022 | const look = cameras.look(id); | |
| 1023 | const read = async (answer: Response) => { | |
| 1024 | const bytes = new Uint8Array(await answer.arrayBuffer()); | |
| 1025 | return answer.ok && bytes.length < 8_000_000 ? bytes : null; | |
| 1026 | }; | |
| 1015 | 1027 | try { |
| 1016 | 1028 | const light = (await lightHooks.read())?.light; |
| 1017 | const answer = await fetch(source.replace(/\/[^/]*$/, "/snapshot"), { signal: AbortSignal.timeout(Math.min(readTimeoutMs, 5000)) }); | |
| 1018 | const bytes = new Uint8Array(await answer.arrayBuffer()); | |
| 1019 | if (answer.ok && bytes.length < 8_000_000) grow.stills.add(bytes, Date.now(), light?.configured === true && light.on === false); | |
| 1029 | const dark = light?.configured === true && light.on === false; | |
| 1030 | let bytes: Uint8Array | null = null; | |
| 1031 | if (look?.unit && remote && onArm()) { | |
| 1032 | bytes = await fetch(`${remote.http}/camera-still/${look.unit}`, { method: "POST", signal: AbortSignal.timeout(20_000) }) | |
| 1033 | .then(read) | |
| 1034 | .catch(() => null); | |
| 1035 | } | |
| 1036 | bytes ??= await read(await fetch(source.replace(/\/[^/]*$/, "/snapshot"), { signal: AbortSignal.timeout(Math.min(readTimeoutMs, 5000)) })); | |
| 1037 | if (!bytes) return; | |
| 1038 | const done = await finishStillApart(bytes, { region: look?.region ?? null, gains: look?.gains ?? null }).catch(() => null); | |
| 1039 | // A still that will not decode is kept whole and uncorrected: a picture is still better than a gap. | |
| 1040 | if (done) grow.stills.add(done.raw, at, dark, { cropped: done.cropped, corrected: done.corrected }); | |
| 1041 | else grow.stills.add(bytes, at, dark); | |
| 1020 | 1042 | } catch { |
| 1021 | 1043 | /* the camera or the Pi did not answer: no still this time */ |
| 1022 | 1044 | } |
Software/so101/src/server.ts +24
| @@ -33,6 +33,8 @@ | ||
| 33 | 33 | * PUT /camera-profile/<n>[?try=1] {"exposure"?, "whiteBalance"?, "gain"?, "focus"?, "backlight"?, "brightness"?, "contrast"?, "saturation"?} put it on so101-camera@n's |
| 34 | 34 | * camera now, one control at a time, and keep it for the camera service's next start (?try=1: only put it on); |
| 35 | 35 | * {"saved", "set", "failed"}. Without camera control (the simulation) nothing runs: 501, and nothing is kept |
| 36 | * POST /camera-still/<n> one 1920×1080 JPEG from so101-camera@n's camera, its stream paused for the grab (src/camera-still.ts); | |
| 37 | * 409 while another is taken, 502 when the grab failed, 501 in the simulation | |
| 36 | 38 | * GET /settings the safe zone: {"zone": "enforce"|"warn"|"off", "floorMarginMm", "baseMarginMm", "jointOffsetsDeg"}, |
| 37 | 39 | * and the safety limits of src/safety.ts (speed, acceleration, rate, dead-man, park, gripper force, joint limits) |
| 38 | 40 | * PUT /settings change any of those; "servoGains" takes partial per-joint patches ({"2": {"p": 80}}), joints 1-5, 0-254 |
| @@ -87,6 +89,7 @@ import { WebSocketServer, type WebSocket } from "ws"; | ||
| 87 | 89 | import { browserModule } from "./browser-modules.ts"; |
| 88 | 90 | import { defaultProfile, parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts"; |
| 89 | 91 | import { loadProfile, parseUnit, profileFile, saveProfile, type Applied } from "./camera-control.ts"; |
| 92 | import { StillError } from "./camera-still.ts"; | |
| 90 | 93 | import { Clock, CommandGate, HELD_CLOSE, isRelease, sentAtOf } from "./command-gate.ts"; |
| 91 | 94 | import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts"; |
| 92 | 95 | import type { FeetechBus } from "./bus.ts"; |
| @@ -152,6 +155,8 @@ export interface ServerOptions { | ||
| 152 | 155 | watchQuietMs?: number; |
| 153 | 156 | /** Puts a profile on so101-camera@<unit>'s camera (src/camera-control.ts); only `so101 serve` on the Pi gives one, so the simulation never runs v4l2-ctl. */ |
| 154 | 157 | cameraControl?: (unit: number, profile: CameraProfile) => Promise<Applied>; |
| 158 | /** Grabs one full-size still from so101-camera@<unit>, pausing its stream (src/camera-still.ts); only on the Pi. */ | |
| 159 | cameraStill?: (unit: number) => Promise<Uint8Array>; | |
| 155 | 160 | } |
| 156 | 161 | |
| 157 | 162 | export interface RunningServer { |
| @@ -883,6 +888,25 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 883 | 888 | if (keep) saveProfile(profileFile(dataDir, unit), profile); |
| 884 | 889 | return json(res, 200, { saved: keep, ...applied }); |
| 885 | 890 | } |
| 891 | const cameraStill = /^\/camera-still\/([^/]*)$/.exec(url.pathname); | |
| 892 | if (cameraStill) { | |
| 893 | if (req.method !== "POST") throw new HttpError(405, "POST /camera-still/<n>"); | |
| 894 | let unit: number; | |
| 895 | try { | |
| 896 | unit = parseUnit(cameraStill[1]); | |
| 897 | } catch (err) { | |
| 898 | throw new HttpError(400, (err as Error).message); | |
| 899 | } | |
| 900 | if (!options.cameraStill) return json(res, 501, { error: "no camera here: this is the simulation, not the Pi" }); | |
| 901 | let bytes: Uint8Array; | |
| 902 | try { | |
| 903 | bytes = await options.cameraStill(unit); | |
| 904 | } catch (err) { | |
| 905 | return json(res, err instanceof StillError && err.busy ? 409 : 502, { error: (err as Error).message }); | |
| 906 | } | |
| 907 | res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "no-store", "content-length": String(bytes.length) }); | |
| 908 | return res.end(bytes); | |
| 909 | } | |
| 886 | 910 | if (req.method === "PUT" && url.pathname.startsWith("/calibration/")) { |
| 887 | 911 | const kind = url.pathname.slice("/calibration/".length); |
| 888 | 912 | if (!/^[a-z][a-z0-9_-]{0,31}$/.test(kind)) throw new HttpError(400, "calibration kind: lowercase letters, digits, - and _"); |
Software/so101/src/ui/grow-timelapse.js +30 −4
| @@ -2,6 +2,8 @@ | ||
| 2 | 2 | /** |
| 3 | 3 | * The grow card's timelapse, log and canopy chart, built in the browser from the stills the relay keeps |
| 4 | 4 | * (GET /grow/stills, /grow/stills/<at>.jpg, /grow/log): no video is encoded anywhere. Plain ESM served at /ui/. |
| 5 | * A still with a colour-corrected copy (/grow/stills/<at>-corrected.jpg) is shown corrected, labelled so, unless | |
| 6 | * the "Corrected" button is turned off. | |
| 5 | 7 | * The page has `[data-grow]` holding the camera's box as `data-region` JSON and `data-owner="1"` for the owner. |
| 6 | 8 | */ |
| 7 | 9 | import { greenFraction } from "./grow-canopy.js"; |
| @@ -35,6 +37,23 @@ export function chartPoints(days, width, height, pad = 6) { | ||
| 35 | 37 | .join(" "); |
| 36 | 38 | } |
| 37 | 39 | |
| 40 | /** | |
| 41 | * Where a still is drawn from, and the label beside its time: its corrected copy when it has one and that is wanted. | |
| 42 | * @param {{ at: number, corrected?: boolean }} still | |
| 43 | * @param {boolean} wantCorrected | |
| 44 | */ | |
| 45 | export function stillView(still, wantCorrected) { | |
| 46 | const corrected = wantCorrected && still.corrected === true; | |
| 47 | return { src: `/grow/stills/${still.at}${corrected ? "-corrected" : ""}.jpg`, label: corrected ? "corrected" : "" }; | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * The region a still's canopy is counted in: none for a still already cropped to it. | |
| 52 | * @param {{ cropped?: boolean }} still | |
| 53 | * @param {unknown} region | |
| 54 | */ | |
| 55 | export const canopyRegion = (still, region) => (still.cropped ? null : region); | |
| 56 | ||
| 38 | 57 | /** @param {string} url @returns {Promise<any>} */ |
| 39 | 58 | const get = async (url) => { |
| 40 | 59 | const res = await fetch(url, { cache: "no-store" }); |
| @@ -73,7 +92,7 @@ async function measure(stills, region) { | ||
| 73 | 92 | const g = canvas.getContext("2d"); |
| 74 | 93 | g.drawImage(img, 0, 0, canvas.width, canvas.height); |
| 75 | 94 | const { data } = g.getImageData(0, 0, canvas.width, canvas.height); |
| 76 | found.push({ at: s.at, green: greenFraction(data, canvas.width, canvas.height, region) }); | |
| 95 | found.push({ at: s.at, green: greenFraction(data, canvas.width, canvas.height, canopyRegion(s, region)) }); | |
| 77 | 96 | } catch { |
| 78 | 97 | // A still that will not decode is skipped: it has no reading, which is the honest answer. |
| 79 | 98 | } |
| @@ -107,9 +126,10 @@ export async function growCard(root) { | ||
| 107 | 126 | const slider = el("input", undefined, { type: "range", min: "0", max: "0", value: "0", "aria-label": "Scrub the timelapse" }); |
| 108 | 127 | const when = el("output", ""); |
| 109 | 128 | const play = el("button", "Play", { type: "button" }); |
| 129 | const fix = el("button", "Corrected", { type: "button", "aria-pressed": "true", hidden: "", title: "Show the stills with the camera's colour correction" }); | |
| 110 | 130 | const buttons = Object.entries(RANGES).map(([key, label]) => el("button", label, { type: "button", "data-range": key })); |
| 111 | 131 | const bar = el("div", undefined, { class: "controls" }); |
| 112 | bar.append(...buttons, play); | |
| 132 | bar.append(...buttons, play, fix); | |
| 113 | 133 | const lapse = el("div", undefined, { class: "lapse" }); |
| 114 | 134 | lapse.append(bar, view, slider, when); |
| 115 | 135 | const log = el("div", undefined, { class: "growlog" }); |
| @@ -121,8 +141,9 @@ export async function growCard(root) { | ||
| 121 | 141 | const show = (i) => { |
| 122 | 142 | const s = stills[i]; |
| 123 | 143 | if (!s) return; |
| 124 | view.src = `/grow/stills/${s.at}.jpg`; | |
| 125 | when.textContent = new Date(s.at).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); | |
| 144 | const v = stillView(s, fix.getAttribute("aria-pressed") === "true"); | |
| 145 | view.src = v.src; | |
| 146 | when.textContent = new Date(s.at).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) + (v.label ? ` · ${v.label}` : ""); | |
| 126 | 147 | slider.value = String(i); |
| 127 | 148 | }; |
| 128 | 149 | const stop = () => { |
| @@ -137,11 +158,16 @@ export async function growCard(root) { | ||
| 137 | 158 | timer = setInterval(() => (Number(slider.value) + 1 >= stills.length ? stop() : show(Number(slider.value) + 1)), 150); |
| 138 | 159 | }); |
| 139 | 160 | slider.addEventListener("input", () => (stop(), show(Number(slider.value)))); |
| 161 | fix.addEventListener("click", () => { | |
| 162 | fix.setAttribute("aria-pressed", String(fix.getAttribute("aria-pressed") !== "true")); | |
| 163 | show(Number(slider.value)); | |
| 164 | }); | |
| 140 | 165 | const pick = async (range) => { |
| 141 | 166 | stop(); |
| 142 | 167 | for (const b of buttons) b.setAttribute("aria-pressed", String(b.dataset.range === range)); |
| 143 | 168 | stills = await load(rangeStart(range)); |
| 144 | 169 | slider.max = String(Math.max(0, stills.length - 1)); |
| 170 | fix.hidden = !stills.some((s) => s.corrected); | |
| 145 | 171 | if (!stills.length) { |
| 146 | 172 | view.removeAttribute("src"); |
| 147 | 173 | when.textContent = "No stills in this range yet."; |