Firmware Internals¶
How the beebo firmware works inside: roles, transports, the clock, diagnostics, the RF bench protocol and board changes. For what each feature does see the features; for updating a node see Update and Service a Node.
Roles and builds¶
Multi-Role: Switching Companion/Repeater¶
examples/multi_role folds companion_radio and simple_repeater into one firmware image. The role is a persisted setting (board.node.role, companion or repeater) and every role-conditional call site — login/ACL handling, self-advert type, self-info replies, packet-forwarding gates — reads the live role. The two roles are mutually exclusive: a node runs exactly one at a time. A role switch needs no reflash but always reboots the node (the user view is on the Roles page). Each role keeps its own identity, prefs, contacts and ACL on flash, loaded once at boot, so the switch itself only changes which role boots (a static-role build, e.g. heltec_v4_3_companion, compiles out the unwanted half entirely).
The role is changed with BEEBO_CMD_SET_NODE_ROLE (binary companion protocol), beebo settings board.node.role, or the node.role <value> text command, all routed through requestNodeRoleSwitch() (examples/multi_role/Beebo.h). On an actual change the firmware:
- Refuses the switch outright while an OTA or bulk monitor-log transfer (
beebo rxlog/monitor) is in flight (ERR_CODE_BAD_STATE) — neither session type has a way to preserve its cursor/handle across a reboot. - Flushes any pending dirty contacts/ACL write under the outgoing role.
- Persists the new role to the board file unconditionally, whatever the autosave state.
- Acknowledges, then reboots into the new role. Because the reboot always follows, the connection drops and a CLI alias's
nodes/<node>/identity stays valid for the life of the connection.
A switch to the role already running is a no-op. node.role has no equivalent on plain-text simple_repeater/companion_radio firmware (which predate the concept) and no -r/--raw CLI form — see board.node.role in the CLI settings reference. Identities can also be swapped as a unit with beebo config switch NAME, which imports the keypair, sets the name, switches the role if needed and reboots in one server-side call.
Static-Role Builds¶
The switchable multi-role build is the right default, but it means every device carries both roles' static state whether or not it ever switches: the companion contact/channel/message store and the repeater ACL/region-map tables are both always loaded. For a device that will permanently run one role, build.sh --companion or build.sh --repeater produce a smaller image with the other role's state compiled out entirely (BEEBO_ENABLE_COMPANION_ROLE/BEEBO_ENABLE_REPEATER_ROLE, examples/multi_role/Beebo.h) instead of just disabled at runtime:
build.sh --companion→heltec_v4_3_companion: compiles out the repeater role's state: the ACL table, key store, region map, the anonymous-login and admin-request handlers.build.sh --repeater→heltec_v4_3_repeater: compiles out the companion role, including the wholeBaseChatMeshbase class, so the contact table, channel table, connection table and offline message queue are absent from the binary.
Static RAM of the --jtag builds: 167,544 B for multi-role, 153,312 B for the companion build (14 KB less) and 138,096 B for the repeater build (29 KB less). The offline message queue lives in PSRAM in every build, so it no longer counts toward a role's saving.
Both are still examples/multi_role firmware, not a fork: same CMD_BEEBO binary protocol, same beebo CLI compatibility, same OTA mechanism. The only user-visible difference is board.node.role: a static build still reports and accepts its own role, but rejects switching to the role it wasn't built with (ERR_CODE_ILLEGAL_ARG over the binary protocol, a plain error over the text node.role command). A device previously flashed with the multi-role build (or the other static-role build) that has an incompatible persisted role falls back automatically at boot to whichever role the new image actually supports — no crash, no corrupted prefs, and the original persisted role is left untouched on flash (not overwritten) so re-flashing multi-role later restores it.
Pick the multi-role build unless a device's role is permanently fixed and the RAM headroom is worth the loss of runtime flexibility — a repeater-only build is the main case this matters for.
Transports¶
Multi-Transport Session Locking¶
MultiSerialInterface aggregates the enabled transports. They all listen simultaneously when no session is active; the first to deliver a frame locks the session and the others are disabled until it disconnects (checkRecvFrame, src/helpers/MultiSerialInterface.h). A non-exclusive USB session can also be pre-emptied mid-flight if an exclusive transport (BLE/WiFi) wants to lock — USB is never allowed to starve wireless.
When the active client disconnects, disconnectActive() cycles only that one transport — disable() then enable() — and releases the lock so the others re-enable, without restarting the whole aggregator. There's no per-client queueing in this class, just a single active-transport index, so there's nothing to "flush" on takeover.
BLE and TCP are enforced as mutually exclusive at every set site, on both companion_radio and simple_repeater firmware — turning one on live-switches the other off (src/helpers/CommonCLI.cpp). If a toggle would otherwise leave both off, USB is force-enabled instead, since disabling USB saves no meaningful power once both radios are off. USB has no live add/remove path, so if that fallback would strand the caller's active BLE/TCP session with no live transport left, the device force-reboots automatically to bring USB up cleanly. Any transport-set command also accepts an explicit --reboot flag (beebo settings --reboot <path> <value>, or the equivalent admin toggle) to force a clean reboot after the change regardless.
SerialBLEInterface::deinitRadio() stops the NimBLE host and controller with BLEDevice::deinit(false), which also deletes the server; initRadio() rebuilds the server, service and both characteristics on the next BLE enable.
Clock¶
Auto Time Sync¶
On every connection the beebo CLI reads the node's clock and compares it to the host's (an RTT-corrected, ms-precision two-round-trip probe-then-sync). A behind-drift is corrected forward unconditionally, no matter how small, straight through RTCClock::getTime()/setTime() (fw/src/helpers/ESP32Board.h) — no warning is printed first, since the firmware's own backwards-jump guard already protects RTC monotonicity, so there's nothing else to gate on. An ahead-drift can't be corrected live (the RTC only ever moves forward without a reboot): beebo clock --boot reboots the node with the correct time. Only a connected host sets the clock; contacts, adverts and other nodes' timestamps never do. This applies over the companion binary protocol and, via the text-CLI equivalents (clock.epoch/time <secs>), over -r/--raw connections too. Apart from a repeater's own admin-only clock sync, the device clock never syncs itself (no NTP, no sync-on-connect in the firmware) — it free-runs and only advances via a connection's own auto-sync, so nodes reconnected after being off mesh for a while don't silently carry a stale clock.
Run beebo clock any time to see the result of that connection's own auto-sync (Device, the clock's value from before the sync; Source, the host time it was compared/corrected against) without triggering a second, redundant sync. Pass --boot to force an immediate corrective reboot when the device is found ahead, instead of waiting for a future organic reboot to apply the recorded drift — --boot is the only case where beebo clock runs its own live round trip rather than reusing the connection's own auto-sync result, since the automatic sync never reboots the device on its own.
RTC Persistence Across Power Cycles¶
A soft reset keeps the RTC alive in RAM, but a true power cycle (ESP_RST_POWERON) would reset it to a fixed epoch. So reboot() saves the current time to NVS before calling esp_restart(), and ESP32RTCClock::begin() restores from that saved value on a power-on boot (src/helpers/ESP32Board.h). Combined with Auto Time Sync, this means a node's clock now survives being fully powered off between visits, rather than only surviving reboots that happen while it stays powered.
Diagnostics and Protocol Additions¶
- Transport debug ring — an in-RAM ring buffer traces connect/disconnect/lock events across BLE/TCP/USB, streamed live by
beebo monitor debug(monitor watchskips the backlog) and correlated to wall-clock time. - MonRing capture ring — a continuous RX/TX monitor ring in free PSRAM records per-packet RSSI/SNR/noise and disposition, backing
beebo monitor/rxlog/analyze. Also carries the tuning optimizer's records (MON_ADAPTIVEdecisions,MON_EVALwindows and blocks,MON_TRIALsetting-trial records), setting-change and command-run records, and a general fault event log (MON_EVENT) — see Performance Tuning and the record kinds. - RSSI in message frames — RX RSSI is written into the two reserved bytes of the V3 synced-message frame (
MSG_INCLUDE_RSSI), so drained messages carry RSSI alongside SNR; wire-compatible, old apps just ignore the bytes. - Extended stats — companion stats commands (
STATS_TYPE_CORE/RADIO/PACKETS, plus system and profile stats) surface heap/PSRAM/flash usage, loop/dup packet counts and the command-latency ring. - Clean session teardown —
CMD_APP_DISCONNECTlets a client end its own transport session without tearing down the others. - Bulk transfer —
BULK_XFERstreams large TX frames and paged MonRing drains without buffering the whole payload. - Streamed OTA — OTA writes stream straight to flash instead of buffering the image in PSRAM first.
RF Bench/Measurement Protocol Additions¶
These are examples/multi_role additions, dispatched as BEEBO_CMD_* sub-commands under the companion protocol's CMD_BEEBO wrapper (examples/multi_role/BeeboProtocol.h, generated from protocol.yaml), not top-level companion commands:
- CW carrier mode —
BEEBO_CMD_SET_CW(sub-id 6) drives a continuous-wave test carrier with a safety auto-stop deadline, for bench TX power measurement. - TX power optimize toggle —
BEEBO_CMD_SET_TX_OPTIMIZE(sub-id 7) switches between RadioLib's efficiency-orientedpaOptTableand a fixed monotonic PA mapping for SX1262. - KCT8103L FEM LNA control —
companion.fem.rxgain/repeater.fem.rxgain(a per-role persisted pref, read and written through the prefs TLV opcodes) toggle the V4.3 front-end module's LNA; disabled by default, reports unsupported on GC1109 (V4.2) boards via auto-detection. - RX boosted gain —
companion.radio.rxgain/repeater.radio.rxgaintoggle the SX1262 RX boosted-gain setting the same way. - RSSI at the antenna connector —
board.fem.rssi_offset.lna/.bypass(default −17/+2 dB) are added to every SX1262 RSSI read for the active FEM path, so packet RSSI and the noise floor read at the antenna connector whateverfem.rxgainis; 0/0 gives raw SX1262 values. See RSSI at the Antenna Connector. - Noise floor restart — changing
rxgain,fem.rxgainor the active RSSI offset restarts the noise-floor average, so it never mixes samples from two RX paths. The radio itself is not reset; the SX1262 AGC reset stays on the upstreamagc_reset_intervaltimer.
Heltec V4 Board FW Changes¶
The beebo build modifies the upstream Heltec V4 board configuration for headless operation:
- Optimized settings — default transmission power is set not to exceed the Power Amplifier (PA) compression point.
- GPS disabled — the bare board has no GPS module (
ENV_INCLUDE_GPS=0). - Button and display removed — compiled out for headless deployment (no
PIN_USER_BTN, noDISPLAY_CLASS). - Board name — reports as "Heltec V4.3 Headless" (or "Heltec V4 Headless" for ≤ V4.2) instead of the OLED/TFT variants.
- LoRa front-end logging — auto-detected PA type (KCT8103L or GC1109) is printed at boot for hardware identification.
- USB CDC on boot — enabled so the USB port is available for serial debug, and as a companion transport when
usbis enabled.