Skip to content

Device Inclusion

Amir Nathoo edited this page May 24, 2026 · 1 revision

Overview

Inclusion is the secure onboarding process by which a fresh device joins a RadioMesh network coordinated by a hub. It performs mutual authentication, exchanges a network-wide encryption key, and persists enough state on the device that it auto-rejoins after reboot without re-running the handshake.

The protocol is inspired by the inclusion sequences used by Zigbee, Z-Wave, and LoRaWAN, adapted for low-power LoRa meshes.

The two sides of the handshake are:

  • A hub (MeshDeviceType::HUB) — broadcasts inclusion availability and validates joining devices.
  • A standard device (MeshDeviceType::STANDARD) — listens for hub broadcasts and requests to join.

Inclusion is fully driven by the framework. The application controls only two things: when the hub enters inclusion mode, and how it reacts to inclusion progress callbacks.

The Inclusion Sequence

The hub and joining device exchange five message types (topics 0x06–0x0A) in a fixed order:

Step Direction Message Topic Encrypted? Payload
1 Hub → broadcast INCLUDE_OPEN 0x08 No Hub announces it is accepting devices
2 Device → hub INCLUDE_REQUEST 0x06 No Device public key + initial counter
3 Hub → device INCLUDE_RESPONSE 0x07 Partial Hub public key + ECDH-encrypted network key + nonce
4 Device → hub INCLUDE_CONFIRM 0x09 Yes (net key) Encrypted nonce (proves key decryption)
5 Hub → device INCLUDE_SUCCESS 0x0A Yes (net key) Inclusion completed

Steps 1 and 2 must be unencrypted: the device does not yet hold the network key, and the hub does not yet know which device is asking to join. Step 3 marks the cryptographic handoff — from that point on, all messages between hub and device use the network key.

The flow is documented in source at src/framework/device/inc/InclusionController.h:14-21.

Device and Hub States

Device state (DeviceInclusionState)

  • NOT_INCLUDED — Device has no network key. Can only send inclusion messages. This is the state of any fresh device.
  • INCLUSION_PENDING — A handshake is in progress.
  • INCLUDED — Device holds a valid network key and can send/receive application messages.

Query the current state with device->isIncluded().

Hub state (HubMode)

  • NORMAL — Hub does not accept new devices. Existing included devices continue to operate normally.
  • INCLUSION — Hub is broadcasting INCLUDE_OPEN and ready to onboard new devices.

Configuring for Inclusion

Standard device

device = builder.start()
                .withLoraRadio(radioParams)
                .withSecureMessaging(securityParams)
                .withRxPacketCallback(onPacketReceived)
                .build("MyDevice", device_id, MeshDeviceType::STANDARD);

No additional builder calls are needed. The device automatically listens for INCLUDE_OPEN once it is built and setup() is called.

Hub

device = builder.start()
                .withLoraRadio(radioParams)
                .withWifiAccessPoint(apParams)
                .withDevicePortal(portalParams)
                .build("MyHub", hub_id, MeshDeviceType::HUB);

The hub typically pairs inclusion with withDevicePortal so an operator can toggle inclusion mode from a web UI (see examples/DeviceInclusion/MiniHub/).

Runtime Control

The application controls inclusion through two IDevice methods:

// Hub-only: start/stop broadcasting INCLUDE_OPEN.
int  enableInclusionMode(bool enable);

// Both: check current network membership.
bool isIncluded() const;

// Both: erase persisted inclusion state, returning the device to NOT_INCLUDED.
int  factoryReset();

Standard devices need no runtime call — joining happens automatically when a hub is in inclusion mode and the device receives INCLUDE_OPEN.

A common hub pattern is to call enableInclusionMode(false) automatically after a successful inclusion to limit the join window (see MiniHub.ino).

Security Model

  • ECDH-style key exchange. Each side generates a fresh keypair per session. The device sends its public key in INCLUDE_REQUEST; the hub uses it to encrypt the network key in INCLUDE_RESPONSE. Only the holder of the matching device private key can decrypt the network key.
  • Plaintext bootstrap window. INCLUDE_OPEN and INCLUDE_REQUEST are unavoidably unencrypted — neither party has a shared secret yet. This window is bounded by the session timeout and validated by the nonce check in step 4.
  • Per-session nonce. The hub includes a 4-byte random nonce in INCLUDE_RESPONSE. The device must echo this nonce, encrypted with the freshly-received network key, in INCLUDE_CONFIRM. This proves the device actually decrypted the network key rather than replaying a captured exchange.
  • MIC authentication. When SecurityMethod::AES is in use, every encrypted packet carries a Message Integrity Code that the receiver validates before accepting the payload. Tampered or replayed packets are dropped silently.
  • Network key scope. Once inclusion completes, the device uses the network key for all application traffic. The device's per-session keypair is no longer needed and is not used for routine messages.

Persistence and Auto-Rejoin

Inclusion state is persisted to flash so a rebooted device re-enters the network without re-running the handshake. The InclusionController stores four items under short keys:

Key Contents
is Inclusion state
mc Message counter (replay protection)
pk Device private key
hk Hub public key

On startup, loadAndApplyNetworkKey() restores the device's crypto context from these values. If they are intact, the device is immediately INCLUDED; if missing or corrupt, it falls back to NOT_INCLUDED and listens for the next inclusion broadcast.

To force a fresh inclusion (e.g. moving a device to a different network), call device->factoryReset(), which wipes these keys.

Timeouts and Failure Modes

  • Session timeout: 60 seconds. Each handshake step has a fixed window. If the peer does not respond in time, the protocol state resets to IDLE and the device returns to NOT_INCLUDED (or, on the hub, stays in inclusion mode awaiting another device).
  • Total timeout: 60 seconds. A complete handshake must finish within this window.
  • Auto factory reset (application policy). The StandardDevice example demonstrates a 5-minute fallback: if the device fails to join within FACTORY_RESET_TIMEOUT, it calls factoryReset() and restarts inclusion from a clean slate. This is application-level policy, not framework-enforced.
  • Silent drops. Packets that fail MIC validation, replay-counter checks, or topic-vs-state validation (e.g. a NOT_INCLUDED device trying to send application data) are dropped without notification. Use the packet callback for visibility.

See Also

Clone this wiki locally