Beatled Protocol

Binary UDP protocol for communication between the server (Raspberry Pi 4) and LED controllers (Pico W, ESP32, or POSIX simulator).

Transport

ChannelPortDirectionDescription
UDP9090Device → ServerHELLO / TIME / TEMPO requests
UDP8765Server → DevicesNEXT_BEAT / BEAT / PROGRAM / responses
HTTPS8443Client → ServerWeb client REST API

All multi-byte fields are in network byte order (big-endian). All structs are packed (__attribute__((__packed__))).

Server-side delivery for the per-beat and PROGRAM messages is configurable via --broadcast-mode={unicast,subnet,limited}. The default is unicast — the server sends one packet per registered client, with NEXT_BEAT timestamps adjusted per-client by that client’s measured one-way delay (reported in TEMPO_REQUEST). See docs/deployment.markdown for when to pick each mode.

Protocol version

This page documents protocol v2. Server and firmware build against a single shared header (controller/lib/beatled_protocol/include/beatled/protocol.h); upgrading one without the other is not supported.

Message Format

Every message starts with a 1-byte type header:

┌───────┐
│ type  │  uint8_t — beatled_message_type_t
└───────┘

Message Types

TypeValueDirectionTransport
ERROR0Server → DeviceUnicast
HELLO_REQUEST1Device → ServerUnicast
HELLO_RESPONSE2Server → DeviceUnicast
TEMPO_REQUEST3Device → ServerUnicast
TEMPO_RESPONSE4Server → DeviceUnicast
TIME_REQUEST5Device → ServerUnicast
TIME_RESPONSE6Server → DeviceUnicast
PROGRAM7Server → DeviceUnicast
NEXT_BEAT8Server → DeviceUnicast
BEAT9Server → DevicesBroadcast

ERROR (0)

Sent by the server when a request cannot be processed.

OffsetFieldTypeDescription
0typeuint8_t0
1error_codeuint8_tError code (see below)

Size: 2 bytes

Error codes:

CodeNameDescription
0UNKNOWNUnspecified error
1UNKNOWN_MESSAGE_TYPEUnrecognized message type
2NO_DATANo data available (e.g. no tempo detected yet)

HELLO_REQUEST (1)

Sent by a controller to register with the server. Contains the device’s unique board ID as a hex string.

OffsetFieldTypeDescription
0typeuint8_t1
1board_idchar[17]Null-terminated hex string (8 bytes × 2 hex chars + NUL)

Size: 18 bytes


HELLO_RESPONSE (2)

Sent by the server to acknowledge registration and assign a client ID.

OffsetFieldTypeDescription
0typeuint8_t2
1client_iduint16_tAssigned client identifier

Size: 3 bytes


TEMPO_REQUEST (3)

Sent by a device every ~10 s to refresh the tempo state and report its current one-way-delay estimate.

OffsetFieldTypeDescription
0typeuint8_t3
1owd_us_estimateuint32_tController’s most recent median(RTT)/2 in microseconds; 0 = no sample yet, do not compensate

Size: 5 bytes (was 13 in protocol v1 — the unused beat_time_ref / tempo_period_us echoes were dropped; the OWD field is new)

The server uses owd_us_estimate to compensate NEXT_BEAT timestamps per-client. See Controller Sync for the full round-trip.


TEMPO_RESPONSE (4)

Server response with the current tempo state.

OffsetFieldTypeDescription
0typeuint8_t4
1beat_time_refuint64_tReference beat timestamp (microseconds since epoch)
9tempo_period_usuint32_tBeat period in microseconds
13program_iduint16_tActive LED program ID

Size: 15 bytes


TIME_REQUEST (5)

Sent by a device to initiate NTP-style clock synchronization. The device records its local time as orig_time before sending.

OffsetFieldTypeDescription
0typeuint8_t5
1orig_timeuint64_tDevice’s local time at send (microseconds)

Size: 9 bytes


TIME_RESPONSE (6)

Server response containing three timestamps for clock offset calculation.

OffsetFieldTypeDescription
0typeuint8_t6
1orig_timeuint64_tEchoed from request (device’s send time)
9recv_timeuint64_tServer’s time when request was received
17xmit_timeuint64_tServer’s time when response was sent

Size: 25 bytes

Clock Offset Calculation

Using the NTP symmetric algorithm:

T1 = orig_time    (device send time, device clock)
T2 = recv_time    (server receive time, server clock)
T3 = xmit_time    (server transmit time, server clock)
T4 = local time   (device receive time, device clock)

offset = ((T2 - T1) + (T3 - T4)) / 2

The offset is added to device local timestamps to convert them to server time. Multiple rounds of time sync are performed to improve accuracy.


PROGRAM (7)

Sent by the server to change the active LED program on a device. In protocol v2 the server pushes a PROGRAM on every state change and a low-rate (~1 Hz) refresh, so late joiners and packet loss don’t strand controllers on a wrong pattern. The seq field lets controllers ignore stale duplicates and out-of-order pushes.

OffsetFieldTypeDescription
0typeuint8_t7
1program_iduint16_tNew LED program ID
3sequint16_tMonotonically-increasing sequence number (wraps at 65535)

Size: 5 bytes (was 3 in protocol v1 — seq was added)


NEXT_BEAT (8)

Sent by the server to each registered device before each beat (unicast by default). Devices use next_beat_time_ref to schedule LED updates at the precise moment, and seq to detect packet loss.

OffsetFieldTypeDescription
0typeuint8_t8
1next_beat_time_refuint64_tPredicted time of next beat (microseconds, server clock). In unicast mode this value is per-client: the server subtracts the client’s owd_us_estimate so the embedded time equals the intended hit instant on the client’s clock when the packet arrives.
9beat_countuint32_tRunning beat counter
13sequint16_tMonotonically-increasing sequence number (wraps at 65535); controllers detect loss via gaps and ignore stale duplicates

Size: 15 bytes (was 19 in protocol v1 — tempo_period_us and program_id were dropped from the per-beat path, and seq was added; period now comes only from TEMPO_RESPONSE, program only from PROGRAM)


BEAT (9)

Defined for parity with the beat-detector callback; not currently emitted by the live server. Same shape as NEXT_BEAT.

OffsetFieldTypeDescription
0typeuint8_t9
1beat_time_refuint64_tTime of detected beat (microseconds, server clock)
9beat_countuint32_tRunning beat counter
13sequint16_tMonotonically-increasing sequence number

Size: 15 bytes


See Controller Registration and Synchronization for the full startup sequence and state machine walkthrough.