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_ids on 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_STOP file) 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, then sudo -u micromound /opt/micromound/micromound --clear-stop --state /var/lib/micromound --reason "why", then sudo systemctl start micromound. The clear refuses while the service runs. It records who and why in stop-clears.jsonl in 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/stop takes an optional reason). 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.46 has 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.46 or 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.
    • Locally, systemctl stop micromound is a safe shutdown (SIGTERM → safe state → persist). It is not a stop.
  • 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, uncomment ReadWritePaths=/sys/class/gpio and set ProtectKernelTunables=no. Pin numbers are then global sysfs numbers, and chip must be left unset.
  • Exit codes. 0 clean stop; 1 bring-up refused by the hardware or a safety refusal (systemd restarts — a chip powered late is worth retrying); 2 a refused configuration (unreadable manifest, a physical manifest without --hardware, bad arguments) — systemd does not restart on 2, so systemctl status shows the reason instead of a restart loop.
  • Development machine. To run a manifest that names physical ports with no hardware, pass --simulate; the daemon says SIMULATING and every port is in memory. Without --hardware or --simulate such a manifest is refused, so a device can never fake its readings by accident.