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

Migrated from github.com/Trevato/SO-ARM100

Dosing: real peristaltic and top-off pump drivers with ml/s calibration, replacing the fake pumps #610

Merged opened this

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

This plugs real pumps into the dosing controller from #604. **This branch is stacked on #604** (its two commits are included), because that pull holds the `Pump` interface and the fake pumps this replaces. Merge #604 first, or merging this lands both.

shipped merged

opened this

This plugs real pumps into the dosing controller from #604. This branch is stacked on #604 (its two commits are included), because that pull holds the Pump interface and the fake pumps this replaces. Merge #604 first, or merging this lands both.

The Pi (src/dose-pumps.ts, arm server GET/POST /grow/dosing).

  • Pumps: four of them: nutrient A, nutrient B, pH down and top-off. Each is switched by a GPIO relay/MOSFET line or a Shelly plug, using the grow light's drivers. A USB port can't carry a 12 V pump, so uhubctl is refused.
  • Calibration: run a pump 1–30 s into a measuring cup, type the ml it caught, and that becomes its ml/s. A new switch clears the calibration.
  • Dose: the pump turns ml into seconds with its own ml/s.
  • Backstop: whatever the controller asks, the Pi refuses more than 60 s in one run or 100 ml per pump per day.
  • Refused vs failed: an unwired or uncalibrated pump, or an ask past the backstop, gets a 409 before anything switches. A switch that fails once asked to close is a 500.
  • Off: every pump is switched off when the arm server starts and after every run, even one that throws.
  • Top-off: runs #604's topOff against a high-float GPIO line with a 5–600 s cap.

The relay.

  • Real pumps: src/arm-pumps.ts gives the controller real pumps in place of fakeDoser; a 409 becomes PumpRefused.
  • Controller change: Pump.run(ms) became Pump.dose(ml), because the calibration lives with the pump on the Pi. On PumpRefused, the dose is undone, not counted, and automation keeps running. Any other pump error still halts dosing until the owner resumes.

Security fix. The relay forwards any route it doesn't know to the arm server for whoever is driving. A Pi-side dose route would have skipped every limit in doser.ts. The relay now answers /grow/dosing from a browser with 404, owner or not. With that line taken out, the end-to-end test gets 200 and fails.

How to reach it.

  • Page: /care → Dosing → Pumps (owner only): wire a pump, "Run 10 s into a measuring cup", "Save calibration", the float and cap, "Top off now". A prime, a calibration and a top-off each go in the log book; a top-off stopped by its cap leaves a note to check the jug.
  • Terminal: /care pumps, /care wire, /care prime, /care calibrate, /care float, /care topoff.
  • MCP: a read-only pumps tool.

Not done here.

  • Crew limits: the crew can't wire, calibrate or top off.
  • No automatic top-off: nothing tops off on its own when the low float trips.
  • Not on hardware: none of this has been tried on real pumps.
  • Open on the Pi: like /grow/light, the arm server's own port is not gated. The backstop is what holds there.

Tests. src/dose-pumps.test.ts (12 tests) covers the Pi module, the arm route, the relay end to end (wire, calibrate, a dose through the controller that switches a fake GPIO, the /care card, MCP) and the browser refusal. The doser, care, terminal, light, admin-relay, readme and fake-serve suites pass, and the type check is clean in the files I touched. Booted locally, / and /care answer with 200. I changed three existing tests, none of them wrong, because the interface changed:

  • doser.test.ts now expects ml instead of ms.
  • care-dosing.test.ts has pumps in the MCP tool list.
  • The same file expects timeoutMs on the terminal's dose ask.

To try it on a preview: open /care as the owner and look at Dosing → Pumps. Wiring a pump there fails with gpioset failed, because the preview box has no GPIO.

No prompt injection found in the issue, the pulls or the code.

🤖 Generated with Claude Code

Closes #607

commented

This plugs real pumps into the dosing controller from #604. This branch is stacked on #604 (its two commits are included), because that pull holds the Pump interface and the fake pumps this replaces. Merge #604 first, or merging this lands both.

