ModuleDevices — Developer Reference
Source: src/MoonBase/Modules/ModuleDevices.h
ModuleDevices handles automatic discovery of MoonLight and WLED devices on the local network and provides group-synchronized light control across multiple devices. It uses two UDP sockets with strictly separated responsibilities.
UDP Port Architecture
| Port | Socket | Direction | Who uses it |
|---|---|---|---|
| 65506 | deviceUDP |
Bidirectional | MoonLight and WLED — shared discovery port |
| 65507 | deviceControlUDP |
Bidirectional | MoonLight only — WLED never listens here |
The key invariant: control commands never go on port 65506. WLED listens on 65506 for device metadata. Sending control commands on that port caused WLED to misinterpret them as malformed discovery packets, corrupting its state. Port 65507 is MoonLight-private.
Packet Formats
UDPWLEDHeader — 44 bytes, WLED-compatible
byte 0: token — must be 255 (WLED validates this)
byte 1: id — must be 1 (WLED validates this)
byte 2–5: ip0–ip3 — sender IP (WLED checks ip0 == localIP[0] as subnet check)
byte 6–37: name — null-padded hostname (32 bytes)
byte 38: type — board type (32=ESP32, 33=S2, 34=S3, 35=C3, 36=P4) | 0x80 if lights on
byte 39: insId — last IP octet; WLED uses this as instance index
byte 40–43:version — numeric build date, YYYYMMDD parsed from APP_DATE
A static_assert(sizeof(UDPWLEDHeader) == 44) enforces this at build time. This layout matches UDPWLEDMessage in StarLight/SysModInstances.h and the WLED instances protocol.
UDPMessage — full MoonLight discovery packet (port 65506)
[UDPWLEDHeader — 44 bytes] ← WLED reads only this portion
[char versionStr[32] ] ← human-readable version, e.g. "10.0.1"
[char build[16] ] ← build date string, e.g. "20260411"
[uint32_t uptime ]
[uint16_t packageSize ] ← sizeof(UDPMessage); receiver uses for size-based dispatch
[uint8_t brightness ]
[uint8_t palette ]
[uint8_t preset ]
Total: 101 bytes with __attribute__((packed)). WLED receives all 101 bytes but only parses the first 44; the rest is ignored. MoonLight receivers recognise the full packet by size.
UDPControlMessage — MoonLight-only control (port 65507)
[UDPWLEDHeader — 44 bytes] ← sender identification
[char targetName[32] ] ← unicast: receiver hostname; group broadcast: empty string
[uint8_t brightness ]
[uint8_t lightsOn ]
[uint8_t palette ]
[uint8_t preset ]
Total: 80 bytes with __attribute__((packed)). WLED never sees this packet.
Discovery Flow (port 65506)
loop10s() calls sendUDP(false), which:
- Builds a
UDPMessagewithtoken=255, id=1, correct IP bytes, board type, and current control state. - Broadcasts to
255.255.255.255:65506. - Calls
updateDevices()on itself (UDP does not loop back to the sender).
receiveUDP() dispatches incoming packets on port 65506 by size:
| Packet size | Interpretation | Handler |
|---|---|---|
sizeof(UDPWLEDHeader) = 44 |
WLED device | updateDevicesWLED() — limited fields (name, ip, lightsOn only) |
sizeof(UDPMessage) = 101 |
MoonLight device | updateDevices() — all fields |
| anything else | Unknown / corrupted | Log warning, discard |
Both handlers also validate token==255 && id==1 before accepting a packet.
Why WLED gets limited fields
WLED's 44-byte discovery packet does not include brightness, palette, or preset — those travel on WLED's separate sync port (21324, UDPWLEDSyncMessage). MoonLight currently does not listen on 21324, so WLED devices show brightness=0, palette=0, preset=0 in the device table. This could be extended in a future iteration by adding a listener on the WLED notifier port.
Control Flow (port 65507)
From the device table UI (onUpdate())
When a user edits a row in the devices table, onUpdate() fires and dispatches on three cases:
This device (targetIP == activeIP):
Apply directly via _moduleControl->update(..., "unicast"). The addUpdateHandler then broadcasts to the group and sends a status update.
Remote device, same group (partOfGroup matches in either direction between sender and target hostnames):
Send a UDPControlMessage group broadcast to 255.255.255.255:65507 with empty targetName. All group members receive it in one packet and apply it via processControlMessage().
Remote device, different group:
Send a UDPControlMessage unicast to targetIP:65507 with targetName set to the receiver's hostname. The receiver applies the change and then re-broadcasts to its own group (see loop prevention below).
Receiving a control packet (processControlMessage())
processControlMessage() is called for every 80-byte packet arriving on port 65507:
// getSystemHostname() returns String by value — stored to avoid a dangling char* pointer
String myName = esp32sveltekit.getSystemHostname();
if (myName == senderName) return; // ignore own echo
bool isUnicast = (targetName[0] != '\0') && (myName == targetName);
bool isGroupBroadcast = (targetName[0] == '\0') && partOfGroup(myName, senderName);
isUnicast→ apply with origin"unicast".isGroupBroadcast→ apply with origin"group".- Neither → discard silently.
Group broadcast control — from addUpdateHandler
Any local light control change fires addUpdateHandler:
if (originId != "group") sendUDP(true); // broadcast control to group; suppressed only when change arrived via group broadcast
sendUDP(false); // always broadcast status so all device tables update immediately
sendUDP(true) builds a UDPControlMessage with empty targetName and broadcasts to 255.255.255.255:65507.
Loop prevention
| Origin when applying | Source | sendUDP(true) fired? |
|---|---|---|
| numeric (WebSocket client ID) | UI change on control panel | yes → group broadcast |
"unicast" |
received unicast from different group, or self-targeted device table edit | yes → re-broadcasts to own group |
"group" |
received group broadcast | no — loop stops here |
A device that applies a group broadcast uses origin "group", suppressing sendUDP(true) and stopping propagation after one hop.
Hostname constraint: the string "group" is a reserved origin sentinel. A device whose hostname starts with group followed by a hyphen (e.g. group-1) would create an ambiguity only if "group" were passed as a hostname in a partOfGroup call — which it never is. The actual risk is more subtle: if any future code path emits origin "group" for a non-broadcast reason, loop prevention breaks silently. The sentinel must remain unique; do not reuse it for any other purpose.
Group Naming
partOfGroup(base, device) uses hostname prefix matching via hyphens:
base = "kitchen-1"→ prefix ="kitchen"→ matches"kitchen-2","kitchen-3", etc.- Devices without a hyphen only match themselves exactly.
This is the sole mechanism for group membership — no configuration required beyond the hostname set in the WiFi STA module.
Regression risk: the matching logic has easy-to-introduce edge cases. Before changing partOfGroup, verify these cases:
base |
device |
Expected |
|---|---|---|
kitchen-1 |
kitchen-2 |
same group ✓ |
kitchen |
kitchen-1 |
different — kitchen has no hyphen, stands alone |
kitchen-1 |
kitchenette-1 |
different — prefix kitchen ≠ kitchenette |
a-b-1 |
a-b-2 |
same group — prefix is a-b |
a-b |
a-bc-1 |
different — a-b has no hyphen after b, stands alone |
The existing unit tests in test/test_native/ cover partOfGroup. Any change to the matching logic must update those tests; the cases above are the ones most likely to regress.
Socket Lifecycle
Both sockets are initialised in loop10s() on first successful network connection:
deviceUDPConnected = deviceUDP.begin(65506);
deviceControlUDPConnected = deviceControlUDP.begin(65507);
loop20ms() guards on deviceUDPConnected || deviceControlUDPConnected before calling receiveUDP(). Inside receiveUDP(), the discovery section (port 65506) runs unconditionally, but the control section (port 65507) returns early if deviceControlUDPConnected is false — so a failed control socket bind does not block discovery processing.
sendUDP(true) guards on deviceControlUDPConnected — if port 65507 fails to bind, group control broadcasts are skipped with a LOGW.
Protocol Versioning / Breaking Changes
Struct size contract
The packageSize field in UDPMessage carries sizeof(UDPMessage), and receiveUDP() dispatches on exact size matches — any packet whose size does not equal a known struct size is logged as unknown and discarded. This means adding, removing, or reordering any field in any of the three structs is a breaking protocol change.
Current sizes (as of the WLED-compatibility rewrite):
| Struct | Size | Enforced by |
|---|---|---|
UDPWLEDHeader |
44 bytes | static_assert(sizeof(UDPWLEDHeader) == 44) |
UDPMessage |
101 bytes | must add assert if changed |
UDPControlMessage |
80 bytes | must add assert if changed |
Rule: every struct that participates in size-based dispatch must have a static_assert on its size. If you change any field in UDPMessage or UDPControlMessage, add a static_assert(sizeof(...) == N) immediately after the struct definition to catch future accidental size changes at compile time. The assert for UDPWLEDHeader already exists and is the model to follow.
Mixed-version network behaviour
When devices on the same network run different firmware versions with different struct sizes, they silently ignore each other's packets — there is no error message, no fallback, and no partial decode. The size simply does not match any known case, so receiveUDP() logs a warning and discards the packet.
Consequences: - Discovery (port 65506): mixed-version MoonLight devices stop seeing each other in the device table. WLED discovery still works because WLED packets are always 44 bytes and that branch is independent. - Control (port 65507): control packets from the old version are discarded by the new, and vice versa. Groups appear to stop syncing with no visible error.
Coordinated OTA is required when struct sizes change. Flash all devices in the network before relying on device discovery or group sync.
Previous UDPMessage (before WLED-compatibility rewrite) was 97 bytes. Mixed networks with old and new firmware lose MoonLight-to-MoonLight discovery until all devices are updated. WLED compatibility was broken in the old format (token=0 caused WLED to reject packets immediately).
Related
- End-user docs: moonbase/devices
- Upstream reference: StarLight SysModInstances.h
- WLED instance protocol:
UDPWLEDMessage(44 bytes, port 65506) documented in StarLight source - Group sync behaviour: WiFi STA — hostname