Plan — UDP device discovery (projectMM + WLED), mDNS becomes advertise-only¶
Builds on the shipped WLED-app interop (mDNS
_wled._tcpadvertise +/json/info+ WS state). This plan changes how a projectMM device discovers other devices on the LAN — moving from mDNS query to passive UDP listen, per the bench evidence gathered 2026-06-29.
Why (the evidence, already measured)¶
- mDNS discovery is query-driven and the query destabilises our own advertise. A blocking PTR query for a service we also host exhausts the IDF mDNS pool and makes our
_http/_wledadvertisement vanish from peers. Bench-confirmed: with querying disabled, every board became reliably discoverable. - A passive mDNS browser can't replace the query — no device on the LAN re-announces unsolicited (75 s capture: zero announcements), so there's nothing to passively hear over mDNS.
- But UDP broadcast discovery IS passive and reliable. projectMM controls its own beacon (both ends ours). WLED broadcasts a 44-byte status packet on UDP 65506 every ~30 s by default (
token==255, id==1) — measured live on two reference WLEDs. So both ecosystems can be discovered by listening to UDP broadcasts, with no querying. - mDNS advertise stays REQUIRED — the WLED native app discovers us only via mDNS
_wled._tcp(confirmed in its source,DeviceDiscovery.kt). UDP can't replace that direction. So mDNS shrinks to advertise-only: we announce so foreign apps find us; we never query.
Net effect: discovery becomes pure passive UDP receive, the self-query-disturbs-advertise bug is structurally impossible, and mDNS does only the one thing it's needed for (making us visible to WLED apps / Home Assistant).
Sanity check against the docs¶
- Common patterns first / Industry standards, our own code: UDP broadcast presence is the textbook LAN-discovery-without-infrastructure pattern; WLED's 65506 packet is a documented wire format we observe and re-implement fresh (not copy — per [[no-wled-mm-derivation]] and the MoonLight
ModuleDevices.hreference we read for the byte layout). mDNS-for-advertise is the standard service-announce. - Default to subtraction / Complexity lives in core: removes the mDNS-query path from DevicesModule + the platform; the UDP receive primitive is a small core seam each plugin leans on.
- Robust to any input: a malformed/short datagram is dropped, never crashes (the robustness contract); the plugin classify stays defensive.
- Hot path discipline: the UDP listen is a non-blocking
recvFromdrained onloop1s/loop20ms, off the render path — same shape as the current mDNS poll.
The transport split (the end state)¶
| Plugin | Discovers peers via | Makes us discoverable via |
|---|---|---|
| MmPlugin (projectMM↔projectMM) | UDP broadcast — our own presence packet on a chosen port, 255.255.255.255 |
the same UDP broadcast |
| WledPlugin (WLED / HA / WLED app) | UDP 65506 listen — WLED's 44-byte beacon (token==255,id==1, byte 38 board type) |
mDNS _wled._tcp + /json/info shim (the WLED app only does mDNS — unchanged, already shipped) |
Both discovery paths are passive UDP receive. No plugin queries mDNS. mDNS is advertise-only (mdnsInit keeps announcing _http._tcp+mm=1 and _wled._tcp+mac=; the mdnsListenPoll query path and its DevicesModule caller are removed).
Design¶
1. Reshape the DevicePlugin seam from mDNS-shaped to transport-agnostic¶
Today the seam is service()/proto()/classify(MdnsHost&). Replace with a UDP-discovery shape:
class DevicePlugin {
virtual const char* label() const = 0;
// The UDP port this plugin listens on for presence packets (0 = none).
virtual uint16_t discoveryPort() const = 0;
// Classify a received datagram from `srcIp`. Returns true + fills `out` (type, name)
// when this plugin owns the packet; false to decline. Defensive: a short/garbage
// datagram on the claimed port → decline, never crash.
virtual bool classifyPacket(const uint8_t* data, size_t len, const uint8_t srcIp[4],
DiscoveredDevice& out) const = 0;
// (reserved) command(...) — unchanged, still future.
};
- MmPlugin:
discoveryPort()= the projectMM presence port (a fixed port we pick — e.g. reuse/define one distinct from 65506 so we don't collide with WLED; the MoonLight precedent uses 65506 for WLED-compatible + 65507 for its own — we can broadcast a projectMM packet on our own port).classifyPacketrecognises our own presence packet (a small fixed header with a magic + the deviceName + IP) →DevType::ProjectMM. - WledPlugin:
discoveryPort()= 65506.classifyPacketvalidateslen>=44 && data[0]==255 && data[1]==1→DevType::Wled, extracting the name (WLED's packet carries the hostname at bytes 6–37) and the source IP.
2. projectMM presence broadcast (the MmPlugin's "make us discoverable")¶
A small periodic broadcast (every ~10–30 s, slow like WLED's) of a projectMM presence packet: magic bytes + protocol version + our IP + deviceName. Sent from the platform (it owns the socket + the broadcast address), driven on a slow loop1s cadence from DevicesModule (or a dedicated slow timer). Fixed-size, no allocation.
3. Platform UDP-discovery seam¶
Two small additions to platform.h (desktop stubs as usual):
- udpDiscoveryListen(port) / a shared receive that DevicesModule drains — OR reuse the existing UdpSocket (bind + non-blocking recvFrom(srcIp)) directly. Prefer reusing UdpSocket (already in platform.h, used by ArtNet) — DevicesModule owns one bound UdpSocket per distinct discovery port, drains each on its tick, and feeds datagrams to the plugins. No new platform primitive if UdpSocket covers it (it does: bind, non-blocking recvFrom with srcIp, sendToAddr/broadcast for our own beacon).
- Broadcast send for our presence packet: UdpSocket::sendToAddr({255,255,255,255}, port, …) — confirm the socket has SO_BROADCAST (add if missing; ArtNet may already broadcast).
4. DevicesModule rewire¶
- Drop the mDNS-query loop (
mdnsListenPollcalls, thequeryTick_/serviceCursor_rotation). - Own a bound
UdpSocketper distinctdiscoveryPort()across plugins (dedupe — projectMM + WLED are different ports). Onloop1s(orloop20msfor snappier discovery),recvFromeach socket in a non-blocking loop, hand each datagram to the plugins (classifyPacket), upsert the recognised device. SameDevicestruct, ListSource, persistence, age-out, self-row. - Broadcast our own presence packet on the slow cadence.
kStaleMssized to a few presence intervals (a device re-announces every ~10–30 s, so ~3× that).
5. mDNS: advertise-only¶
mdnsInitunchanged (keep the_http+mm=1,_wled+mac=, re-advertise, symmetric stop — all shipped).- Remove
mdnsListenPoll(platform) + its decl + the desktop stub + the DevicesModule caller. mDNS no longer queries anything. MdnsHoststruct +mdnsBrowsemay stay if still used elsewhere; if not, remove (subtraction).
Files¶
- Edit:
src/core/DevicePlugin.h(seam reshape + Mm/WledclassifyPacket),src/core/DevicesModule.h(UDP listen/drain/broadcast, drop mDNS query),src/platform/platform.h(+ desktop/esp32 ifUdpSocketneeds a broadcast flag or a presence-send helper),src/platform/esp32/platform_esp32.cpp(removemdnsListenPoll; presence broadcast if platform-side),src/platform/desktop/platform_desktop.cpp(stub adjustments). - Tests:
test/unit/core/unit_DevicesModule_discovery.cpp— driveclassifyPacketwith synthetic datagrams (a 44-byte WLED packettoken=255,id=1; a projectMM presence packet; a short/garbage packet → declined). TheinjectMdnsHitForTestseam becomesinjectPacketForTest. Plus the existing age-out / no-contamination cases adapted. - Docs:
docs/moonmodules/core/DevicesModule.md(UDP discovery + the transport-per-plugin table),docs/history/decisions.md(the transport-split lesson).
Decisions locked (product owner)¶
- Port: 65506, WLED-compatible 44-byte format. projectMM broadcasts a valid
UDPWLEDHeader(token=255, id=1, real IP octets, deviceName at bytes 6–37, board-type byte at 38). Verified safe against the product owner's concern "don't send WLED wrong info it gets confused by": per MoonLight's working code, a WLED receiving a 65506 packet uses it for discovery only — it shows the sender in its device list and does NOT sync to it or change state. The packet carries no command. WLED's validation istoken==255 && id==1 && ip0==localIP[0](a subnet check), so we setip0to our real first octet. Sync/control is a separate concern on port 65507 which WLED never listens on — so there is no path for our presence packet to command a WLED. Bonus: real WLEDs + WLED apps that browse 65506 may see us via UDP too. - Cadence: ~10 s broadcast, drain on
loop1s. A new device appears within ~10 s; light traffic.kStaleMs~60 s (≈ 6 intervals).
Riskiest parts¶
- Packet contents must be a valid WLED header so WLED reads us but never mis-syncs — resolved above (discovery-only by WLED's design; we fill token/id/ip0/name/type correctly). The one must-get-right:
ip0 == our real first IP octetor WLED's subnet check rejects us. UdpSocketbroadcast — confirmSO_BROADCASTis set for the send side; receiving broadcasts needsbind(port)on0.0.0.0(already how ArtNet-in binds). On ESP32, a bound listen socket must survive netif up/down (re-bind on reconnect).- Multiple bound sockets — DevicesModule binds one per discovery port (projectMM + 65506). Bounded (≤ plugin count). Non-blocking drains, no starvation.
- Desktop —
UdpSocketworks on desktop (ArtNet uses it), so discovery can actually be unit-tested with real loopback datagrams, not just stubs — a nice testability gain over the mDNS-stubbed path.
Verification¶
- Desktop build green;
ctest+ scenarios green; ESP32 all variants green. - Unit:
classifyPacketaccepts a real 44-byte WLED packet + a projectMM presence packet, declines garbage; no name/IP cross-contamination; age-out at the new window. - Bench (the real test): all 4 boards discover each other (projectMM presence) + both reference WLEDs (65506) and hold steady — without the advertise instability (the mDNS advertise stays rock-solid because nothing queries it). Cross-check: the WLED native app still lists all 4 boards (mDNS advertise +
/json/infounchanged). - Save this plan (done); mark
… (shipped).mdwhen it lands.
Out of scope¶
- The control/command half (
DevicePlugin::command) — still reserved; we discover + classify, not yet command foreign devices. - WLED UDP sync/realtime (ports 21324/11988) — a separate feature (driving WLED pixels), not discovery.
- Live peer state (a peer's brightness in our list) — still the REST-after-discovery follow-up.