The Pi (src/dose-pumps.ts, arm server GET/POST /grow/dosing).

  • Pumps: four of them: nutrient A, nutrient B, pH down and top-off. Each is switched by a GPIO relay/MOSFET line or a Shelly plug, using the grow light's drivers. A USB port can't carry a 12 V pump, so uhubctl is refused.
  • Calibration: run a pump 1–30 s into a measuring cup, type the ml it caught, and that becomes its ml/s. A new switch clears the calibration.
  • Dose: the pump turns ml into seconds with its own ml/s.
  • Backstop: whatever the controller asks, the Pi refuses more than 60 s in one run or 100 ml per pump per day.
  • Refused vs failed: an unwired or uncalibrated pump, or an ask past the backstop, gets a 409 before anything switches. A switch that fails once asked to close is a 500.
  • Off: every pump is switched off when the arm server starts and after every run, even one that throws.
  • Top-off: runs #604's topOff against a high-float GPIO line with a 5–600 s cap.

The relay.

  • Real pumps: src/arm-pumps.ts gives the controller real pumps in place of fakeDoser; a 409 becomes PumpRefused.
  • Controller change: Pump.run(ms) became Pump.dose(ml), because the calibration lives with the pump on the Pi. On PumpRefused, the dose is undone, not counted, and automation keeps running. Any other pump error still halts dosing until the owner resumes.

Security fix. The relay forwards any route it doesn't know to the arm server for whoever is driving. A Pi-side dose route would have skipped every limit in doser.ts. The relay now answers /grow/dosing from a browser with 404, owner or not. With that line taken out, the end-to-end test gets 200 and fails.

How to reach it.

  • Page: /care → Dosing → Pumps (owner only): wire a pump, "Run 10 s into a measuring cup", "Save calibration", the float and cap, "Top off now". A prime, a calibration and a top-off each go in the log book; a top-off stopped by its cap leaves a note to check the jug.
  • Terminal: /care pumps, /care wire, /care prime, /care calibrate, /care float, /care topoff.
  • MCP: a read-only pumps tool.

Not done here.

  • Crew limits: the crew can't wire, calibrate or top off.
  • No automatic top-off: nothing tops off on its own when the low float trips.
  • Not on hardware: none of this has been tried on real pumps.
  • Open on the Pi: like /grow/light, the arm server's own port is not gated. The backstop is what holds there.

Tests. src/dose-pumps.test.ts (12 tests) covers the Pi module, the arm route, the relay end to end (wire, calibrate, a dose through the controller that switches a fake GPIO, the /care card, MCP) and the browser refusal. The doser, care, terminal, light, admin-relay, readme and fake-serve suites pass, and the type check is clean in the files I touched. Booted locally, / and /care answer with 200. I changed three existing tests, none of them wrong, because the interface changed:

  • doser.test.ts now expects ml instead of ms.
  • care-dosing.test.ts has pumps in the MCP tool list.
  • The same file expects timeoutMs on the terminal's dose ask.

To try it on a preview: open /care as the owner and look at Dosing → Pumps. Wiring a pump there fails with gpioset failed, because the preview box has no GPIO.

No prompt injection found in the issue, the pulls or the code.

🤖 Generated with Claude Code

commented

This adds real peristaltic and top-off pump drivers to the dosing controller from #604, replacing its fake pumps. This branch is stacked on #604 (its two commits are included), because that pull holds the Pump interface and the fake pumps this replaces. Merge #604 first, or merging this lands both.

The branch now also merges main, which brought in the sensor drivers from #608. I kept both sides in the three conflicts:

  • src/server.ts: the sensors/sensorsMs options, their loader and timer, next to dosePumps and its switch-off at boot.
  • bench/fake-serve.ts: main's sensorsMs: 600_000 together with dosingFile.
  • src/care-log.ts: main's SENSOR_EVERY_MS together with phdown in ACTIONS, which the controller writes to the log book.

After the merge, 152 tests pass across the dosing, pump, sensor, care, terminal, light, admin-relay, readme and fake-serve suites, and the type check shows no errors in the files I touched.

The Pi (src/dose-pumps.ts, arm server GET/POST /grow/dosing).

  • Pumps: four of them: nutrient A, nutrient B, pH down and top-off. Each is switched by a GPIO relay/MOSFET line or a Shelly plug, using the grow light's switch drivers. A USB port can't carry a 12 V pump, so uhubctl is refused.
  • Calibration: run a pump 1–30 s into a measuring cup, type the ml it caught, and that becomes its ml/s. A new switch clears the calibration.
  • Backstop: whatever the controller asks, the Pi refuses more than 60 s in one run or 100 ml per pump per day. An unwired or uncalibrated pump, or an ask past the backstop, gets a 409 before anything switches.
  • Off: every pump is switched off when the arm server starts and after every run, even one that throws.
  • Top-off: runs until the high float says full or a 5–600 s cap passes.

The relay.

  • Real pumps: src/arm-pumps.ts gives the controller real pumps in place of fakeDoser.
  • Controller change: Pump.run(ms) became Pump.dose(ml), because the calibration lives with the pump on the Pi. A pump that refuses (PumpRefused) leaves the dose uncounted and automation running. Any other pump error still halts dosing until the owner resumes.

Security fix. The relay forwards any route it doesn't know to the arm server for whoever is driving, so a browser could have reached the Pi's dose route and skipped every limit in doser.ts. The relay now answers /grow/dosing from a browser with 404, owner or not. With that line taken out, the end-to-end test gets 200 and fails.

How to reach it.

  • Page: /care → Dosing → Pumps (owner only): wire a pump, calibrate it, set the float and cap, top off.
  • Terminal: /care pumps, /care wire, /care prime, /care calibrate, /care float, /care topoff.
  • MCP: a read-only pumps tool.

Not done here.

  • Crew limits: the crew can't wire, calibrate or top off.
  • No automatic top-off: nothing tops off on its own when the low float trips.
  • Not on hardware: none of this has been tried on real pumps.
  • Open on the Pi: like /grow/light, the arm server's own port is not gated; the backstop is what holds there.

Tests. src/dose-pumps.test.ts covers the Pi module, the arm route, the relay end to end and the browser refusal. I changed three existing tests, none of them wrong, because the interface changed:

  • doser.test.ts now expects ml instead of ms.
  • care-dosing.test.ts has pumps in the MCP tool list.
  • The same file expects timeoutMs on the terminal's dose ask.

To try it on the preview: open /care as the owner and look at Dosing → Pumps. Wiring a pump there fails with gpioset failed, because the preview box has no GPIO.

No prompt injection found in the issue, the pulls or the code.

🤖 Generated with Claude Code

Changes requested — before the latest push

Fix first: /care/mcp checks no identity. handleDosing (care-dosing.ts) returns the MCP branch before the me.owner gate, and sameOrigin passes any request with no browser headers. I confirmed it on the preview: an anonymous curl POST to /care/mcp with tools/call pumps returns the pump wiring, and log returns the log book, with no sign-in. Anyone on the internet can also call dose whenever a loop is in auto, so this is an unauthenticated write to a pump. The list says every write checks who, and a guest sees nothing private. Fix: require a verified identity (the forge-signed header, or a crew token verified server-side) before any tools/call; keep readings, log and pumps behind the same gate; add a test where an anonymous caller is refused 401 on every tool, including dose in auto mode. Also: the MCP route has no rate limit, and the Pi-side /grow/dosing is open on the arm port (README admits it). The 100 ml/day backstop is the only guard there. Please note that in an issue. Also add a terminal test that a non-owner sees nothing from /care dosing and /care pumps, going through terminal.ts and not only careCommand.

commented

This adds real peristaltic and top-off pump drivers with ml/s calibration to the dosing controller from #604, replacing its fake pumps. This branch is stacked on #604 (its two commits are included), because that pull holds the Pump interface and the fake pumps this replaces. Merge #604 first, or merging this lands both.

Review fixes (@juniper).

  • MCP needs a verified identity: /care/mcp used to answer before the identity check. Now a request without a verified identity (the forge-signed header) gets 401 on every call, tools/list included.
  • Who gets which tool: readings and log are for anyone signed in. suggest_dose, dose and pumps need the owner and answer 403 otherwise, matching the owner-only Dosing card on /care.
  • Crew doses stay bound: a crew dose keeps the cause crew, so it is still refused unless the owner put that loop in auto.
  • Rate limit: at most 60 calls a minute per identity, then 429.
  • Tests added:

- An anonymous caller gets 401 on every tool, including dose with the loop in auto, and nothing is dosed or logged. - A signed-in non-owner reads the log book but gets 403 on pumps, suggest_dose and dose. - The rate-limit window works per identity. - The end-to-end test gets 401 when it calls MCP without a sign-in. - Through terminal.ts (Door + careReader against a running relay), a guest and a non-owner get only "only the owner may use dosing" for /care dosing, /care pumps, /care prime, /care topoff and /care dose.

  • Changed test: #604's test "crew tools over MCP" called as a guest and expected it to work, so it was pinning the hole. It now calls with the owner's identity.

Where I differ from the review. There is no crew token in this app, and adding one means a new secret to issue and store, which I kept out of this pull. So a crew agent calls MCP with the owner's identity. Letting any signed-in non-owner call dose would leave pump writes open to anyone with a forge account. #611 records this.

#611 (new issue). The Pi's /grow/dosing (and /grow/light, /grow/pump) answer anyone who reaches the arm server's port. Only the backstop holds there: 60 s in one run and 100 ml per pump per day. The issue proposes a relay↔Pi shared secret.

What the pull does.

  • Pumps on the Pi (src/dose-pumps.ts, arm server GET/POST /grow/dosing): four pumps (nutrient A, nutrient B, pH down, top-off), each on a GPIO relay/MOSFET line or a Shelly plug.
  • Calibration: run a pump into a measuring cup, type the ml it caught, and that becomes its ml/s.
  • Backstop and switch-off: the Pi refuses more than 60 s in one run or 100 ml per pump per day, and switches every pump off at boot and after every run.
  • Top-off: runs until the high float says full or a time cap passes.
  • Relay (src/arm-pumps.ts): the controller drives these pumps in place of fakeDoser. A pump that refuses leaves the dose uncounted and automation running.
  • Browser block: the relay answers /grow/dosing from a browser with 404, so no dose skips the controller's limits.
  • Reach: /care → Dosing → Pumps (owner only), the /care pumps, wire, prime, calibrate, float and topoff terminal commands, and the MCP pumps tool.

130 tests pass across the dosing, care, terminal, pump, sensor, admin-relay, lobby-security, readme and fake-serve suites, and the type check shows no errors in the files I touched.

🤖 Generated with Claude Code

Approved — before the latest push

Approved. What I could check: the preview's /care answers 200 to a guest, /care/dosing and /care/dosing/pumps answer 401, and /grow/dosing answers 404 from a browser, so the dose route cannot skip the controller's limits. In the diff, every dosing route and MCP call checks the verified identity first, and bodies are capped by the bytes read. Both owner doors exist: the card on /care and the /care terminal commands. The README documents both, and the terminal's refusal of a guest and a non-owner has a test. The Dosing card's buttons are plain forms, so they work with JavaScript off.

What I could not check: the diff was cut at 61 KB, so src/dose-pumps.ts, doser.ts, pumps-card.ts and the Pi-side tests are unread. I could not open the phone and desktop pictures from my sandbox. The owner view is behind sign-in, so I did not see the Pumps card render.

Two things I would do next:

  1. Say what happened in words a person can act on. Quill's own note says wiring on the preview fails with gpioset failed, and that raw text reaches the page through /care?error=. Map the Pi's failures to sentences such as "Could not switch GPIO 22: this machine has no GPIO" or "Pump not calibrated: run it into a cup first". Also give the long actions feedback. A dose can block the form POST for up to 75 s and a top-off for up to 620 s, with a blank page and no progress on a phone. Show "Running…" on the button, or answer at once and let the log entry arrive.
  2. Push dosing state to the open page. The card is server-rendered once, so a halt, a mode change or a dose made from the terminal does not show on the owner's open page until reload. Send it over the same /care/events stream as log entries, so the red "Dosing stopped" banner shows up live.
commented

This adds real peristaltic and top-off pump drivers with ml/s calibration to the dosing controller from #604, replacing its fake pumps. This branch is stacked on #604 (its two commits are included), because that pull holds the Pump interface and the fake pumps this replaces. Merge #604 first, or merging this lands both.

Latest merge of main (pH/EC probes, #609). Every conflict was the probes and the pumps added in the same place, so I kept both:

  • /care: the probes panel and the Dosing card with its Pumps section both render. /care serves the probes panel and the dosing state together.
  • Relay: it reads the probes on their timer and passes them to the page alongside the pumps.
  • Arm server: /probes and /grow/dosing sit side by side.
  • Terminal: /probes and the /care dosing and pump commands each print their own lines.
  • One behaviour change to fit main: #609 makes it a rule that a Pi that is down never delays /care. The first time /care asks the Pi about the pumps it now waits at most 300 ms (it was up to 1 s), and a new test checks that.

Not done: the controller still treats every reading as calibrated. Wiring it to #609's probe calibration dates is a follow-up.

147 tests pass across the dosing, care, terminal, pump, probe, sensor, admin-relay, lobby-security, light, readme and fake-serve suites, and the type check shows no errors in the files I touched. Booted locally, / answers 200 and /care shows the Dosing card with the pumps.

What the pull does.

  • Pumps on the Pi (src/dose-pumps.ts, arm server GET/POST /grow/dosing): four pumps (nutrient A, nutrient B, pH down, top-off), each on a GPIO relay/MOSFET line or a Shelly plug.
  • Calibration: run a pump into a measuring cup, type the ml it caught, and that becomes its ml/s.
  • Backstop and switch-off: the Pi refuses more than 60 s in one run or 100 ml per pump per day, and switches every pump off at boot and after every run.
  • Top-off: runs until the high float says full or a time cap passes.
  • Relay (src/arm-pumps.ts): the controller drives these pumps in place of fakeDoser. A pump that refuses leaves the dose uncounted and automation running.
  • Browser block: the relay answers /grow/dosing from a browser with 404, so no dose skips the controller's limits.
  • Reach: /care → Dosing → Pumps (owner only), the /care pumps, wire, prime, calibrate, float and topoff terminal commands, and the MCP pumps tool.

From @juniper's review, still in place.

  • MCP needs a verified identity: /care/mcp answers 401 on every call without the forge-signed identity.
  • Dosing tools are the owner's: suggest_dose, dose and pumps answer 403 to anyone else.
  • Rate limit: at most 60 calls a minute per identity, then 429.
  • Terminal: tested through terminal.ts, a guest or non-owner gets nothing from the dosing and pump commands.
  • Open, in #611: there is no crew token, so a crew agent calls MCP with the owner's identity. The Pi's own /grow/* routes are open on the arm server's port.

🤖 Generated with Claude Code

Approved

Approved, on the diff and the preview. I could not open the head tree or the screenshots from my sandbox, so the pumps-card markup, the 360px layout and dose-pumps.ts are read from the diff only (the diff I was given is cut off before them).

Checked on the preview as a guest: /care shows no dosing; POST /care/mcp gives 401; /care/dosing/pumps gives 401; POST /grow/dosing gives 404, so a browser cannot skip the controller. CSP and nosniff are present. In the diff, every dosing route checks owner or known, writes check the same origin, bodies are capped at 4096 bytes by bytes read, MCP is 401 without an identity and rate limited per identity, and the terminal has the same gate with a test. The page, terminal and MCP all reach the pumps, and the README says how.

Two things to do next:

  1. Close #611: the Pi's /grow/dosing has no caller check, and only the 60 s / 100 ml backstop holds on that port. A relay-to-Pi shared secret is the fix.
  2. After a setup write (wire, calibrate, float), refresh cachedRead so the redirect to /care does not show the pre-change pumps for up to 5 s. Please confirm that with a test. I could not see where the write path calls refresh.

Smaller: the care-page.ts imports from doser.ts are split across two lines, and tests reach into private fields (later["s"], jam["d"]).

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs