A soil-moisture sensor for a houseplant, and the iPhone app it talks to. Both halves written here, designed against each other.
The sensor is an ESP32 with a capacitive probe. It samples the soil, decides whether what it just measured is believable, and sends the reading over Bluetooth LE as sixteen bytes — including a flag saying how much to trust it. The app decodes those bytes, decides what a human should be told, and is willing to say "I don't know."
That last part is the actual project. Writing an app that draws a number is a weekend. Writing one that behaves well when your own sensor lies to you is the part that transfers to everything else.
firmware/sprout/ ESP32 firmware (C++, Arduino, NimBLE)
ios/Sprout/ iPhone app (Swift 6, SwiftUI, CoreBluetooth)
docs/PROTOCOL.md the contract between them
docs/HARDWARE.md wiring, parts, and three non-obvious decisions
docs/ROADMAP.md v2 through v5, and what's deliberately not in them
makeThat compiles the firmware for both boards, generates the Xcode project, builds the app, and runs the tests. Individually:
make firmware # compile both ESP32 targets
make firmware-test # run the firmware's fault logic natively on this Mac
make flash # upload to a connected DevKit
make monitor # serial log
make app # build the iOS app for the simulator
make test # the app's unit tests
make sim # pretend to be a Sprout over real Bluetoothmake firmware-test compiles the real moisture.cpp and calibration.cpp —
the same translation units that go on the device — against a fake Arduino in
firmware/test/shim/, and runs them in under a second
with ASan and UBSan on. Nothing is reimplemented, so a pass says something about
the firmware rather than about the test.
It exists because the fault detection is the point of the project and it is the one part you cannot test on real hardware: you can't ask a capacitive probe to please return 4093 now, or to corrupt its own flash.
Prerequisites: Xcode 26, brew install xcodegen, and a Python venv with
PlatformIO at .venv-pio (python3 -m venv .venv-pio && .venv-pio/bin/pip install platformio).
CoreBluetooth has no radio in the iOS Simulator, so the app falls back to a simulated sensor that emits real wire-format packets through the real decoder. Menu → Simulator gives you ten scenarios:
Healthy plant · Drying out · Probe falls out · Noisy wiring · Occasional spikes · Out of calibration · Never calibrated · Sensor goes quiet · Low battery · Newer firmware
Every one of those is a thing that actually happens to a breadboard on a windowsill. Having them on a menu is what turns "the app should degrade gracefully" from an intention into something you can look at and argue with.
The in-app simulator can't tell you anything about BluetoothSource.swift,
because it replaces it. tools/sprout-sim is the other
half: a macOS command-line tool that advertises the same GATT service from your
Mac, so a real iPhone exercises the real CoreBluetooth path — discovery,
subscription, MTU, write-then-ack, reconnection — with no ESP32 in the room.
make sim SCENARIO=probe-falls-outIt shares the wire format with the app by symlink rather than by copy, and
make builds it by default, so a change to Protocol.swift that the tool
hasn't kept up with breaks the build instead of breaking a demo.
It has to be run from Terminal.app or iTerm — macOS attributes Bluetooth permission to the terminal application, not to the binary, and when it's missing the process is killed with no message at all. That trap and its diagnosis are written up in tools/sprout-sim/README.md.
A missing value is not a zero value. The wire format has explicit sentinels
(0xFFFF, 0x8000), the Swift side carries them as Optional, and the
compiler makes every screen deal with it. A probe lying on the carpet reads as
"no value", never as "bone dry" — otherwise a loose wire sends a push
notification at 2 a.m. telling you to water a plant that's fine.
The firmware never hides a bad reading; it labels one. It sends the raw ADC count even when that count is meaningless, because you cannot debug a sensor you cannot see. Deciding whether a flagged reading is good enough to show is the app's call, and the firmware doesn't get to make it by omission.
Showing a number and trusting a number are different bars. PlantStatus has
both display and trustworthyEnoughToAlert. A rough reading is fine on a
screen someone chose to open; only a clean one is allowed to wake them up.
The device is the source of truth for its own calibration. Anchors live in the ESP32's flash, not in app storage, because they describe the probe and the pot — not the phone. Reinstall the app and the calibration survives.
Every threshold is a judgement call, so they live in one file.
firmware/sprout/config.h. Changing your mind should be a one-line edit.
The caveats have to survive being read aloud. Confidence is drawn as a 45 %
opacity difference, which is invisible to VoiceOver and marginal for low vision.
So the honesty is carried in words too — PlantStatus.spokenDescription is unit
tested, and its tests assert that the hedged and unhedged versions can never say
the same thing. Otherwise a sighted user and a VoiceOver user look at the same
screen and get different claims about the same plant.
- Firmware compiles clean for
esp32devandesp32c3(Arduino-ESP32 3.3.11,-Wall -Wextra, no warnings). - 44 native firmware tests, 113 assertions,
-Werrorwith ASan and UBSan. Nine of those pin the packet's byte layout against the same hand-written literals the Swift suite decodes, so the two sides cannot drift apart while agreeing with each other. - App builds clean under Swift 6 language mode with
SWIFT_STRICT_CONCURRENCY=complete. - 41 app unit tests, including a byte-for-byte packet transcribed by hand from
protocol.h— the one test that catches the firmware and the app drifting apart while agreeing perfectly with each other. - Every simulator scenario driven and inspected on an iPhone 17 Pro simulator,
at default text size and at
accessibility-extra-large.
The firmware suite found a real one on its first run: writing new calibration
anchors also reset the warm-up counter, so for three samples after every
calibration the device claimed to be a freshly powered-on probe and the app said
"taking a first reading" about a sensor that had been in the pot for a week. The
scale had moved, but the hardware hadn't. Fixed in moistureForgetBaseline().
Not verified: anything involving actual soil. No ESP32 has been flashed and no probe has been dunked, because there isn't one plugged into this machine. The bring-up procedure in docs/HARDWARE.md ends with the test that matters — pull the probe wire out and confirm the app says "probe unplugged" instead of "your plant is dry."