Skip to content

A duty-cycled water-level node on the v3-ultrasonic base: ultrasonic ranging, radio sleep, and the power numbers - #1

Open
cversek wants to merge 23 commits into
edgecollective:mainfrom
cversek:upstream/waterbear-node
Open

A duty-cycled water-level node on the v3-ultrasonic base: ultrasonic ranging, radio sleep, and the power numbers#1
cversek wants to merge 23 commits into
edgecollective:mainfrom
cversek:upstream/waterbear-node

Conversation

@cversek

@cversek cversek commented Jul 22, 2026

Copy link
Copy Markdown
Member

This PR carries the June-July 2026 work of turning the v3-ultrasonic companion sensor into a duty-cycled, battery-powered water-level node: a MaxBotix MB7388 ultrasonic rangefinder on the Rook v0.4 carrier, measuring down to the water surface and reporting over the mesh. Bench-validated end to end; not yet deployed - this is a work-in-progress checkpoint, and there will be follow-ups (deployment, range characterization, a repeater) as they happen.

The base already supplied the hard half - the mesh layer, the acknowledgement machinery, the flood fallback, the leaf behavior - so everything here lives in the variant and application layer. The MeshCore library source is untouched. One seam was written into the Dispatcher (radio sleep/wake), compiled, and then deliberately reverted in favor of an app-side module; the architecture notes in this PR record why, and that seam might be worth its own upstream conversation someday.

The node in its enclosure, reading 0.63 m

The work was developed on my fork as a ten-chapter series of PRs, each written up dev-blog style with the instrument captures that back its claims. This PR lands all ten; the fork PRs are the long-form version if any chapter deserves a closer read:

  1. Find out where the sleep current actually goes - the SX1262 has two power domains; gating its supply enable leaves the digital core at ~0.9 mA, and only a commanded SPI sleep reaches microamps. 2.82 mA -> 1.05 mA -> 146.6 uA.
  2. A trustworthy reading from the ultrasonic sensor - commanded ranging (the UART owns the strobe pad until you detach PSEL.TXD), the 10k-series-resistor trap that made the UART read zero bytes from a healthy sensor (use 1k), median-of-five.
  3. Instrumentation, and the night the radio was innocent - a "radio wedge" that was actually a 300 s send interval plus no established console cadence. The LED link lamp, tx/ack counters, and fault-register probes that ended it.
  4. The mode machine as host-testable logic - the duty cycle as a pure transition table, tested with plain g++, including a button-classifier race caught in design review.
  5. Sleeping a radio the framework owns - why app-level radio sleep deadlocks the Dispatcher, and the library-untouched workaround. Wake with standby()+startReceive(), never begin().
  6. Wiring the machine to the hardware - two bugs only the bench could find: a second transmit authority, and retries faster than the ACK round trip (which makes a healthy link look dead).
  7. The System-ON idle campaign - the honest negative. After a null sweep of every software knob, the ~900 uA idle floor belongs to the nRF52840 SuperMini clone class, not the firmware. Sub-milliamp needs System OFF plus an external wake source, i.e. hardware. The one real win (releasing UARTE0 across sleep, 6.689 -> 1.066 mA) ships with a control build that un-fixes it on demand.
  8. The validation gate - running the firmware the way the field will (battery-first boot, sensor attached, human fingers) surfaced six defects, including a button that had never once worked and a display that libeled a working sensor. All fixed and verified same-day.
  9. Bench diagnostics - the two reusable sketches from the gate investigation.
  10. Bill of materials - every part with sourcing and July-2026 prices; the MB7388 dominates at $109.95, whole node $140-180.

Current numbers, all measured on battery with a Joulescope: sleep floor 1.066 mA with the sensor attached and gated, ~15 mA average while ranging with 82 mA pings, ~198 mA transmit bursts, acknowledgement round trips 2.5-2.8 s. Back-of-envelope: the sleep floor alone would run the 2000 mAh cell about 78 days, and with ranging and transmit bursts on top at the 300 s cadence, call it one to two months. Before the UART release that number was under two weeks. Not where it ends, either: the plan is a 5 V USB solar panel keeping the battery topped up (the enclosure lid already carries one, untested in any depth yet), and beyond that the System-OFF + external-RTC architecture that gets to many months on the cell alone is the next hardware conversation.

One define, 6.689 vs 1.066 mA

A note on the history: it is a reconstruction. The firmware was developed on the bench without incremental commits, and the branches were authored afterwards from dated notes and instrument captures - so the sequence is tidier than the real path was, and the wrong turns are kept in the record on purpose (chapter 3 is built entirely around one). docs/DEVLOG.md says this properly. Development was assisted by Anthropic's Claude Code agentic platform, using the Opus 4.8 and Fable 5 models.

