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
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 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:
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.
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.mdsays 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.