Skip to content

The transports

BLE · USB · TCP · ETHERNET · QUEUES · ONE CLIENT · RECONNECTING

The companion protocol runs over five kinds of connection, and the firmware does not know the difference: above BaseSerialInterface there is only a frame. For a client it is different. This chapter describes what each transport imposes on the side building the app — not what the bytes look like, because that is documented elsewhere.

Note

Source. This page has been verified against the firmware itself: MeshCore v1.17.1, commit d929643, 14 August 2026 — files examples/companion_radio/main.cpp, src/helpers/esp32/SerialBLEInterface.cpp, src/helpers/esp32/SerialBLEInterface.h, src/helpers/nrf52/SerialBLEInterface.h, src/helpers/esp32/SerialWifiInterface.cpp and src/helpers/ArduinoSerialInterface.cpp.

Above a shared interface lives one frame format; below it three
implementations, each with its own demands on the client

Note

The byte layout of the frame header, the four receive states and the BLE stack with GATT and NUS are not here but in USB Serial, WiFi as a companion link and BLE Architecture. This chapter is about the consequences of those for a client.

More than one transport at a time

Up to and including v1.16.0 a build held exactly one transport and the branches in main.cpp were mutually exclusive. Since v1.17.1 that is no longer so. MultiSerialInterface is itself a BaseSerialInterface that holds a set of other interfaces underneath and serves them all at once; main.cpp registers them one by one and then hands the set to the mesh as a single interface:

examples/companion_radio/main.cpp r.188-191

#if defined(BLE_PIN_CODE)
  bluetooth_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
  interface_manager.addInterface(InterfaceType::Bluetooth, &bluetooth_interface);
#endif

The blocks after it have the same shape and are independent: WIFI_SSID adds WiFi, ENABLE_USB_INTERFACE the USB console, ETHERNET_ENABLED ethernet and SERIAL_RX a second hardware serial port. They are no longer else branches, so a build can have several. Which transports are in there is still decided at compile time and is not a setting.

Five kinds, then. InterfaceType knows Bluetooth, USB, WiFi, Ethernet and HardwareSerial (src/helpers/MultiSerialInterface.h r.10-17).

Warning

Five kinds, four slots. MAX_INTERFACES defaults to four, with the comment ble, usb, wifi, ethernet (src/helpers/MultiSerialInterface.h r.5-8). addInterface() looks for the first free slot and returns false when there is none; in main.cpp that outcome is nowhere checked. A build that sets all five #defines therefore loses the last one silently. Among the shipped variants that combination does not occur — the two ethernet targets, thinknode_m7 and rak4631, set no SERIAL_RX — but anyone building a variant of their own runs into a limit that gives no error.

For a client that means the transport is a property of the device someone holds, not something the app can choose — but a device can now offer more than one. A full client therefore supports BLE, serial and TCP. meshcore_py does that with ble_cx, serial_cx and tcp_cx behind one protocol interface; see Architecture of a client.

Note

isConnected() on MultiSerialInterface returns true as soon as one of the underlying interfaces is connected, and writeFrame() writes to every connected interface. Two clients attached at the same time on different transports therefore see each other's replies. The protocol still assumes one client at a time; see What a client must assume per transport.

BLE: the node sends at intervals

The BLE implementation does not write directly but queues frames and drains that queue with a fixed minimum interval:

src/helpers/esp32/SerialBLEInterface.cpp r.189-198

#define  BLE_WRITE_MIN_INTERVAL   60

bool SerialBLEInterface::isWriteBusy() const {
  return millis() < _last_write + BLE_WRITE_MIN_INTERVAL;   // still too soon to start another write?
}

size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
  if (send_queue_len > 0   // first, check send queue
    && millis() >= _last_write + BLE_WRITE_MIN_INTERVAL    // space the writes apart
  ) {

So there are at least sixty milliseconds between two notifications. That lets the node send at most roughly sixteen notifications per second in theory. When fetching three hundred and fifty contacts that is noticeable, and it is the reason CMD_GET_CONTACTS accepts a timestamp so only changes come back.

The send queue is small and differs per platform:

Platform FRAME_QUEUE_SIZE File
ESP32, BLE 4 src/helpers/esp32/SerialBLEInterface.h r.26
ESP32, WiFi 4 src/helpers/esp32/SerialWifiInterface.h r.27
nRF52, BLE 12 src/helpers/nrf52/SerialBLEInterface.h r.24

If that queue fills up, writeFrame() returns zero and the frame is gone — no error reaches the app. A client sending many commands in quick succession without waiting for answers can therefore miss responses unnoticed. Waiting for the response before sending the next command is not politeness but a requirement.

TCP: one client at a time

The WiFi implementation accepts a new connection by throwing out the existing one:

src/helpers/esp32/SerialWifiInterface.cpp r.57-68

  auto newClient = server.available();
  if (newClient) {

    // disconnect existing client
    deviceConnected = false;
    client.stop();

    // switch active connection to new client
    client = newClient;

    // forget received frame header
    resetReceivedFrameHeader();

Two apps on the same node over TCP is therefore not shared access: the new connection drops the existing one without any protocol notification. The displaced client only notices that its socket is closed. With BLE the same holds by a different route: one GATT connection per radio, GATT being the Bluetooth Low Energy layer these frames travel over. See BLE Architecture.

That is the technical underpinning of what Responsibilities already noted: whoever synchronises first empties the queue, and the second app never sees those messages.

Serial: the firmware cannot detect an unplugged cable

The serial implementation cannot know whether anything is at the other end and therefore always answers affirmatively to isConnected(). For a client that means no event arrives when the cable is pulled. The only detection left is a response that fails to arrive within a time limit.

What a client must assume per transport

On serial and TCP a continuous run of bytes arrives — a byte stream — with no marker where one frame ends and the next begins. The client determines that boundary itself from the header described in USB Serial. On BLE, GATT delivers each notification as a self-contained block.

BLE USB serial TCP
Frame boundary given by GATT extract from the byte stream extract from the byte stream
Connection drops noticeable not noticeable noticeable
Multiple clients no no no, the new one displaces
Pace at least 60 ms per frame as fast as the port as fast as the network
Reconnection needed yes, regularly rarely on a network change

The bottom row matters most. After every reconnection app_target_ver on the node is zero again and the opening has to be redone: first CMD_APP_START, then CMD_DEVICE_QUERY. See The interaction model.

Sources

Firmware, commit d929643 (v1.17.1, 14 August 2026):

Related chapters:

Translated from Dutch by Anthropic Claude