Next: the boxed node goes in the field. Range-link characterization first, then the dock deployment, then the hilltop repeater it justifies. Updates as they happen.

cversek added 23 commits July 22, 2026 16:43
FORK.md explains what this fork is, pins the upstream baseline, and warns
that the history is a reconstruction from bench notes rather than
incremental commits. The gitignore additions keep local editor backups
and scratch dirs out of the tree.
Minimal sketch, no mesh, so the numbers are attributable. Three
iterations measured on a Joulescope with the node on battery:
System OFF alone 2.82 mA, plus SX126X_POWER_EN low 1.05 mA, plus
radio.sleep() over SPI 146.6 uA. The lesson is the SX1262 has two
power domains: gating the supply enable kills the TCXO and RF switch
but leaves the digital core in standby at ~0.9 mA until it is
commanded to sleep. A dark OLED measured ~1.5 uA, irrelevant.

Plots from the Joulescope captures are in docs/images.
Radio-free build that ranges with the MaxBotix MB7388 and shows the
result on the OLED. Three things had to be right: commanded ranging
(the UART peripheral owned the strobe pad; detach PSEL.TXD before
pinMode works), the RX path (a 10k series resistor rounded the
9600-baud edges below the start-bit threshold and the UART read zero
bytes while the sensor transmitted fine; use 1k), and median-of-five
filtering. Validated against a walking target, 537 to 1440 mm.

Includes the sonar power-gate demo captures and the ranging-cost A/B
(whole node: ranging costs only 0.54 mA over idle) in docs/images.
Additive, flag-gated debug aids for the sensor and receiver examples,
after a night lost to a radio wedge that did not exist. The symptom
was one packet after power-up then silence; the cause was a 300 s
send interval plus a console with no established cadence, which is
indistinguishable from a dead link.

What landed: SEND_TIMER_MILLIS so sends repeat off millis() instead
of an RTC that never advances without GPS or NTP; an LED link-health
lamp (toggle on send, off on ACK, on when a send times out); OLED
tx/ack counters; a getDeviceErrors() probe around each send; and a
USB-CDC debug env for the Heltec receiver so both ends are
observable. Re-tested at 20 s: 100 percent ACK, fault register clean
on every send. Default builds are unchanged.
Deployment behavior as a pure transition table with no hardware
dependencies: POST -> STATUS -> SLEEP -> WAKE -> MEASURE -> TRANSMIT
-> SLEEP, with button gestures routed out of WAKE.

The detail worth writing down is a race caught in design review: a
single button check() in WAKE races the user's second tap, because
the classifier cannot report CLICK until its multi-click window
closes nor LONG_PRESS until its threshold elapses. WAKE has to spin
until a terminal gesture or a safety cap. That invariant is asserted
in the tests rather than left as a comment.

g++ -std=c++11 -Wall -o /tmp/test_modes test_modes.cpp && /tmp/test_modes
29 checks, 0 failures
With the radio left in receive the sleep floor was about 8 mA, the
largest remaining consumer. Sleeping it from the app hung the node
twice before the reason was clear: Dispatcher::loop() touches the
radio over SPI on every iteration and runs an 8 s stuck-radio
watchdog, on the assumption of a continuously live radio, with no
pause or resume exposed. An app-level powerOff() deadlocks the next
poll against the sleeping chip's BUSY line; begin() on a slept radio
deadlocks the same way.

A small seam inside MeshCore (virtual sleep/wake on mesh::Radio,
radioSleep/radioWake on Dispatcher) was written and compiled, then
reverted: the app already controls when the_mesh.loop() runs, so an
application-side module gets the same result with the library
untouched. Wake is standby() then startReceive(), never begin();
warm sleep retains config and DIO mapping.

Verified: an acknowledged transmit 2502 ms after the radio had
genuinely SPI-slept. The architecture map in notes/ records how the
ownership chain forces this design.
The mode machine driving real peripherals: gated sonar power, the
strobe-and-median read, battery telemetry, and transmit with retry,
on the v3-ultrasonic base so the mesh and acknowledgement machinery
stays upstream's.

Two bugs here compiled fine and passed the host tests, and were only
visible with hardware running. First, the forked MyMesh::loop()
carried an autonomous interval send that fired on boot: a second
transmit authority that would have fired during SLEEP in the field.
Removed; the mode machine is the only thing that transmits. Second,
retries fired on the mesh's internal ~0.8 s send timeout instead of
the intended 5 s, so every retry pre-empted the 2.5-2.8 s
acknowledgement round trip and the link could never succeed against
a perfectly healthy receiver. Retries now gate on elapsed time; a
late ACK still lands through processAck.

