Skip to content

Companion radio + MQTT observer firmware (WiFi) - #94

Draft
agessaman wants to merge 33 commits into
observer-firmware-devfrom
feat/companion-observer
Draft

agessaman wants to merge 33 commits into
observer-firmware-devfrom
feat/companion-observer

Conversation

@agessaman

@agessaman agessaman commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Draft. Adds a companion radio firmware that also runs the MQTT observer, for #77. A node can be flashed between the repeater observer and the companion observer and keep its MQTT configuration and identity.

What changes

New role

  • Five WiFi environments: heltec_v4_companion_observer_wifi, heltec_v4_expansionkit_companion_observer_wifi (adds the expansion kit's sensor I2C bus), Heltec_v3_companion_observer_wifi, Xiao_S3_WIO_companion_observer_wifi, Waveshare_S3_LR1121_companion_observer_wifi. They are not named *_observer_mqtt, so the release workflows do not build them.
  • build-companion-observer-alpha.yml builds only these envs when a compobs-alpha-* tag is pushed and publishes them as a GitHub prerelease, with no flasher manifests or OTA channel.
  • The companion reads the same /mqtt.json and /identity/_main as the repeater observer. With no WiFi SSID in /mqtt.json it adopts the companion's own WiFi credentials once.
  • The observer CLI (get/set mqtt.*, wifi.*, memory, diagnostics) is reachable from USB serial, the app's CLI command and remote admin.
  • The status message gains an additive role field (repeater, room_server, companion) so brokers can tell the roles apart. client_version is unchanged.

Decoupling from the repeater

  • Observer config and CLI moved out of CommonCLI into ObserverConfig / ObserverCLI.cpp (renamed from CommonCLI_Observer.cpp), behind an ObserverCallbacks seam. MQTTBridge reads node name, radio parameters, forwarding state and role through MQTTBridgeHost instead of the repeater's NodePrefs. The repeater and companion each have a NodePrefs with virtuals, so linking both was an ODR clash.
  • memory and stats-radio-diag moved into a shared NodeDiagCLI.
  • CompanionObserver drives the bridge through ObserverBridgeControl, the same start / restart / late-stop-ack sequencer the repeater and room server use, with a self-heal platform that is compiled out.

Companion specifics

  • Self-heal is refused: set mqtt.selfheal returns an error and no reboot driver runs, because a phone may be attached. The saved value is left alone for a later repeater flash.
  • ota check, ota update and the upload portal are not supported in this version.
  • On PSRAM boards the mesh object is built in PSRAM from setup(). On the Heltec V4 with five brokers that took free internal heap from about 37 KB to about 160 KB.
  • Non-PSRAM boards keep the two-slot cap.
  • The Heltec V4 env advertises mDNS (_meshcore._tcp). The network link brings WiFi up after setup(), so observer builds start the responder on the first link-up and retry a failed start on the next one. Plain companion builds are unchanged.
  • The V4 and Xiao envs embed the full root bundle (adafruit-full), matching the repeater observer envs on those boards.
  • The panic core dump is read back at boot and published once, as on the repeater.

Shared fixes found on the way

  • The JWT username is rebuilt when the device identity changes.
  • Observer CLI replies are bounded, and slot counts read safely before the bridge exists.

Behavior to be aware of

  • Both roles write /prefs.json with their own schema, and the upstream serializer drops keys it does not know. A companion settings save therefore resets repeater-only fields, including the repeater admin password, after a flash back. This matches upstream and is documented in MQTT_IMPLEMENTATION.md; it is not changed here.
  • A slot-wide set (for example mqtt.iata) restarts the bridge synchronously, about 5 to 6 s per slot, and the app link and mesh loop stall for that time. The repeater behaves the same way.
  • Repeater and room-server builds change only through the refactor and the new role field.

Testing

  • 783 native tests pass (after merging observer-firmware-dev at 34e658e20). New: ObserverBridgeControl with the self-heal driver unsupported, on a moving clock (it fails if the platform is flipped to supported), and role beside the widest crash and self-heal status fields.
  • Builds: the five companion observer envs, heltec_v4_companion_radio_wifi, Heltec_v3_repeater_observer_mqtt, heltec_v4_repeater_observer_mqtt, Heltec_v3_room_server_observer_mqtt.
  • Heltec V4 (PSRAM, five slots) on this branch: five slots connected about 20 s after reset; a full bridge restart stopped cleanly and all five were back 17 s later; a single-slot restart reconnected in 4 s; after a five-minute hold all slots were connected with 152 KB internal heap free, 139 KB largest block. Self-heal and OTA commands were refused. mDNS resolved meshcore_<name>._meshcore._tcp.local.:5000 after boot and again after a bridge restart. App TCP port 5000 accepted a connection.
  • Heltec V3 (no PSRAM, two slots, stock TLS): both slots connected 11 s after reset; bridge restart and slot restart both clean; after a five-minute hold both slots connected, 52 KB free, 37 KB largest block.
  • Round trip: both boards were flashed app-only from repeater observer to companion observer and back. They came back as repeaters with every slot connected and the saved mqtt.selfheal value unchanged.
  • Two source reviews of the merge with observer-firmware-dev and of the ObserverBridgeControl adoption; the findings (mDNS start latch, a test that could not fail) are fixed in the last two commits.

Not tested

  • Xiao S3 WIO on hardware (build only).
  • The app over BLE or TCP beyond opening the port, and contact or channel persistence, on this tip. Earlier tips of the branch ran an app session on the V4 and a soak on the V3.
  • mDNS across a WiFi outage.
  • The late-stop-ack resume path on hardware (host tests only).
  • Heltec V4 expansion kit on hardware (build only).
  • Waveshare S3 LR1121 on this tip (build only). The test board's 128 KB SPIFFS is full of /bl/* files and logs fopen(/spiffs/contacts3) failed, so contact persistence was never validated there.

Addresses #77.

Companion and repeater firmware share /prefs.json, so a repeater's
forwarding setting (repeat.disable = 0) carries over when a device is
reflashed to companion. The client-repeat frequency allowlist was only
checked in CMD_SET_RADIO_PARAMS, so the companion repeated at whatever
frequency it booted on.

Apply the allowlist when deciding to forward, and report the same
effective value in the device-info frame. Check the frequency last
applied to the radio rather than _prefs.freq, because CLI "set radio"
stores new params without retuning until reboot. The stored flag is
left alone, so a later prefs save can't flip a repeater's forwarding
off when the device is flashed back.
…ePrefs

The observer stack only worked inside CommonCLI hosts: /mqtt.json load/save
lived on CommonCLI, the observer CLI was a set of CommonCLI members, and
MQTTBridge read the repeater NodePrefs directly. A companion build has its own
NodePrefs class of the same name, so it could host none of it.

- ObserverConfig owns MQTTPrefs, /mqtt.json load/recovery/atomic save (moved
  verbatim from CommonCLI.cpp) and the observer CLI (CommonCLI_Observer.cpp ->
  ObserverCLI.cpp). Host hooks go through ObserverCallbacks; CommonCLI keeps
  its public API and forwards via a nested adapter.
- MQTTBridge takes an MQTTBridgeHost (node name, radio params, forwarding,
  role) instead of NodePrefs; repeater and room server implement it.
- Status messages gain an additive "role" field (repeater / room_server) so
  brokers can tell observer hosts apart. client_version is unchanged.

No change to the /mqtt.json format or recovery behavior.
A companion build can now also run the MQTT observer, so one node serves the
app over WiFi/TCP and uplinks heard packets to MQTT brokers (#77).

- CompanionObserver wraps ObserverConfig and MQTTBridge behind a header that
  includes neither, keeping the repeater NodePrefs out of companion code.
- /mqtt.json and the identity are shared with the repeater observer, so a
  node reflashed between the two roles keeps its MQTT/WiFi setup. A stored
  companion WiFi SSID is adopted into /mqtt.json once if it has none.
- The observer network link owns WiFi; the app's TCP listener (port 5000)
  opens once the link has an address.
- Observer commands (mqtt*, wifi.ssid/pwd, timezone, ...) are served by the
  companion CLI: USB serial console, the app's CLI command, and remote admin.
- Status messages report role "companion"; OLED shows MQTT slots up.
- New envs heltec_v4_companion_observer_wifi and
  Heltec_v3_companion_observer_wifi (not *_observer_mqtt, so release CI
  skips them for now). V3 has no PSRAM: 150 contacts, 32-frame offline
  queue, one broker slot (new MQTT_MAX_ACTIVE_SLOTS_NO_PSRAM override).
…een roles

Covers configuration surfaces, what carries over when reflashing between the
companion and repeater observer (/mqtt.json, identity, name, radio) and what
does not (repeater-only /prefs.json keys, including the admin password), the
shared repeat.disable setting with opposite defaults, and V3 limits.
The board from #77. PSRAM, so it keeps the normal companion limits.
…D edge cases

- Echoed config in "unknown config"/"??" replies and the "set radio" copy
  were unbounded; long CLI lines (160-byte serial buffer, app CLI frames)
  overflowed the reply or a 132-byte stack buffer. Affects repeaters too.
- The OLED polled getSlotStatusSnapshot(), which reads a slot's client the
  MQTT task may delete. New getSlotCounts() reads only the slot flags.
- Pass the caller's sender_timestamp to the observer CLI so remote admin
  gets secrets masked, as on the repeater.
- Restart the bridge after a private key import so the device ID and JWT
  username follow the new identity.
- Do not adopt a 32-character companion SSID into /mqtt.json (31 max);
  a truncated SSID would join nothing.
The v1_<pubkey> username was cached for the bridge's lifetime, so after a
companion private key import the CONNECT username kept the old key while
tokens were signed by the new one. setDeviceID() now clears it on change.
…bserver

Same output format as the repeater's memory reply, so heap tooling and
habits carry over; it was missing because it lives in CommonCLI.
On a Heltec V4.2 with five brokers, internal heap fell to 29 KB free with a
14 KB largest block: the companion's contact table, offline queue and frame
buffers (~120 KB) sat in internal DRAM next to WiFi and the MQTT slots.
PSRAM is initialized at boot on this framework, so allocate the_mesh there
(zeroed, as a static object would be) and keep a reference. Static internal
RAM drops from 177 KB to 54 KB. Falls back to internal heap when no PSRAM is
fitted. Only ESP32 PSRAM boards with WITH_MQTT_BRIDGE; stock companion
builds are unchanged.
Both are fork additions that lived inline in the upstream-tracked
CommonCLI.cpp, so the companion could not use them. NodeDiagCLI holds them
for every host; CommonCLI delegates with one call (adapter over
CommonCLICallbacks) and the companion radio implements NodeDiagHost in all
builds. Output formats and the local-only rule for stats-radio-diag are
unchanged. Replaces the interim memory copy in CompanionObserver.
…atic init

The V4 (qio_qspi) framework config does not initialize PSRAM at boot;
Arduino's psramInit() runs after static constructors, so the static-init
PSRAM allocation silently fell back to internal heap and freed nothing.
Construct the mesh at the start of setup() behind a pointer, with the_mesh
as a macro over it. Measured on a Heltec V4.2 with five brokers: internal
heap 36.8 KB free / 25.6 KB largest block before, 159.7 KB / 135 KB after.
Measured on hardware with the app connected and two TLS slots up: 51 KB
internal heap free, 34.8 KB largest block, 36 KB minimum since boot, which
leaves room for a TLS reconnect. One slot left 114 KB free. Contacts (150)
and offline queue (32) stay trimmed.
The companion observer had its own start, restart and late-ack resume logic.
It now uses the sequencer the repeater and room server share, with a disabled
self-heal platform so a saved mqtt.selfheal setting can never reboot it.
…ver envs

The repeater observer envs on these boards use adafruit-full, so a custom TLS
broker that verified as a repeater could fail after a flash to the companion.
The network link brings WiFi up after setup(), so observer builds start the
responder on the first link-up instead of in setup().
With time fixed at zero the driver could not have acted either way.
Push a compobs-alpha-* tag to build the *_companion_observer_wifi envs and
publish them as a GitHub prerelease. No flasher manifests or OTA channel:
companion observers refuse `ota`. The observer workflows still skip these
envs, which do not match *observer_mqtt.
Same as heltec_v4_companion_observer_wifi plus the kit's sensor I2C bus
(ENV_PIN_SDA=4, ENV_PIN_SCL=3), matching the expansion kit repeater envs.
Cherry-picked from fix/companion-mdns-rename (f60aa6085), keeping the observer's link-up mDNS start.
Setting the hostname sends a goodbye and re-announces every record, so a
rename that only changes characters stripped from the hostname made the
service briefly disappear for browsers. Update only the node-name TXT
record in that case.
…not retry

Arduino-ESP32 reconnects by itself only for a fixed list of disconnect
reasons. After any other one, such as the vendor code 220 seen on a
Meraki network, the station sat idle until the first 15 s rung of the
reconnect ladder. Make one reconnect attempt 2 s after such a drop, once
per outage; the ladder still handles every later attempt.
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.

2 participants