MICROMOUND — DEVICES · v0.4.2.14
Deploying a mound
Source: runtimes/micromound/docs/DEPLOY.md, a path from the root of the product's repository — versioned with the code, rendered here at every release.
This is the walk from "a Pi, a relay board, an ADS1115 and a soil probe on the bench" to "a mound ANTHILL can see, chartered, running unattended". Every step is checkable before the next one; none of them actuates anything until the last.
Everything here is v0.9.x substrate: the ports are proven against fakes and the kernel headers,
and this document is the on-hardware verification that v0.10.0 waits for. When you finish
step 6 and the relay clicks when — and only when — the mission says so, that is the M4 boundary.
0. What you need
- A Raspberry Pi running a 64-bit Raspberry Pi OS (Bookworm or later; the release ships
linux-arm64, and the Pi 3, 4, 5 and Zero 2 W all run it). A 32-bit OS has no build. - A relay module on a GPIO pin. Note whether it is active-low (most cheap relay boards are: the relay closes when the input is pulled LOW). Get this wrong and "safe" is "on".
- An ADS1115 breakout on the I2C header pins (SDA → GPIO 2, SCL → GPIO 3, VDD → 3V3, GND → GND;
ADDR → GND gives address
0x48), and an analog sensor into AIN0 whose output stays below 3.3 V — the ADC's gain setting is resolution, not protection. - ANTHILL reachable over HTTPS from the Pi, with a mound minted for this device (its Micromound page → Adopt a device). Copy the one-time token; it is shown once.
1. Enable the buses
sudo raspi-config nonint do_i2c 0 # dtparam=i2c_arm=on
sudo reboot
ls -l /dev/gpiochip* /dev/i2c-* # both present; groups gpio and i2c own them
sudo apt install -y i2c-tools gpiod
i2cdetect -y 1 # the ADS1115 shows as 48
gpioinfo | head # your header lines, none marked [used] by another program
If i2cdetect shows nothing at 48, stop here: wiring or the ADDR pin. If a GPIO line shows a
consumer already, something else holds it and the daemon will refuse it with EBUSY.
2. Install the daemon
Download the micromound-<version>-linux-arm64.tar.gz asset from the release, then:
tar -xzf micromound-*-linux-arm64.tar.gz
sudo bash deploy/install.sh # creates the micromound user (groups gpio, i2c), /opt, /etc, /var/lib
The installer prints what it did and the next steps. It does not start anything.
3. Author the manifest in ANTHILL, put it on the Pi
On ANTHILL's Micromound page, with your mound selected, Manifest → Attached hardware: add an
On/off output (capability act.water_valve, GPIO pin e.g. 17, longest single run e.g. 20,
and under Advanced Active when high = no for an active-low relay) and an Analog sensor
(capability sense.soil_moisture, ADC input 0, unit pct, scale/offset if you know them). The
form asks for exactly the settings the daemon reads — it is generated from the daemon's own
catalog (micromound --describe-drivers prints the same thing).
ANTHILL queues the signed manifest for the mound's next beat; you also want it as a file for the
hardware check and for an offline start. Save the same manifest as
/etc/micromound/manifest.json (sudo install -m 0640 -o root -g micromound manifest.json /etc/micromound/).
A minimal one, by hand:
{
"manifest_id": "mf-workshop-1", "mound_id": "mm-workshop", "issued_at": "2026-09-05T12:00:00Z",
"safe_state": "all_actuators_off",
"hardware": {
"irrigation": { "driver": "digital_actuator",
"settings": { "capability": "act.water_valve", "pin": "17", "active_high": "false", "max_on_s": "20" } },
"soil": { "driver": "analog_sensor",
"settings": { "capability": "sense.soil_moisture", "channel": "0", "unit": "pct", "scale": "50" } }
},
"capabilities": ["act.water_valve", "sense.soil_moisture"]
}
mound_id must be the id the token was minted for — ANTHILL looks the device up by the token and
cross-checks this.
4. Check the wiring — nothing moves
sudo -u micromound /opt/micromound/micromound --manifest /etc/micromound/manifest.json --check-hardware
micromound: hardware check (real ports, GPIO via chardev)
OK irrigation digital_actuator act.water_valve output line claimed and held at its SAFE level (not actuated)
OK soil analog_sensor sense.soil_moisture claimed; first reading 41.2 pct
all 2 device(s) claimed. Nothing was actuated; no mound was composed.
Watch the relay while this runs: it must not click. If it clicks on, active_high is wrong
for your board — fix the manifest, not the wiring. Cover or wet the probe and run again; the
reading should move the right way. A FAIL line carries the driver's own reason (EBUSY,
errno 2, a channel out of range) — this is the same refusal the daemon would give at bring-up,
without the daemon.
Exit code 0 means every device was claimed. gpioinfo during the check shows the line as
consumer=micromound; after it, the kernel has released it.
5. Enroll and start
Edit /etc/micromound/micromound.env: the controller URL and the token. Then:
sudo systemctl enable --now micromound
journalctl -u micromound -f
The first lines say what happened: enrolled (the controller key is now persisted; the token is
burned and ignored from here on), the port banner Ports: REAL hardware (GPIO via chardev, ADS1115/I2C), and the watchdog. Within one sync interval ANTHILL's fleet table shows the mound
online and its capabilities. If enrollment is refused, the log carries ANTHILL's reason (a
burned token, a mound-id mismatch, a tier it does not accept) — nothing to retry until it is fixed.
The daemon runs with no charter: observe only. It will read the probe when asked and refuse
every actuation with no_charter. That is the correct resting state.
6. Charter, then one mission — the M4 boundary
In ANTHILL: Charter — the capability list is pre-filled from what the device reported; ceiling
benign; issue. Then Physical mission → Load the watering example → Dispatch. The mound
collects both on its next beat and runs: read the probe, open the valve for 10 s only if the
reading was below 30, read again, verify.
What you should see, in order: the relay clicks on exactly once, stays on ~10 s (plus up to one
tick interval — the release runs on the service loop), clicks off; ANTHILL's Mission evidence
shows the device's report and the colony's own verification from the two readings, never
merged. Pull the network cable during the hold: the valve still releases on time (the hold is the
device's clock, not the controller's), and when the lease runs out the mound quiesces to
all_actuators_off and refuses new actuations until a fresh charter arrives.
Then sudo systemctl kill -s SIGKILL micromound while a hold is active. The relay releases (the
kernel drops the line to its default — check that your board's idle state is off, SAFETY.md
Layer 0), systemd restarts the daemon in 3 s, it requests the line at the safe level, and the
mission is reported as not proven finished rather than resumed.
If all of that held, the host has run on a real device against real hardware: v0.10.0.
7. A board on the bench, through this Pi
An ESP32 running firmware/esp32 in its serial-link configuration has no Wi-Fi and no TLS: it
frames its enrollment and its beats over the USB-serial cable (PROTOCOL.md §12), and this Pi
relays them to the controller. The bridge is transport — it holds no key and changes no byte — so
the board enrolls and beats exactly as it would over Wi-Fi, under its own identity.
stty -F /dev/ttyUSB0 115200 raw -echo # the link is raw bytes; no line discipline
micromound --bridge /dev/ttyUSB0 --controller https://anthill.example
The bridge logs every relayed path and status, answers the board's clock requests from this Pi's
clock, refuses anything outside micromound/v0/, and reopens the device when the board is
unplugged and plugged back. It can run beside the mound daemon (a second unit, a second process);
it does not need the mound's state directory or its identity. Provision the board's one-time token
in its own NVS (firmware/esp32/README.md); the bridge never sees it as anything but bytes.
Or: the board as this mound's hands. Flash the port-server image instead
(sdkconfig.defaults.ports) and no bridge runs: the board has no identity and enrolls nowhere.
This Pi's manifest names the board's pins and channels with a link setting, and this Pi's kernel
authorizes every actuation exactly as it does for a local GPIO — the board only keeps its compiled
max_on_s per pin and drives everything safe if this Pi goes quiet for 5 s (PROTOCOL.md §12, port
requests). The same stty line applies; the daemon opens the device itself.
{ "type": "digital_actuator", "settings": { "capability": "act.relay_1", "link": "/dev/ttyUSB0", "pin": 5, "max_on_s": "60" } },
{ "type": "analog_sensor", "settings": { "capability": "sense.temp", "link": "/dev/ttyUSB0", "channel": 0, "scale": "100", "offset": "-50", "unit": "C" } }
micromound --check-hardware says hello to the board and refuses the manifest fail-closed if the
board does not answer, does not offer the pin or channel, disagrees with active_high, or reports
itself tripped. A pin's max_on_s in the manifest is the device tier; the board's compiled bound is
the hardware tier below it, and the charter narrows both.
Operating notes
- Stop. ANTHILL's Stop on the fleet row is carried by the next beat. The mound de-energizes,
persists the stop, and stays stopped across restarts until the stop is cleared. It restores
nothing when it is: the mound comes back observe-only and waits for a fresh charter.
- The stop's snapshot (roadmap E-20). Once it has driven its outputs safe and persisted the
stop, the mound reads each of its sensors once and sends the readings with its acknowledgement of
the stop. (A driver that does not answer the safe-state walk in time is a trip, and its output may
still be live when the sensors are read.) ANTHILL keeps them
as the stop's snapshot (
stop_snapshot_idson the fleet row) and logs them (micromound_stop_snapshot). A sensor that could not be read is named there. Under ANTHILL's stop the mound sends one envelope per beat, so the snapshot and its acknowledgement take two beats to arrive, and the beat that reports the mound stopped comes after them. - Resume on the fleet row clears a stop ANTHILL gave this one mound. It sends a signed clear
with the next beat, and the Resume's answer says it did (
v0.9.46, roadmap E-17). - The colony-wide stop (the
MICROMOUND_STOPfile) and a stop the mound took itself (a guard trip, its watchdog) are cleared only at the device. Resume says so rather than sending anything. - At the device, a stop of any kind is cleared the same way:
sudo systemctl stop micromound, thensudo -u micromound /opt/micromound/micromound --clear-stop --state /var/lib/micromound --reason "why", thensudo systemctl start micromound. The clear refuses while the service runs. It records who and why instop-clears.jsonlin the state directory. - Why it was stopped (roadmap E-20). The clear also prints why the stop was taken, as the mound
kept it, and logs it as
stop_reason. For a stop from ANTHILL that is the order's reason: an operator's own words, when the stop was given some (POST /micromound/stoptakes an optionalreason). For a stop the mound took itself it is the guard's words. A stop kept by an older build has no reason to show. - If ANTHILL still holds its own stop — the file is still present, or the mound's Stop has not been resumed — the next beat stops the mound again. End that one in ANTHILL too.
- A device from before
v0.9.46has no clear on the wire. Upgrade it, or reprovision it. - A reduced-profile board under ANTHILL's stop (roadmap E-20). While ANTHILL's stop is in
force, every answer carries a fresh stop order, and the board acknowledges each one. A board
built from this release sends one envelope a beat under it, and every beat ends.
- A board built from MICROMOUND
v0.9.46or earlier goes on draining after the stop. Once its uplink queue is full (16 envelopes; 8 on the ESP32 image) its beat never ends: each exchange frees a slot and the next stop's acknowledgement takes it. Its outputs stay safe, but it senses nothing, and it has ANTHILL sign an order and an ack for every exchange, as fast as the link allows, until the stop ends or the ESP32's task watchdog reboots it (120 s). Then it starts again. - If its queue was already full when the stop first arrived, that first beat is the one that never ends, and the board writes a stop to storage only after a beat returns. The stop holds in memory, but the watchdog's reboot brings the board up unstopped. It has no charter then, so it is observe-only until its next beat is answered with the stop again.
- Flash these boards with this release. Until one is flashed, a board held by ANTHILL's Stop of that one mound can be released from the order without being released from the stop. Wait until the fleet row reports the board stopped (it writes its stop to storage before any beat says so), then Resume that mound. ANTHILL sends a reduced-profile board no clear, so the board stays stopped until it is reprovisioned, and ANTHILL goes on refusing it a charter, a mission and a configuration while it reports the stop.
- The colony-wide stop has no such release. Removing the file ends it for every mound that has not yet taken it.
- A board built from MICROMOUND
- Locally,
systemctl stop micromoundis a safe shutdown (SIGTERM → safe state → persist). It is not a stop.
- The stop's snapshot (roadmap E-20). Once it has driven its outputs safe and persisted the
stop, the mound reads each of its sensors once and sends the readings with its acknowledgement of
the stop. (A driver that does not answer the safe-state walk in time is a trip, and its output may
still be live when the sensors are read.) ANTHILL keeps them
as the stop's snapshot (
- Logs.
journalctl -u micromound. Refusals are one line each with the reason; the daemon never logs a secret. - State is
/var/lib/micromound:identity/(the device key — back it up nowhere, re-enroll instead),state/,evidence/. Use endurance media for a long-lived deployment. - Legacy GPIO. A kernel without
/dev/gpiochip*can use--gpio sysfs; in the unit, uncommentReadWritePaths=/sys/class/gpioand setProtectKernelTunables=no. Pin numbers are then global sysfs numbers, andchipmust be left unset. - Exit codes.
0clean stop;1bring-up refused by the hardware or a safety refusal (systemd restarts — a chip powered late is worth retrying);2a refused configuration (unreadable manifest, a physical manifest without--hardware, bad arguments) — systemd does not restart on 2, sosystemctl statusshows the reason instead of a restart loop. - Development machine. To run a manifest that names physical ports with no hardware, pass
--simulate; the daemon saysSIMULATINGand every port is in memory. Without--hardwareor--simulatesuch a manifest is refused, so a device can never fake its readings by accident.
