Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Sprout

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

Build it

make

That 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 Bluetooth

make 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).

Run it without hardware

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.

Run it without hardware, on a real radio

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-out

It 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.

The stance

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.

What's verified

  • Firmware compiles clean for esp32dev and esp32c3 (Arduino-ESP32 3.3.11, -Wall -Wextra, no warnings).
  • 44 native firmware tests, 113 assertions, -Werror with 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."

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages