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

Closed opened this in answer to
Closed without merging

smith/arm:smith/582-grow-light-schedule-a-switch-driver-smar → main 8 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
2 files changed +47 −14
Software/so101/src/light-endpoint.test.ts +39 −12
@@ -1,5 +1,6 @@
11 import { afterEach, expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
3 import { connect } from "node:net";
34 import { tmpdir } from "node:os";
45 import { join } from "node:path";
56 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
8384 expect((await post(base, { schedule: { onMinutes: 0, offMinutes: 5 } }, "pump")).status).toBe(400);
8485 });
8586
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();
95112 });
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 }
99126 });
Software/so101/src/server.ts +8 −2
@@ -596,7 +596,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
596596 req.on("data", (c) => {
597597 data += c;
598598 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 = "";
600603 reject(new HttpError(413, "body too large"));
601604 }
602605 });
@@ -1293,7 +1296,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
12931296 }
12941297 json(res, 404, { error: "no such route" });
12951298 } 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 }
12971303 json(res, 500, { error: err instanceof Error ? err.message : String(err) });
12981304 }
12991305 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs