Precise USB-controlled fluid reward dispenser for behavioral experiments. Built around an ESP32 microcontroller driving a Boxer 9QX pump. The device is commanded with newline-terminated JSON over a 2,000,000 baud serial link.
- API: See
api.mdfor full command reference (set/do/get). - Assembly: See
docs/assembly/README.mdfor hardware assembly + wiring (with photos). - Quick test:
test_connection.pyauto-detects the device and queriesflow_rate. - Typical use: Send JSON commands like
{"do":{"reward":0.5}}or{"get":["flow_rate"]}over the serial port. - Python helper:
juicer.pyprovides a small, researcher-friendly wrapper with port auto-discovery and convenient methods.
api.md— serial API reference and CLI examples (Linux one-liners).juicer.py— importable Python helper for experiment scripts (port auto-discovery, convenience methods, raises on device failures by default).test_connection.py— cross-platform port discovery +flow_ratesanity check.unit_test.py— integration-style protocol/state tests over serial (some tests actuate the pump briefly).update_juicer_firmware.py— Debian-focused interactive toolchain setup + compile + firmware upload helper.juice_pump3/— firmware source (ESP32).docs/assembly/README.md— hardware assembly guide (with photos).
These scripts require Python 3.10+ and pyserial.
If your system Python won’t let you install packages globally (common on Debian/Ubuntu with “externally managed environment” errors), use a virtual environment:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
python -m pip install -e .If you already manage your own environment (conda/venv/etc), just run from the repo root:
python -m pip install -e .Then run either:
juicer-test-connection
juicer-unit-testfrom juicer import Juicer
with Juicer() as j:
j.set(target_rps=2.5)
j.set(reward_overlap_policy="append")
resp = j.reward(0.5, "reward_mls", "reward_number", "juice_level")
print(resp)- Port selection:
Juicer()auto-detects the device. To force a specific port:
from juicer import Juicer
j = Juicer(port="/dev/ttyACM0") # or "COM15" on Windows
j.close()-
Error handling: the helper raises if the device replies with
{"status":"failure", ...}(or other non-success status).- If you want to handle failures yourself, construct with
raise_on_failure=Falseand check the returned dict.
- If you want to handle failures yourself, construct with
-
Keeping a connection open (recommended for long experiments): create one
Juicer()and reuse it, then callclose()at shutdown. e.g.,
from juicer import Juicer
j = Juicer()
j.reward(0.5, "reward_mls", "reward_number", "juice_level")
j.close()This repo includes an interactive helper that can install the full toolchain (user-local), patch the ESP32 core so the device enumerates as juicer3, pull the latest main, compile (with caching), and upload.
Run from the repo root:
python3 update_juicer_firmware.pyNotes:
- Requires
sudoforapt-getand to add you to thedialoutgroup. - After being added to
dialout, you must log out/in (or reboot) once. - Device detection prefers
/dev/serial/by-id/*juicer3*and usesarduino-cli board list --format jsonto pick the correct ESP32-S2 vs ESP32-S3 FQBN.
- Device enumerates as a USB serial port (e.g.,
/dev/ttyACM0on Linux,COMxon Windows). - Responses are JSON per request; see
api.mdfor expected shapes.