ESP32-S3 Remote ID add-on module firmware targeting EU UAS Regulation compliance, built on ESP-IDF and OpenDroneID.
The firmware broadcasts the four mandatory message types required by EU Commission Delegated Regulation (EU) 2019/945 and implementing Regulation (EU) 2021/664, encoded per ASTM F3411-22a (OpenDroneID): Basic ID, Location/Vector, System, and Operator ID. Transmission supports Bluetooth LE legacy advertising and Wi-Fi Beacon advertisements. Payload encoding uses the official opendroneid-core-c library, included as a git submodule.
BLE uses service UUID 0xFFFA and rotates one ODID message per advertisement. Wi-Fi Beacon uses the upstream OpenDroneID Message Pack Beacon frame builder and sends vendor information elements with the ASD-STAN OpenDroneID OUI.
Each BLE advertisement carries one ODID message as a standard Service Data AD structure:
0xFFFA service data = 0x0D | message_counter | 25-byte OpenDroneID message
The 0xFFFA UUID is assigned by the Bluetooth SIG for UAS Remote ID and is the universal identifier all compliant receivers scan for. The 0x0D byte is the Bluetooth SIG Open Drone ID Application Code defined in ASTM F3411-22a. It is part of the standard, not specific to any platform.
Any compliant receiver can read these advertisements:
- Android apps such as
opendroneid/receiver-androidfilter on the0x0Dapplication code to identify Remote ID packets among general BLE traffic. - iOS third-party apps use the
0xFFFAservice UUID for discovery; iOS 16 and later also includes native OS-level Remote ID detection. - Dedicated scanners authority and enforcement hardware reads the same standardised packet format.
ESP32-S3 board with external BLE/WiFi antenna running the Remote ID firmware.
All identity and position fields are set through ESP-IDF's Kconfig system. Run:
make menuconfigand navigate to ESP Remote ID. The sections below explain what each option means and which values are legally required.
Note: The firmware is protocol-compliant with ASTM F3411 and the EU UAS Regulation (EU) 2019/945 / 2021/664 broadcast requirements. Whether your specific operation is legal depends on your national CAA rules, aircraft registration, and operating category. This firmware does not substitute for regulatory advice.
| Option | Description |
|---|---|
| UAS ID | Unique identifier for the aircraft, broadcast in the Basic ID message. Max 20 characters. |
| Operator registration ID | Your national pilot/operator registration number (e.g. an EASA number like FIN87ASTRDGE12K8), broadcast in the Operator ID message. Max 20 characters. |
Set both to your actual registration values before any flight. The placeholder defaults (CHANGE_ME_*) are not valid for operation and intentionally block all broadcasts until replaced or overridden by valid MAVLink OpenDroneID input.
| Drone type | Correct setting |
|---|---|
| Self-built, no manufacturer serial | CAA Registration ID use your national operator registration number as the UAS ID |
| Has a manufacturer serial (ANSI/CTA-2063-A) | Serial Number |
For most self-built drones the UAS ID and Operator ID will be the same string: your national operator registration number. For Belgium, you can find your CAA Registration ID in the portal of "Directoraat Generaal Luchtvaart" after obtaining the necessary licenses.
Select the airframe type that matches your aircraft. Helicopter / Multirotor is the default and covers most DIY multirotors.
| Scenario | Class | Category |
|---|---|---|
| Self-built drone (no CE class label) | Undeclared (default) | Open |
| Factory drone with CE class label | C0 - C6 as marked | Open / Specific / Certified |
Self-built drones do not carry a C-class label. Setting the class to anything other than Undeclared when no CE mark exists is incorrect. The default Kconfig values (Undeclared class, Open category) are the correct starting point for the vast majority of self-built aircraft.
The Location and System messages must be broadcast at 1 Hz regardless of whether position is known. Two modes are supported:
No position available (default: Broadcast a known takeoff position = disabled)
The messages are populated with the ASTM F3411 invalid sentinel values:
| Field | Sentinel value |
|---|---|
| Latitude / Longitude | 0 |
| Altitude (baro, geo, height) | −1000 m |
| Speed (horizontal / vertical) | 255 / 63 |
| Direction | 361° |
| Timestamp | 0xFFFF |
| All accuracy fields | Unknown |
| Status | Undeclared |
This is protocol-compliant. The broadcast system is active and all four message types are transmitted; receivers will decode the messages and display the UAS ID while showing the position as unknown.
Known takeoff position (Broadcast a known takeoff position = enabled)
Enable this option and enter the takeoff coordinates. The firmware latches these values at compile time and broadcasts them as the UA and operator position (EU regulation permits using the takeoff location as the operator location for add-on modules).
| Option | Format | Example |
|---|---|---|
| Takeoff latitude | Degrees × 10⁶ (signed) | 50962290 for 50.962290° N |
| Takeoff longitude | Degrees × 10⁶ (signed) | 4454977 for 4.454977° E |
| Takeoff altitude | Whole metres above WGS-84 ellipsoid | 50 |
Obtain the WGS-84 altitude from a GNSS receiver or an online tool such as https://www.unavco.org/software/geodetic-utilities/geoid-height-calculator/geoid-height-calculator.html.
BLE 4 legacy is enabled by default. All other transports are disabled by default so receiver testing can isolate one transport at a time. BLE 4 legacy and BLE 5 Long Range can run simultaneously; each is an independent task on the same NimBLE host.
Regulatory note on BLE 5 Long Range: EU regulation 2019/945 only recognises BLE 4 legacy advertising and Wi-Fi Beacon as approved DRI transports. BLE 5 Extended Advertising is defined in ASTM F3411-22a (referenced by FAA 14 CFR Part 89) but is not listed in the EU delegated regulation. A BLE 5 only configuration is therefore not compliant in the EU. BLE 5 is disabled by default for this reason.
| Option | Default | Description |
|---|---|---|
Enable Bluetooth LE legacy advertisements |
Enabled | Broadcasts OpenDroneID messages using BLE 4 legacy advertising on service UUID 0xFFFA. Supported by all current receiver apps. |
Enable Bluetooth 5 Long Range (LE Coded PHY) |
Disabled | Broadcasts OpenDroneID messages using BLE 5 extended advertising with LE Coded PHY (S=8). Increases range significantly at the cost of a lower air data rate. Can run alongside BLE 4 legacy. Disabled by default: not an approved transport under EU 2019/945. Valid under ASTM F3411-22a (FAA). Most current receiver apps do not yet support BLE 5. |
BLE advertisement rotation interval |
250 ms |
BLE payload rotation cadence for both BLE transports. Default keeps Location refreshed around 1 Hz while also rotating Basic ID, System, and Operator ID. |
Enable Wi-Fi Beacon advertisements |
Disabled | Broadcasts OpenDroneID Message Pack payloads in Wi-Fi Beacon vendor IEs. |
Enable Wi-Fi NAN advertisements |
Disabled | Broadcasts OpenDroneID Message Pack payloads in Wi-Fi NAN sync/action frames. |
Wi-Fi Remote ID channel |
6 |
2.4 GHz channel used by the ESP32-S3 Wi-Fi transport. |
Wi-Fi TX power |
20 dBm |
Converted internally to ESP-IDF quarter-dBm units. |
Wi-Fi Beacon SSID |
OpenDroneID |
SSID embedded only in Beacon frames. Receivers should parse the vendor IE, not rely on this SSID. |
Wi-Fi Beacon TX interval |
1000 ms |
Wi-Fi Beacon transmission cadence. |
Wi-Fi NAN TX interval |
1000 ms |
Wi-Fi NAN sync/action transmission cadence. |
Enable onboard RGB status indicator |
Disabled | Drives a board-mounted addressable RGB LED as a local status indicator. |
Onboard RGB LED data GPIO |
48 |
Physical ESP32-S3 GPIO connected to the onboard WS2812/SK6812-style RGB LED data input. The UICPAL ESP32-S3-N16R8 board variant used by this firmware uses GPIO48; other variants may use GPIO33 or another pin. |
Onboard RGB LED brightness |
16% |
Scales the local indicator LED brightness. |
Onboard RGB operational flash pattern |
Drone beacon 1 Hz short flash |
Pattern used after transports have started and valid Remote ID identity is available. |
Enable external GPIO lighting outputs |
Disabled | Enables up to five simple GPIO outputs for external light trigger circuits. |
Startup delay before transmissions |
10000 ms |
Development delay before starting BLE/Wi-Fi so there is time to attach the serial monitor. |
The onboard RGB indicator is intended for local module status, not for driving external aviation lights. It uses the ESP32-S3 RMT peripheral to drive one addressable RGB LED, typically a WS2812/SK6812 device on the devboard. The UICPAL ESP32-S3-N16R8 board variant used by this firmware uses GPIO48 for the onboard RGB LED.
Status behavior:
| Firmware state | Onboard RGB behavior |
|---|---|
| Waiting for Remote ID readiness or transport startup | Amber slow blink |
| Operational | Configured green flash pattern |
| Error state | Red fast blink |
Operational means the firmware has started its enabled transports and the state store has a valid Basic ID/UAS ID plus Operator ID. This prevents the indicator from showing the operational pattern while placeholder identity values are still blocking broadcasts.
The RGB data pin is not a suitable transistor trigger for external lights because addressable LEDs use a high-speed encoded data waveform. Use the external GPIO lighting outputs for that purpose.
The external GPIO lighting module provides five independently configurable GPIO outputs. These outputs are logic-level triggers intended for transistor, MOSFET, relay-driver, or opto-isolator inputs. Do not power aircraft lights directly from ESP32 GPIO pins.
Each output has its own configuration:
| Setting | Description |
|---|---|
Enable output N |
Enables this lighting output. |
Output N GPIO |
Physical ESP32-S3 GPIO used as the trigger signal. |
Output N active high |
Controls whether logical ON drives the pin high or low. |
Output N open drain |
Uses open-drain output mode for compatible external circuits. |
Output N operational pattern |
Pattern used when Remote ID transports are started and identity is ready. |
Output N pattern phase offset |
Staggers matching patterns across multiple outputs. |
Outputs remain off until valid Basic ID/UAS ID and Operator ID are available and enabled transports have started.
Available operational patterns:
Solid onBeacon 1 Hz short flashBeacon 1 Hz 50% dutySingle strobeDouble strobeTriple strobeFast strobe
Example setup:
Output 0: GPIO 4, Double strobe, phase 0 ms
Output 1: GPIO 5, Double strobe, phase 500 ms
Output 2: GPIO 6, Beacon 1 Hz short flash
Output 3: disabled
Output 4: disabled
MAVLink input is available but disabled by default. When enabled, the firmware listens on a configured UART and accepts these MAVLink OpenDroneID messages:
OPEN_DRONE_ID_BASIC_IDOPEN_DRONE_ID_OPERATOR_IDOPEN_DRONE_ID_LOCATION
Incoming Basic ID and Operator ID updates feed the same readiness gate as Kconfig values. This allows a flight controller or companion computer to provide identity at runtime while keeping the placeholder Kconfig values in flash. Broadcasts still do not start until a valid Basic ID/UAS ID and Operator ID are present.
| Option | Default | Description |
|---|---|---|
Enable MAVLink OpenDroneID UART input |
Disabled | Enables the UART MAVLink parser and state producer. |
MAVLink UART number |
1 |
ESP32-S3 UART peripheral used for input. This does not imply physical pins. |
MAVLink UART baud rate |
57600 |
UART baud rate. Common values are 57600, 115200, and 921600. |
MAVLink UART RX GPIO |
-1 |
Physical ESP32-S3 pin routed to the selected UART RX signal. Connect this to the flight controller MAVLink TX pin. Must be configured when MAVLink input is enabled. |
MAVLink UART TX GPIO |
-1 |
Optional physical ESP32-S3 pin routed to the selected UART TX signal. Connect this to the flight controller MAVLink RX pin only for bidirectional wiring. Leave as -1 for receive-only. |
Accepted MAVLink target system |
0 |
0 accepts all systems; non-zero accepts that system or broadcast target 0. |
Accepted MAVLink target component |
0 |
0 accepts all components; non-zero accepts that component or broadcast target 0. |
On ESP32-S3, the UART number selects the hardware UART block (UART0, UART1, or UART2). It does not uniquely select board pins. UART signals are routed through the ESP32 GPIO matrix, and many dev boards label only the programming/debug serial pins as RX/TX. Choose GPIOs that are exposed on your board and not already used by USB serial, flash/PSRAM, buttons, LEDs, or strapping functions.
Receive-only wiring is enough for the current MAVLink producer:
Flight controller TX -> ESP32-S3 MAVLink UART RX GPIO
Flight controller GND -> ESP32-S3 GND
DroneCAN input development has started and is disabled by default. This targets the DroneCAN protocol used by ArduPilot and PX4 DroneCAN peripherals and flight-controller buses. DroneCAN is the maintained UAVCAN v0-derived protocol; it is distinct from OpenCyphal/UAVCAN v1. The current milestone brings up the ESP32-S3 TWAI/CAN controller, receives DroneCAN extended CAN frames, reassembles supported multi-frame transfers, and decodes uavcan.equipment.gnss.Fix2 plus a project-specific Remote ID identity message into the Remote ID state.
No extra git submodule is required for this first milestone; it uses ESP-IDF's built-in TWAI driver. A later milestone that decodes DroneCAN DSDL cleanly may add a small UAVCAN/DroneCAN support library or generated DSDL sources, but that should be decided when the target flight-controller messages are confirmed.
| Option | Default | Description |
|---|---|---|
Enable DroneCAN input |
Disabled | Starts the DroneCAN/TWAI receive task. |
DroneCAN RX GPIO |
-1 |
ESP32-S3 GPIO connected to the CAN transceiver RXD output. Must be configured when DroneCAN is enabled. |
DroneCAN TX GPIO |
-1 |
ESP32-S3 GPIO connected to the CAN transceiver TXD input. Must be configured because the TWAI peripheral owns both pins, even in listen-only mode. |
DroneCAN bitrate |
1 Mbit/s |
CAN bus bitrate. Common DroneCAN setups use 1 Mbit/s; match the flight controller bus. |
Local DroneCAN node ID |
0 |
Reserved for future active participation. Current implementation receives only and does not publish DroneCAN transfers. |
Accept anonymous DroneCAN transfers |
Enabled | Allows source node ID 0 message transfers. |
Use CAN listen-only mode |
Disabled | Sniffs the bus without ACKing frames. Leave disabled for normal FC-to-ESP operation. |
Remote ID identity DroneCAN data type ID |
20000 |
Vendor-specific DroneCAN data type ID for the custom Remote ID identity message. Publisher and receiver must match. |
Currently decoded messages:
| DroneCAN message | Data type ID | Remote ID fields updated |
|---|---|---|
uavcan.equipment.gnss.Fix2 |
1063 |
Latitude, longitude, altitude, horizontal speed, vertical speed, track direction, fix status, and coarse accuracy flags. |
com.peinser.remoteid.Identity |
20000 by default |
Basic ID UAS ID/type, UA type, and Operator ID. |
The firmware also recognizes uavcan.protocol.NodeStatus (341) and uavcan.equipment.gnss.Fix (1060) in logs, but Fix2 and the custom identity message are the messages that update Remote ID state in this milestone.
ArduPilot/PX4 DroneCAN does not define a standard Remote ID identity message equivalent to MAVLink OPEN_DRONE_ID_BASIC_ID and OPEN_DRONE_ID_OPERATOR_ID. This firmware therefore supports a project-specific DroneCAN message for publishers that you control.
Use this DSDL definition and configure its data type ID to match REMOTEID_DRONECAN_REMOTEID_IDENTITY_DTID. A suitable file path for a publisher DSDL tree is com/peinser/remoteid/20000.Identity.uavcan:
# com.peinser.remoteid.Identity
# Default data type ID used by this firmware: 20000
uint8 id_type # ASTM/OpenDroneID ODID_idtype_t value
uint8 ua_type # ASTM/OpenDroneID ODID_uatype_t value
uint8[20] uas_id # Null-padded ASCII, max 20 bytes
uint8[20] operator_id # Null-padded ASCII, max 20 bytes
The message is intentionally fixed-size so small publisher implementations do not need dynamic array serialization. If the text fields are shorter than 20 bytes, pad the remaining bytes with zero. If they are exactly 20 bytes, omit the null terminator.
ArduPilot/PX4 will not emit this message by default. A publisher must be added through flight-controller firmware changes, scripting support, or a companion node on the same DroneCAN bus.
By default the ESP32 TWAI controller runs in normal CAN mode. It does not transmit DroneCAN application messages, but it does acknowledge received CAN frames at the bus level. This matters on a two-node bus with only the flight controller and ESP module: without an ACKing receiver, the flight controller will treat its CAN transmissions as failed. Enable listen-only mode only when sniffing an already healthy bus with at least one other ACKing node.
Basic wiring with an external 3.3 V-compatible CAN transceiver:
ESP32-S3 CAN transceiver Flight controller CAN port
-------- --------------- --------------------------
DroneCAN TX GPIO --------> TXD
DroneCAN RX GPIO <-------- RXD
3V3 ---------- VCC (if using a 3.3 V transceiver/module)
GND ---------- GND ------------------------- GND
CANH ------------------------- CANH / CAN_H / H
CANL ------------------------- CANL / CAN_L / L
Use a CAN transceiver that matches the ESP32-S3 IO voltage on TXD/RXD. The ESP32-S3 GPIOs are 3.3 V logic, so a 5 V CAN transceiver module is only safe if its digital IO side is explicitly 3.3 V compatible. Many bare CAN transceiver boards expose VCC, GND, TXD, RXD, CANH, and CANL; connect CANH/CANL only to the bus, never directly to ESP32 GPIO pins.
DroneCAN is a shared two-wire bus. The bus should be one physical trunk with short stubs to each node:
120 ohm terminator 120 ohm terminator
| |
v v
CANH ===+======================+======================+===============+=== CANH
| | |
| | |
FC CAN port ESP32 CAN transceiver Other DroneCAN node
| | |
CANL ===+======================+======================+===============+=== CANL
Termination rules:
- Install exactly two 120 ohm terminators, one at each physical end of the CANH/CANL trunk.
- Do not add a third terminator on the ESP module if the flight controller and another end-of-bus device already provide termination.
- If the ESP transceiver is physically at one end of the bus, enable or install one 120 ohm terminator at the ESP end and one at the opposite end.
- With power off, a correctly terminated bus normally measures about 60 ohm between
CANHandCANLbecause the two 120 ohm terminators are in parallel. - Keep the ESP transceiver stub short. If possible, place it on the trunk rather than at the end of a long branch.
For Android app testing, use one transport at a time because many receiver apps do not display whether a detected aircraft came from BLE or Wi-Fi.
Before flashing for any actual operation:
- Set UAS ID to your aircraft identifier (operator registration number for self-builds)
- Set Operator registration ID to your national CAA/EASA pilot registration number
- Set UAS ID type to CAA Registration ID (unless you have a CTA-2063-A serial)
- Set UA type to match your airframe
- Leave EU equipment class as Undeclared if your drone has no CE class label
- Set EU operation category to Open (or your authorised category)
- Optionally enable Broadcast a known takeoff position and enter your takeoff coordinates
Note: BLE and Wi-Fi transports wait for the store readiness gate before transmitting. The gate requires a usable Basic ID/UAS ID and Operator ID, either from Kconfig or from runtime producers such as MAVLink.
- ESP32-S3 board
- ESP-IDF environment, preferably the included devcontainer
- Initialized submodules:
git submodule update --init --recursiveFor the optional BLE validation script on macOS:
python3 -m pip install bleakOpen the repository in the devcontainer. The container is based on Espressif's ESP-IDF image and includes idf.py, CMake, Ninja, socat, clang-format, and related firmware tooling.
Common commands inside the devcontainer:
make build
make flash
make monitorThe default ESP serial device inside the container is /dev/ttyESP32. Override it with ESPPORT if needed:
make flash ESPPORT=/dev/ttyACM0OpenDroneID BLE reception range is not guaranteed by a fixed distance in the EU documents; practical range depends on transmitter power, receiver hardware, antenna design, enclosure, orientation, and RF environment. This firmware therefore makes BLE advertising TX power explicit and configurable.
The default is +9 dBm, the highest ESP32-S3 BLE level exposed by ESP-IDF:
CONFIG_REMOTEID_BLE_TX_POWER_P9=y
Change it with make menuconfig under ESP Remote ID -> BLE advertising TX power, or by editing sdkconfig.defaults before regenerating sdkconfig. Keep the configured level within the limits of your board, antenna, enclosure, and local RF rules.
Docker on macOS does not expose /dev/cu.* serial devices directly to Linux containers. Use socat to bridge the ESP32-S3 USB serial device from the host into the devcontainer.
On the macOS host:
brew install socat
ls /dev/{cu,tty}.usb*
make bridge-host HOST_SERIAL=/dev/cu.usbmodemXXXXInside the devcontainer, in a second terminal:
make bridge-containerKeep both bridge commands running while flashing or monitoring through /dev/ttyESP32.
After flashing, verify the advertisements from macOS. Pass your configured takeoff coordinates to the --near-lat / --near-lon filters, or omit them to see all OpenDroneID advertisements:
python .dev/scripts/detect-opendroneid-ble.py --timeout 30
# or, to filter by proximity to a known location:
python .dev/scripts/detect-opendroneid-ble.py --timeout 30 --near-lat <lat> --near-lon <lon>Expected output includes app_code=0x0d and message types such as:
type=0 (Basic ID)type=1 (Location)type=4 (System)type=5 (Operator ID)
If the scanner sees valid advertisements but a phone app does not, check that Bluetooth and location permissions are granted and that the app supports BLE legacy Remote ID reception.
Use make menuconfig under ESP Remote ID within the Component config menu to switch transport combinations before building and flashing.
| Test mode | BLE | Wi-Fi Beacon | Wi-Fi NAN | Use case |
|---|---|---|---|---|
| BLE-only | Enabled | Disabled | Disabled | Default Android BLE validation and macOS scanner validation. |
| Wi-Fi Beacon-only | Disabled | Enabled | Disabled | Confirms the receiver can detect Wi-Fi Beacon Remote ID without BLE/NAN ambiguity. |
| Wi-Fi NAN-only | Disabled | Disabled | Enabled | Confirms the receiver can detect Wi-Fi NAN Remote ID without BLE/Beacon ambiguity. |
| Dual Wi-Fi | Disabled | Enabled | Enabled | Tests Beacon and NAN together after each works independently. |
| All transports | Enabled | Enabled | Enabled | Broadcasts all enabled transports after independent validation. |
For Wi-Fi-only testing, flash the firmware and use an Android receiver that supports the specific Wi-Fi transport being tested. If the app does not show a source transport, enable only one Wi-Fi transport at a time so any detection has a known source.
The screenshot below shows the OpenDroneID Android app receiving live advertisements from the module. Current firmware blocks placeholder identifiers (CHANGE_ME_UAS_ID, CHANGE_ME_OP_ID), so replace these with your registration details or provide valid MAVLink OpenDroneID identity before testing reception.
The firmware supports ASTM F3411-22a message set authentication using Ed25519 signatures. When enabled, the four-message set (BasicID + Location + System + OperatorID) is signed on every broadcast cycle and the 64-byte signature is broadcast across four authentication pages.
Each broadcast cycle the firmware:
- Encodes the four base ODID messages into their 25-byte wire format (100 bytes total)
- Signs those bytes with the configured Ed25519 private key
- Distributes the 64-byte signature across four authentication pages (page 0: 17 bytes + metadata, pages 1–3: 23/23/1 bytes)
- Broadcasts the auth pages alongside the base messages
Receivers that have the corresponding public key can verify the signature over the message set they received.
The private key on the device is the leaf key of a standard PKI hierarchy:
Your CA root
└── Device certificate (public key + UAS ID/serial + CA signature)
└── Ed25519 private key (on device, used for signing)
The device certificate binds the public key to the drone's identity (UAS ID, operator, validity period) and is signed by your CA. A web service verifying a broadcast:
- Reads the UAS ID from the BasicID message
- Looks up the device certificate in your registry (keyed by UAS ID)
- Verifies the Ed25519 signature in the auth pages using the certificate's public key
- Verifies the certificate chain back to your CA root
The firmware only needs the private key. The certificate lives in your registry.
- No real-time clock. The timestamp in auth page 0 is seconds since boot, not UTC. This satisfies the wire format but limits anti-replay protection. A GPS or NTP time source would provide a meaningful timestamp.
- Private key security. The key is only as safe as the flash it lives in. Without flash encryption the raw key bytes are readable directly off the chip. See Flash encryption below.
Generate an Ed25519 private key (PKCS#8 PEM format):
openssl genpkey -algorithm ed25519 -out device.pemExtract the public key:
openssl pkey -in device.pem -pubout -out device_pub.pem
openssl pkey -in device.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32The public key is also logged at INFO level on every startup:
I (312) remoteid_auth: Ed25519 authentication enabled, public key: <64 hex chars>
To generate a CSR for CA signing (replace UAS-ID-HERE with the drone's UAS ID):
openssl req -new -key device.pem -out device.csr \
-subj "/CN=UAS-ID-HERE/O=YourOrganisation"Submit device.csr to your CA. Register the signed certificate in your verification service registry, keyed by UAS ID.
The firmware looks for the private key in this order:
- Compiled-in:
REMOTEID_AUTH_PRIVATE_KEY_PEMin Kconfig/sdkconfig. Used during development; takes priority when set. - NVS: NVS namespace
remoteid_auth, keyprivate_key. Used in production; the flash encryption hardware protects it at rest.
If neither source has a key the firmware aborts at startup with an error log.
Format the private key for Kconfig (one line, \n as separator):
awk 'NF {printf "%s\\n", $0}' device.pemEnable authentication and set the key in menuconfig under ESP Remote ID → Authentication, or set directly in sdkconfig:
CONFIG_REMOTEID_AUTH_ED25519=y
CONFIG_REMOTEID_AUTH_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYD...\n-----END PRIVATE KEY-----\n"
A dummy key and placeholder identity are provided in sdkconfig.dev for local development without a real CA:
export SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.dev"
idf.py buildNever flash sdkconfig.dev credentials to a production device.
Leave REMOTEID_AUTH_PRIVATE_KEY_PEM empty in the production build. Provision the key into NVS using the ESP-IDF partition generator before the device is sealed. Follow the procedure below our apply the procedure outlined by your PKI:
-
Generate a per-device key:
openssl genpkey -algorithm ed25519 -out device.pem
-
Provision the key into the device NVS partition before first boot:
make provision-key KEY_FILE=device.pem
or with an explicit port:
python .dev/scripts/provision_key.py device.pem --port /dev/ttyUSB0
The script generates the NVS binary and flashes it in one step. To generate the binary without flashing (e.g. to inspect it or flash it manually later):
python .dev/scripts/provision_key.py device.pem --output nvs.bin parttool.py -p /dev/ttyUSB0 write_partition --partition-name nvs --input nvs.bin
After first boot with flash encryption enabled the NVS partition is encrypted by the hardware and the plaintext binary is no longer accepted.
Flash encryption uses a hardware AES-256 key generated on first boot and stored in eFuse; it never leaves the chip. All flash reads and writes go through the hardware engine transparently; no firmware changes are required.
Enable it in menuconfig under Security features → Enable flash encryption on boot. Choose Development mode during bring-up (allows re-flashing via serial) and Release mode for production units (irreversible; serial flashing is disabled).
Development mode workflow:
idf.py flash # first flash: plaintext, bootloader encrypts on first boot
idf.py encrypted-flash # subsequent flashes: pre-encrypt before writingProduction mode workflow: flash the firmware and NVS partition once before sealing. After first boot the device can only be updated via OTA.
Warning: Release mode permanently burns eFuses on first boot that disable serial flashing and JTAG. This cannot be undone without replacing the SoC. A device in Release mode without a working OTA path is unrecoverable.
To prevent accidental enabling, the build will fail if CONFIG_FLASH_ENCRYPTION_MODE_RELEASE is set without an explicit confirmation. In menuconfig, navigate to ESP Remote ID → Flash encryption Release mode confirmation and type UNRECOVERABLE exactly. The build will be blocked until this string is present.
The firmware includes an optional over-the-air management server. When triggered, the device suspends normal Remote ID operation and starts a Wi-Fi access point with a lightweight HTTP server at http://192.168.4.1. The server provides endpoints for firmware updates, NVS key provisioning, factory reset, and OTA rollback.
OTA mode is required for updating sealed production devices that have flash encryption Release mode active (serial flashing is disabled after first boot in Release mode).
Enable in menuconfig under ESP Remote ID → OTA update server:
| Option | Default | Description |
|---|---|---|
Enable OTA update server |
Disabled | Compile and include the OTA server. |
OTA trigger GPIO |
-1 |
GPIO sampled at boot; hold low (button to GND) to enter OTA mode. -1 disables GPIO triggering. |
Always enter OTA mode on boot |
Disabled | Skip the GPIO check and always start the OTA server. Development only. |
OTA Wi-Fi AP SSID |
RemoteID-OTA |
SSID broadcast while OTA mode is active. |
OTA Wi-Fi AP password |
(empty) | WPA2 passphrase (minimum 8 characters). Leave blank for an open AP. |
OTA Wi-Fi AP channel |
6 |
2.4 GHz channel for the OTA access point. |
OTA HTTP server port |
80 |
TCP port for the HTTP management server. |
Wire a momentary push-button between the configured trigger GPIO and GND. Hold the button while resetting the device. The serial log will show:
I (nnn) remoteid_ota: OTA mode triggered (firmware 1.0.0), starting management server
I (nnn) remoteid_ota: OTA server ready on http://192.168.4.1:80, connect to SSID 'RemoteID-OTA' (WPA2)
I (nnn) remoteid_ota: Endpoints: GET /status POST /update POST /nvs POST /factory-reset POST /rollback
Connect to the AP (default SSID RemoteID-OTA) from a laptop or phone.
The steps below cover the full test cycle from first flash through update and rollback. All make commands assume the devcontainer with ESPPORT set and the device connected via the serial bridge.
The OTA partition table must be on the device before the OTA server can function. A standard make flash writes everything required: bootloader, partition table, initial OTA data, and the firmware image into ota_0.
make flashAfter boot the device runs normally from ota_0. The ota_1 slot is empty and rollback_possible is false.
In menuconfig under ESP Remote ID → OTA update server:
- Enable Enable OTA update server.
- For bench testing without a physical button, also enable Always enter OTA mode on boot. This skips the GPIO check so every reset drops straight into OTA mode; disable it before deploying.
- For hardware testing, set OTA trigger GPIO to the pin wired to your button and leave Always enter OTA mode disabled.
- Optionally set a WPA2 passphrase under OTA Wi-Fi AP password.
Rebuild and reflash after any menuconfig change:
make flashIf Always enter OTA mode is enabled, reset the device. If using a GPIO trigger, hold the button while pressing reset. Confirm the server is up:
make ota-statusExpected output:
{
"firmware_version": "1.0.0",
"idf_version": "v5.5.4",
"running_partition": "ota_0",
"next_partition": "ota_1",
"rollback_possible": false,
"free_heap": 215340
}Make any change to the firmware (for example, bump the version string in CMakeLists.txt or add a log line), then build and upload:
make ota-flashmake ota-flash builds the firmware, reads build/project_description.json to locate the binary, and streams it to POST /update. The device validates the image and reboots into ota_1 roughly 500 ms after the response is received. The terminal will show the curl response:
{"status":"ok","message":"Update applied, rebooting"}Wait a few seconds for the device to reboot, then enter OTA mode again and query status:
make ota-statusrunning_partition should now be ota_1 and rollback_possible should be true, confirming the previous slot is intact.
With rollback_possible: true, test rolling back to the previous firmware:
make ota-rollbackThe device reboots into ota_0. Query status again to confirm running_partition is back to ota_0 and rollback_possible is now false (the ota_1 slot was marked invalid by the rollback).
Repeating steps 4 and 5 alternates between ota_0 and ota_1 on every successful update. Each update overwrites the slot that is not currently running, so there is always exactly one previous firmware available for rollback after a successful update.
If flash encryption Development mode is active, OTA update images are uploaded in plaintext over Wi-Fi and the OTA subsystem writes them encrypted to flash transparently. No encrypted-flash step is required for OTA updates.
A machine-readable OpenAPI 3.0 specification for all endpoints is available at .dev/ota-openapi.yaml. Import it into any OpenAPI-compatible tool (Swagger UI, Insomnia, Postman, Bruno, etc.) for interactive testing against a live device.
All endpoints accept and return JSON (except POST /update which accepts a raw binary body).
Returns firmware and partition information.
curl http://192.168.4.1/status
# or: make ota-status{
"firmware_version": "1.0.0",
"idf_version": "v5.5.4",
"running_partition": "ota_0",
"next_partition": "ota_1",
"rollback_possible": false,
"free_heap": 215000
}Streams a new firmware binary to the next OTA partition and reboots. The device validates the image before setting the boot partition.
# Using curl directly:
curl -X POST http://192.168.4.1/update \
--data-binary @build/remoteid.bin \
-H "Content-Type: application/octet-stream"
# Using make (locates the binary automatically from build/project_description.json):
make ota-flash
# Override target: make ota-flash OTA_HOST=http://192.168.4.1Writes a value to the device NVS. The primary use case is provisioning a new Ed25519 private key to a sealed device.
# Provision a private key (recommended via the provisioning script):
make ota-provision-key KEY_FILE=device.pem
# or: python .dev/scripts/provision_key.py device.pem --ota-url http://192.168.4.1
# Manual JSON example (string value):
curl -X POST http://192.168.4.1/nvs \
-H "Content-Type: application/json" \
-d '{"namespace":"remoteid_auth","key":"private_key","type":"string","value":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"}'
# Binary value (base64-encoded blob):
curl -X POST http://192.168.4.1/nvs \
-H "Content-Type: application/json" \
-d '{"namespace":"my_ns","key":"my_key","type":"blob","value":"<base64>"}'Request body fields:
| Field | Type | Description |
|---|---|---|
namespace |
string | NVS namespace (max 15 characters) |
key |
string | NVS key name (max 15 characters) |
type |
string | "string" or "blob" |
value |
string | Value to store; plain string or base64-encoded bytes for blobs |
Marks the current firmware as invalid and reboots into the previous OTA partition. Requires an explicit confirmation string.
curl -X POST http://192.168.4.1/rollback \
-H "Content-Type: application/json" \
-d '{"confirm":"ROLLBACK"}'
# or: make ota-rollbackReturns HTTP 409 if there is no previous firmware to roll back to.
Erases the NVS partition and reboots. All stored keys and configuration are lost.
curl -X POST http://192.168.4.1/factory-reset \
-H "Content-Type: application/json" \
-d '{"confirm":"FACTORY-RESET"}'
# or: make ota-factory-resetWarning: Factory reset permanently erases the NVS partition. If the device is in Release mode flash encryption and the provisioned private key was the only copy, it is gone. Back up private keys before performing a factory reset.
OTA requires a dual-partition layout. The firmware uses a custom partitions.csv instead of the single-app default:
| Name | Type | Size | Purpose |
|---|---|---|---|
nvs |
data/nvs | 24 KB | NVS key-value store (private key, config) |
otadata |
data/ota | 8 KB | OTA boot slot tracking |
phy_init |
data/phy | 4 KB | RF calibration data |
ota_0 |
app/ota_0 | 2 MB | First firmware slot |
ota_1 |
app/ota_1 | 2 MB | Second firmware slot |
The first flash writes the firmware to ota_0. Subsequent OTA updates alternate between slots. GET /status shows which partition is active and which is next.
- The HTTP server has no authentication beyond the Wi-Fi AP password. Use a strong WPA2 passphrase in any environment with untrusted wireless neighbours.
- OTA mode is only active while triggered. Normal Remote ID operation resumes after every reboot without the trigger asserted.
- When flash encryption Release mode is active, the OTA server is the only way to update firmware or provisioned secrets after the device is sealed.
- The AP MAC address is randomised on every OTA boot to avoid persistent device identification while in management mode.
When authentication is enabled the BLE schedule extends from 8 to 12 slots (3 seconds per full cycle at 250 ms per slot). Auth pages 0–3 are appended after the base message rotation. The Location and System messages are refreshed immediately before signing so auth pages always cover the most recent state.
A web-based firmware configurator allows users to configure and build the firmware without installing the ESP-IDF toolchain. The user fills in a form (UAS ID, operator ID, transports, position, etc.), and the backend generates a sdkconfig overlay, triggers a GitHub Actions build, and returns a ready-to-flash firmware ZIP.
Because the repository is public, GitHub Actions build minutes are free and unlimited.
Browser (web form)
│ POST /build { uas_id, operator_id, transports, ... }
▼
Cloudbuild server (.dev/cloudbuild/)
│ 1. Validate inputs
│ 2. Render sdkconfig overlay
│ 3. Base64-encode overlay
│ 4. Generate correlation UUID (build_id)
│ 5. POST /actions/workflows/cloudbuild.yml/dispatches
│ via GitHub API with { build-id, sdkconfig-overlay-b64 }
│ 6. Return { build_id }
▼
Browser polls GET /build/{build_id}/status
│
▼
Cloudbuild server
→ lists GitHub Actions runs for cloudbuild.yml
→ matches run by name (run-name: cloudbuild-{build_id})
→ returns { status, conclusion, download_url? }
│
▼ (completed + success)
Browser GET /build/{build_id}/download
→ server proxies artifact ZIP from GitHub (token never reaches browser)
The run-name: cloudbuild-{build_id} field on the workflow is what ties a dispatch to a specific poll without needing to store server-side state.
The GitHub Actions build logic is extracted into a reusable workflow at .dev/actions/. Publish that directory's contents into the peinser/actions repository and tag it v1. Any other ESP-IDF project can then use it:
jobs:
firmware:
uses: peinser/actions/.github/workflows/esp-remoteid-build.yml@v1
with:
idf-version: release-v5.5
target: esp32s3
permissions:
contents: readThe cloudbuild server is a Node.js 22 service at .dev/cloudbuild/. It requires a GitHub fine-grained PAT with actions: write and actions: read on this repository.
cp .dev/cloudbuild/.env.example .dev/cloudbuild/.env
# Edit .env: set GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO
docker compose -f .dev/cloudbuild/docker-compose.yml up -dPoint ALLOWED_ORIGIN at your web configurator frontend's origin. Put the service behind a reverse proxy (nginx, Caddy) for TLS before exposing it publicly.
For local development without Docker:
cd .dev/cloudbuild
cp .env.example .env # fill in GITHUB_TOKEN
npm install
npm run dev # runs on http://localhost:3000# Copy the contents of .dev/actions/ into the peinser/actions repository, then:
git tag v1
git push origin main v1The cloudbuild.yml workflow in this repository references peinser/actions/.github/workflows/esp-remoteid-build.yml@v1. Create and tag that repository before triggering any cloudbuild dispatch.
| Endpoint | Method | Description |
|---|---|---|
/healthz |
GET | Health check; returns {"ok":true} |
/build |
POST | Submit a build request; returns {"build_id":"..."} |
/build/:id/status |
GET | Poll build status; returns {status, conclusion, run_url, download_url?} |
/build/:id/download |
GET | Download firmware ZIP (proxied from GitHub) |
POST /build request body:
| Field | Type | Required | Description |
|---|---|---|---|
uas_id |
string | Yes | UAS ID, max 20 characters |
operator_id |
string | Yes | Operator registration ID, max 20 characters |
id_type |
string | Yes | caa_reg or serial |
ua_type |
string | Yes | multirotor, aeroplane, hybrid_lift, or other |
eu_category |
string | Yes | undeclared, open, specific, or certified |
eu_class |
string | Yes | undeclared, c0 through c6 |
transport_ble |
boolean | Yes | Enable BLE 4 legacy advertising |
transport_ble5 |
boolean | Yes | Enable BLE 5 Long Range |
transport_wifi_beacon |
boolean | Yes | Enable Wi-Fi Beacon |
transport_wifi_nan |
boolean | Yes | Enable Wi-Fi NAN |
ble_tx_power |
string | No | n12, n9, n6, n3, n0, p3, p6, or p9 |
ble_tx_interval_ms |
number | No | 100-5000 ms, default 250 |
has_position |
boolean | No | Broadcast a known takeoff position |
takeoff_lat_1e6 |
number | If has_position |
Latitude degrees x 10^6 |
takeoff_lon_1e6 |
number | If has_position |
Longitude degrees x 10^6 |
takeoff_alt_m |
number | If has_position |
Altitude above WGS-84 ellipsoid in metres |
startup_delay_ms |
number | No | 0-60000 ms, defaults to 0 for cloudbuild |
ota_enable |
boolean | No | Include OTA update server |
ota_trigger_gpio |
number | No | GPIO for OTA trigger button (-1 to 48) |
- DroneCAN inputs and state processing.
- Signed OTA, validate firmware binaries against stored public keys before flashing.
