Skip to content

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:

  1. Builds a UDPMessage with token=255, id=1, correct IP bytes, board type, and current control state.
  2. Broadcasts to 255.255.255.255:65506.
  3. 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 kitchenkitchenette
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).