How the sensor firmware works

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.

On this page
  1. The big picture
  2. What this fork adds to MeshCore
  3. The message: format, sending, ACKs
  4. Parsing and posting on the receiver
  5. Reading the ultrasonic sensor
  6. Setup after flashing
  7. Quirks worth knowing

1. The big picture

MaxBotix MB7388"R0742\r" over UART
→
SenderRook v4 (nRF52840)
→
MeshCore meshencrypted DM, 0+ repeaters
→
ReceiverHeltec V3/V4 (ESP32-S3)
→
BayouHTTPS POST, JSON

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.

2. What this fork adds to MeshCore

The MeshCore core (src/, lib/) is unmodified. Everything specific to this kit lives in a handful of places:

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

How the two apps differ from stock MeshCore firmware

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:

3. The message: format, sending, ACKs

The payload

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
KeyFormatWhere it comes from
[SENSOR]literal prefixMarks the DM as machine-readable. The receiver ignores (just logs) anything else.
node_id1–65535set node_id on the sender. Lets several sensors share one Bayou feed.
distmetres, 3 dp, m suffixLatest MB7388 frame (§5).
battvolts, 2 dp, V suffixboard.getBattMilliVolts() (averaged ADC reading).
fwd_hops0–64, or 255Length of the sender's cached path to this target. 255 = no path known, this message will flood. 0 = direct neighbour.
fwd_pathaa.bb.cc or noneThe 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.

How it is sent

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.

ACKs and path learning

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.

4. Parsing and posting on the receiver

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 fieldComes from
node_id, distance_meters, battery_voltsnode_id=, dist=, batt= in the payload
aux_1hops as measured by the receiver on arrival
aux_2fwd_hops= (hops the sender thought it was using)
logpath= from fwd_path=; route= is DIRECT/FLOOD of the arriving packet
sourcethe 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.

5. Reading the ultrasonic sensor

The sensor's output

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 pinRook v4Heltec V4 (as sender)
5 — TTL serial outsilkscreen 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 margin3V3
7 — GNDGNDGND
4 — RX / strobeleave 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.

At boot

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.

Each reading: 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;
}

A reading is taken:

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

6. Setup after flashing

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.

  1. First boot: generate a key. Each node prints 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.
  2. Get each node into the other's contacts. Both directions are needed: the sender needs the receiver's key to encrypt, and the receiver needs the sender's key to decrypt and to ACK. Pick one of these: Check with list on each side.
  3. Sender: choose where to send, and identify the node.
    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.)
  4. Receiver: WiFi and Bayou.
    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.
  5. Verify end to end. Run 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.

7. Quirks worth knowing