Any retry interval shorter than the response time turns a working
link into a dead one, and it looks exactly like a hardware fault
while it does it.
The remaining question was the ~900 uA between the deployment
firmware's 1.066 mA sleep floor and the 146.6 uA the minimal sketch
reaches in System OFF. This lands the full measurement apparatus:
surgery candidates on the field node (USBD off, corrected radio
sleep sequence, Errata-87 FPU clear, HFXO stop), a strip-down ladder
that removes the application in layers, a build-up ladder on the
minimal sketch that adds suspects one at a time, and audit builds
that latch clock and peripheral state to RAM on battery and dump the
snapshot when USB returns, since plugging USB in does not reset the
chip.

The verdict: every knob measured null. The bare minimal sketch
idling in System ON with the radio slept and USBD off sits at the
same ~1.05 mA as the full deployment stack, the SoftDevice-managed
sleep path matches the bare WFE loop, and re-sleeping the radio
every pass changes nothing, which exonerates both the sleep
mechanism and the radio. The floor belongs to the microcontroller
platform and this board class. Community measurements of the same
SuperMini clone family document a matching deep-sleep defect. Sub-
milliamp on this hardware means System OFF plus an external wake
source, which is a hardware architecture change, not a setting.

The uartsleep control build stays: it un-fixes the one real win
(releasing UARTE0 across sleep, 6.689 to 1.066 mA in an A/B with one
define) so the claim can be falsified on demand.
Ran the firmware the way the field will run it -- battery-first cold
boot, sensor already attached, buttons pressed by a human -- instead
of the bench habit of USB first and sensor second. That one
permutation change surfaced six defects, found with a logic analyzer
on the pins, a power analyzer on the battery, and the serial log as
three witnesses that cannot lie in the same direction.

1. The transmit path re-read the sonar after the quiet-window gate
   had already powered it down, printed a false failure, and
   clobbered the freshness flag while shipping a two-second-old good
   reading. The tell: the 'cached' distance advanced between cycles,
   which proves some read succeeds. One deleted call fixed log, flag
   and display.
2. The power-on self-test screen lasted 800 ms, too brief to read.
   Now 3 s.
3. The user button had never worked: begin() was never called, so
   the pin's input buffer was disconnected and every apparent press
   in weeks of bench life had actually been the reset button's boot
   path impersonating it. The logic analyzer showed clean presses
   arriving at a deaf pin.
4. Quick taps fell between 50 ms polls. The pin now latches presses
   in hardware (SENSE low + port LATCH), so no tap is lost no matter
   when it lands. Four rapid taps, four wakes, on the trace.
5. Button wake now goes straight to the status page, and STATUS is
   the gateway to the demo and transmit-test modes. Every mode entry
   provisions its own peripherals (I2C, sonar UART, radio), because
   the shortcut path skips the bring-up the long path used to do.
6. The demo mode banner and a 3 s linger after the transmit test's
   acknowledgement make each mode legible to a human standing at the
   box.

Host tests updated for the new architecture: 31 checks, 0 failures.
Verified live end to end: distance tracking a moving target, ACK
round trips at 2.5-2.7 s including a timeout-flood-recover sequence,
and a 1.066 mA sleep floor with the sonar attached and gated.
Two reusable diagnostics from the sonar power-gate work. gate_cmd is
the reference implementation of commanded ranging plus battery
telemetry: hold the gate on, drive the strobe high to range, read
frames, median-filter, report. gate_probe is the A/B harness that
isolated gate-node behavior when the sonar and radio were suspects
in the same failure.

Five sibling diagnostic sketches from that investigation stay out of
the tree; these two are the ones with reuse value.
Every part in one node plus the development receiver, with what each
piece does, where to buy it, and prices as checked July 2026. The
MB7388 rangefinder dominates the cost; the whole node lands in the
140 to 180 dollar range depending on sourcing. Includes the known
deep-sleep current defect in the SuperMini nRF52840 clone family and
which variant to buy to avoid it.
Find out where the sleep current actually goes
Get a trustworthy reading out of the sonar, with the radio out of the picture
Instrumentation, and the night the radio was innocent
The mode machine as logic you can test on a laptop
Sleeping a radio that the framework owns
Wiring the mode machine to the hardware, and two bugs only the bench could find
The System-ON idle campaign: measuring an empty knob space
The validation gate: six defects before the dock
Bench diagnostics: gate_cmd and gate_probe
Bill of materials and sourcing
Same content, reframed for upstream: the baseline pin, the
reconstruction disclaimer, the power status, and the credits.
@cversek
cversek requested a review from dwblair July 22, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant