Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Grow light schedule: a switch driver (smart plug, GPIO relay, or uhubctl only when it switches the light alone) with a photoperiod that runs on the Pi #588
smith/arm:smith/582-grow-light-schedule-a-switch-driver-smar → main 8 commits fork
2 files
changed +47
−14
Software/so101/src/light-endpoint.test.ts +39 −12
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { afterEach, expect, test } from "bun:test"; |
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | import { connect } from "node:net"; | |
| 3 | 4 | import { tmpdir } from "node:os"; |
| 4 | 5 | import { join } from "node:path"; |
| 5 | 6 | import { FeetechBus } from "./bus.ts"; |
| @@ -83,17 +84,43 @@ test("the pump runs on its own switch and an interval cycle, apart from the ligh | ||
| 83 | 84 | expect((await post(base, { schedule: { onMinutes: 0, offMinutes: 5 } }, "pump")).status).toBe(400); |
| 84 | 85 | }); |
| 85 | 86 | |
| 86 | test("POST /grow/<device> caps the bytes it reads, whatever Content-Length says", async () => { | |
| 87 | const base = await boot({ on: false }); | |
| 88 | const chunk = new TextEncoder().encode(`{"x":"${"a".repeat(100_000)}`); | |
| 89 | let sent = 0; | |
| 90 | const body = new ReadableStream({ | |
| 91 | pull(controller) { | |
| 92 | if (sent++ < 15) controller.enqueue(chunk); // 1.5 MB with no Content-Length: chunked | |
| 93 | else controller.close(); | |
| 94 | }, | |
| 87 | /** Send a chunked body with no Content-Length over a raw socket until the server answers or hangs up; when the answer came, how much was taken and what it said. */ | |
| 88 | function flood(port: number, path: string, chunks: number): Promise<{ answeredMs: number; reply: string }> { | |
| 89 | return new Promise((resolve) => { | |
| 90 | const socket = connect(port, "127.0.0.1"); | |
| 91 | const chunk = Buffer.from(`${(100_000).toString(16)}\r\n{"x":"${"a".repeat(99_994)}\r\n`); | |
| 92 | const started = Date.now(); | |
| 93 | let written = 0; | |
| 94 | let reply = ""; | |
| 95 | let answeredMs = -1; | |
| 96 | socket.on("data", (d) => { | |
| 97 | reply += d; | |
| 98 | if (answeredMs < 0) answeredMs = Date.now() - started; | |
| 99 | setTimeout(() => socket.destroy(), 50); // the answer is all that was wanted | |
| 100 | }); | |
| 101 | socket.on("error", () => {}); | |
| 102 | socket.on("close", () => resolve({ answeredMs, reply })); | |
| 103 | setTimeout(() => socket.destroy(), 2000); // a server that never answers | |
| 104 | socket.write(`POST ${path} HTTP/1.1\r\nhost: x\r\ntransfer-encoding: chunked\r\n\r\n`); | |
| 105 | const pump = () => { | |
| 106 | while (written < chunks * 100_000 && !socket.destroyed) { | |
| 107 | written += 100_000; | |
| 108 | if (!socket.write(chunk)) return void socket.once("drain", pump); | |
| 109 | } | |
| 110 | }; | |
| 111 | pump(); | |
| 95 | 112 | }); |
| 96 | const r = await fetch(`${base}/grow/light`, { method: "POST", body, duplex: "half" } as RequestInit).catch(() => null); | |
| 97 | if (r) expect(r.status).toBe(413); | |
| 98 | expect(((await (await fetch(`${base}/grow`)).json()) as { light: { configured: boolean } }).light.configured).toBe(false); | |
| 113 | } | |
| 114 | ||
| 115 | test("POST /grow/<device> stops reading at the cap, whatever Content-Length says, answers 413, and changes nothing", async () => { | |
| 116 | const base = await boot({ on: false }); | |
| 117 | const port = Number(new URL(base).port); | |
| 118 | for (const device of ["light", "pump"]) { | |
| 119 | const { answeredMs, reply } = await flood(port, `/grow/${device}`, 40); // 4 MB offered, 1 MB allowed | |
| 120 | expect(reply).toContain("413"); | |
| 121 | expect(answeredMs).toBeGreaterThanOrEqual(0); | |
| 122 | expect(answeredMs).toBeLessThan(1000); // answered at the cap, not after the whole body | |
| 123 | const grow = (await (await fetch(`${base}/grow`)).json()) as Record<string, { configured: boolean }>; | |
| 124 | expect(grow[device]!.configured).toBe(false); | |
| 125 | } | |
| 99 | 126 | }); |
Software/so101/src/server.ts +8 −2
| @@ -596,7 +596,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 596 | 596 | req.on("data", (c) => { |
| 597 | 597 | data += c; |
| 598 | 598 | if (data.length > MAX_BODY) { |
| 599 | req.destroy(); | |
| 599 | // Stop reading and say so: destroying the request here left the client waiting for an answer that was never sent. | |
| 600 | req.removeAllListeners("data"); | |
| 601 | req.pause(); | |
| 602 | data = ""; | |
| 600 | 603 | reject(new HttpError(413, "body too large")); |
| 601 | 604 | } |
| 602 | 605 | }); |
| @@ -1293,7 +1296,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1293 | 1296 | } |
| 1294 | 1297 | json(res, 404, { error: "no such route" }); |
| 1295 | 1298 | } catch (err) { |
| 1296 | if (err instanceof HttpError) return json(res, err.status, { error: err.message }); | |
| 1299 | if (err instanceof HttpError) { | |
| 1300 | if (err.status === 413) res.setHeader("connection", "close"); // the rest of the body is unread: end the connection after the answer | |
| 1301 | return json(res, err.status, { error: err.message }); | |
| 1302 | } | |
| 1297 | 1303 | json(res, 500, { error: err instanceof Error ? err.message : String(err) }); |
| 1298 | 1304 | } |
| 1299 | 1305 | }); |
new above
offline