A condensed tour of examples/v3-ultrasonic for people who
already know MeshCore basics: what this fork adds, how a reading becomes a
mesh message and then a Bayou row, how the node reads the ultrasonic
sensor, and what to configure after flashing. For parts, wiring photos
and mounting, see the
mesh water-level overview.
The key idea: a sensor reading is just an ordinary MeshCore direct
message whose text happens to be machine-readable. The sender is a
headless chat node that, every few minutes, DMs a line like
[SENSOR] node_id=1 dist=1.234m batt=3.87V … to a contact
called Receiver. The receiver is a chat node that parses any
DM starting with [SENSOR] and forwards the values to Bayou
over WiFi. Encryption, flood vs. direct routing, path learning and ACKs
are all stock MeshCore — nothing in the packet format is new.
The MeshCore core (src/, lib/) is
unmodified. Everything specific to this kit lives in a handful of
places:
| Path | What it is |
|---|---|
examples/v3-ultrasonic/companion_sensor/main.cpp |
The sender app: reads the MB7388 + battery, DMs a [SENSOR] line on a timer, OLED/button UI, serial CLI. |
examples/v3-ultrasonic/companion_sensor_receiver/main.cpp |
The receiver app: parses [SENSOR] DMs, POSTs JSON to Bayou over WiFi, OLED/button UI, serial CLI. |
variants/rook/ |
Board support for the Rook v4 (nRF52840 pro-micro + SX1262 + SSD1306). User button on P1.00 (Arduino 6); Serial1 RX on Arduino D0 = P0.08. |
variants/{rook,heltec_v3,heltec_v4}/platformio.ini |
Build envs Rook_companion_sensor_v3_ultrasonic, Heltec_v3_…_receiver_v3_ultrasonic, heltec_v4_…_receiver_v3_ultrasonic (and heltec_v4_companion_sensor_v3_ultrasonic for a Heltec V4 sender). |
firmware/2026-08-06_v3-ultrasonic/ |
Pre-built .uf2 / .bin files for the current kit, with a README recording the source commit. |
examples/ultrasonic_probe/ |
Bring-up firmware (env Rook_ultrasonic_probe): no mesh, just dumps whatever arrives on Serial1. Use it to prove the sensor wiring. |
examples/v2, v3, v3-1-wire |
Earlier generations of the same pair using a DS18B20 temperature probe (temp= → temperature_c). Same architecture. |
Both apps subclass upstream BaseChatMesh — the same base class
the companion-radio firmware uses — and started life as copies of upstream
examples/simple_secure_chat. So they behave like a chat
client driven by a serial CLI rather than a phone app (no BLE). On top of
that base they add or override:
[SENSOR] message every
interval seconds (default 300) to up to 4 target contacts.onMessageRecv() parses
the text and queues a Bayou POST.allowPacketForward()
returns true), so each also acts as a repeater for traffic
it hears.910.525 MHz, BW 62.5, SF7, CR5
— so the pair can use any public US MeshCore repeaters in range.The sender builds one line of text with snprintf:
[SENSOR] node_id=1 dist=1.234m batt=3.87V fwd_hops=2 fwd_path=5a.91
| Key | Format | Where it comes from |
|---|---|---|
[SENSOR] | literal prefix | Marks the DM as machine-readable. The receiver ignores (just logs) anything else. |
node_id | 1–65535 | set node_id on the sender. Lets several sensors share one Bayou feed. |
dist | metres, 3 dp, m suffix | Latest MB7388 frame (§5). |
batt | volts, 2 dp, V suffix | board.getBattMilliVolts() (averaged ADC reading). |
fwd_hops | 0–64, or 255 | Length of the sender's cached path to this target. 255 = no path known, this message will flood. 0 = direct neighbour. |
fwd_path | aa.bb.cc or none | The repeater hash bytes in that cached path (up to 15 shown). |
fwd_hops/fwd_path are diagnostics: they record the
route the sender believes it is using, so you can compare it with what
the receiver actually saw. The whole line is well under MeshCore's 160-byte
text limit.
sendSensorReading() calls the stock
BaseChatMesh::sendMessage() for each target. That builds a
normal TXT_MSG datagram — [4-byte timestamp][attempt byte][text],
encrypted with the shared secret derived from the two nodes' keys — and
remembers the expected ACK (the first 4 bytes of a SHA-256 over the
plaintext plus the sender's public key). Then:
The ACK timeout is 500 ms + 16 × airtime for a flood, and
500 ms + (6 × airtime + 250 ms) × (hops + 1) for a direct send.
The sender does not retry; the next reading goes out on the next interval
either way.
This is all upstream behaviour, except the two lines marked (fork):
Sender (Rook) Receiver (Heltec)
------------- -----------------
send #1, no cached path
TXT_MSG ======= FLOOD, path grows per hop ===> decrypt, parse, queue POST
<=== PATH return (FLOOD), ACK piggy-backed ===
store out_path, ACK matches, "Got ACK!"
reciprocal PATH (DIRECT) ====================> store its own out_path
send #2 onward, path cached
TXT_MSG ======= DIRECT along out_path =======> decrypt, parse, queue POST
<=========== ACK (DIRECT along out_path) ====
(fork) no ACK before timeout?
forget out_path -> next send floods and rediscovers the route
(fork) same message arrives DIRECT twice within 60 s?
forget out_path -> this ACK floods instead
On the sender, the ACK result drives the bottom line of the OLED
status page: Last: sending… → Last: ACK 3s ago
or Last: no ACK.
parseSensorMessage() checks for the [SENSOR] prefix,
then finds each key with strstr() and converts it with
atoi()/atof(). Those stop at the first non-numeric
character, so the m and V suffixes are harmless.
Keys can be in any order. Missing keys get defaults (0, or 255 /
none for the path fields). Only dist= is required.
The receiver also works out its own hop count for the arrival
(aux_1). For a FLOOD packet, that's the number of path hashes
the packet picked up on the way. A DIRECT packet's path is consumed as it
travels, so it arrives empty. In that case the receiver uses the length of
its own stored path back to the sender, which assumes the route is the
same in both directions.
handleSensorData() stores the values and sets
post_pending. On every pass, the main loop calls
serviceBayou(), which (re)connects WiFi if needed (blocking
for up to 5 s), then POSTs:
POST https://bayou.pvos.org/data/<bayou_public_key>
Content-Type: application/json
{"private_key":"<bayou_private_key>", "node_id":1,
"distance_meters":1.234, "battery_volts":3.870,
"aux_1":2, "aux_2":2, "log":"path=5a.91 route=DIRECT", "source":"Sensor"}
| JSON field | Comes from |
|---|---|
node_id, distance_meters, battery_volts | node_id=, dist=, batt= in the payload |
aux_1 | hops as measured by the receiver on arrival |
aux_2 | fwd_hops= (hops the sender thought it was using) |
log | path= from fwd_path=; route= is DIRECT/FLOOD of the arriving packet |
source | the sender's contact name as the receiver knows it (Bayou ignores this field) |
A 2xx reply clears post_pending. Any failure (no WiFi, no keys,
HTTP error) retries every 15 s. Only the latest reading is held, so a
newer one overwrites an unsent older one. See the
Bayou tutorial for what Bayou does with each
field.
The MaxBotix MB7388 (HRXL-MaxSonar-WRMT) has a TTL serial output. When its pin 4 is left floating, it free-runs at about 6 Hz, and each reading is a 6-byte ASCII frame:
R0742\r → 742 mm → 0.742 m (9600 baud, 8N1, range 300–5000 mm)
Only one data wire is needed, because the node never talks back to the sensor:
| MB7388 pin | Rook v4 | Heltec V4 (as sender) |
|---|---|---|
| 5 — TTL serial out | silkscreen RX1 = P0.08 = Arduino D0 (Serial1 RX) | GPIO47 (-D ULTRASONIC_RX_PIN=47) |
| 6 — V+ (2.7–5.5 V) | 3V3, or BAT for more range margin | 3V3 |
| 7 — GND | GND | GND |
| 4 — RX / strobe | leave floating (continuous mode) | leave floating |
On the Rook, the silkscreen labels TX0/RX1 do
not match Arduino pins 0/1: the local variant swaps D0/D1 compared
with a stock Nice!Nano. See notes/rook_v4_pinmap.md.
setup() opens Serial1 at 9600 baud, then waits up
to 500 ms for one well-formed frame. Success prints
MaxBotix MB7388 detected on Serial1.; failure prints
No MaxBotix frames on Serial1 (check wiring / 9600 baud).. That
sets the initial has_sensor flag, which the OLED shows as
Dist: … or Sensor: NOT DETECTED. Boot continues
either way.
readDistanceMeters()Condensed from the source:
float readDistanceMeters() {
int last_mm = -1; char digits[8]; int di = 0; bool capturing = false;
unsigned long start = millis();
while (millis() - start < ULTRASONIC_READ_TIMEOUT_MS) { // 400 ms, always the full window
while (Serial1.available()) {
char c = Serial1.read();
if (c == 'R') { capturing = true; di = 0; } // start of a frame
else if (capturing) {
if (c == '\r') { digits[di] = 0; // end of a frame
if (di >= 3) last_mm = atoi(digits);
capturing = false; }
else if (isdigit(c) && di < 7) digits[di++] = c;
else capturing = false; // garbage: wait for next 'R'
}
}
delay(5);
}
return last_mm < 0 ? -1.0f : last_mm / 1000.0f;
}
R. A partial frame left over from buffer
overflow, or any stray byte, just resets the parser. A frame is accepted
only if it has 3 or more digits followed by \r.R5000 means nothing was detected in range. Those values
are passed through unchanged.-1.
updateSensorReadings() then clears has_sensor
but keeps the previous distance, and the message still goes out
as a heartbeat. The payload doesn't say the value is stale.A reading is taken:
interval seconds, and once right after boot);send CLI command or a long press on the Send Sensor page;The node measures the distance down to the water, not the water level. Subtract the reading from the sensor's mount height above your datum. Keep the mount high enough that the water never comes within ~0.4 m of the sensor (the dead zone is 0–300 mm).
Flash the sender (.uf2: double-tap reset, drag the file onto
NICENANO) and the receiver (.bin via the web
flasher or pio … -t upload). Then open a serial console at
115200 baud on each, using pio device monitor,
tio, or the console on the
setup page.
Type help on either node for the full command list.
Press ENTER to generate key: and blocks until you
press Enter. The OLED sits at "Starting…" while it waits; it hasn't
crashed. The identity is saved to flash and survives later application
reflashes. A full erase wipes it, and then you have to pair again.
advert command) sends a zero-hop
advert, so the nodes must be in direct radio range for that.card on one node, then
import meshcore://… on the other, and repeat the other
way round. Imports on the receiver are auto-favourited.list on each side.
target add Receiver # by contact-name prefix; up to 4 targets
set node_id 1 # distinguishes sensors that share a Bayou feed
set interval 300 # seconds, minimum 10
set name Dock-North # optional: advert name, shows up as "source"
(target Receiver also works; it replaces the list with that one target.)
set wifi_ssid YourNetwork
set wifi_password YourPassword
set bayou_public_key <feed public key>
set bayou_private_key <feed private key>
WiFi settings only take effect after a reset: press RST or
power-cycle (the firmware has no reboot command). Bayou keys
take effect immediately. Out of the box the receiver posts to a
shared demo feed (mfupcqtx34ee), so set your own keys.
send on the sender. Its serial
log shows the [SENSOR] … line, then sent (FLOOD)
or sent (DIRECT), then Got ACK!. On the
receiver, status should show
WiFi: connected, Bayou: ok and the last reading.
After a long-press send, the sender's OLED should show
Last: ACK …s ago.
Everything you configure is saved as small files on the node's flash
filesystem: /contacts, /targets and
/sensor_prefs on the sender; /contacts and
/recv_prefs on the receiver. Like the identity, they survive
an application reflash. purge clears contacts only.
0.000 if there hasn't been
one since boot). Watch for MaxBotix: no frame within timeout
on serial or Sensor: NOT DETECTED on the OLED.loop()
reads it once to log the values, and sendSensorReading()
reads it again, so each cycle blocks for about 0.8 s. The second reading
is the one sent.[SENSOR] line, and
the receiver will post it to the feed.DS18B20: line, copied from the temperature version. It
reflects the MaxBotix probe result; ignore the name.