DevicesModule
Source:
DevicesModule.h
Device¶
src/core/DevicesModule.h:300Public Attributes¶
uint8_t ip = {}
char name = {}
DevType type = DevType::Generic
bool self =
bool cached =
: restored from persistence, not yet re-heard live this session.
uint32_t lastSeenMs = 0
: platform::millis() at the most recent presence sighting.
uint8_t colorCount = 0
: Hue bridge only: how many of its lights are color-capable.
DevicesModule¶
src/core/DevicesModule.h:89Inherits:
MoonModule,ListSource
Discovers other devices on the LAN by UDP presence broadcast and presents them as a browsable list, focusing on all devices on the network (including this one, marked as self) rather than the host's own state.
Core + domain-neutral: it finds "a projectMM / a WLED device" and light modules (Art-Net sync, device groups) consume the list rather than living here, so its card looks the same on every projectMM instance, ESP32 or desktop. Submodule of NetworkModule — discovery depends on the network being up (the same placement reasoning as the Improv provisioning module), wired in main.cpp and marked wired-by-code so persistence preserves it.
Discovery is passive UDP. Each device BROADCASTS a small presence packet on a well-known port (WLED + projectMM both use UDP 65506 with the 44-byte WLED-compatible header — see WledPacket), and this module LISTENS (a bound UdpSocket per port its DevicePlugins claim, drained non-blocking each tick). No subnet sweep, no per-host probe, no mDNS query — a device appears when its broadcast arrives and ages out when it stops. projectMM also broadcasts its OWN presence on a slow cadence (~10 s) so peers discover it, and a WLED app browsing 65506 lists it too (discovery-only: a receiving WLED shows us in its instances list, it does not sync to it). This replaces the former mDNS query path, which destabilised our own mDNS advertise (a PTR query for a service we also host exhausts the IDF mDNS pool — see docs/adr/0006-device-discovery-udp-mdns-advertise-only.md). mDNS is advertise-ONLY (announcing _http._tcp+mm=1 and _wled._tcp+mac= so the WLED native app + Home Assistant, which only browse mDNS, discover us); discovery never queries.
Plugins are the interop seam. Foreign ecosystems hook in as plugins, not hardcoded branches (the adapter pattern, cf. ListSource, ModuleFactory): a DevicePlugin declares its UDP port and turns a datagram into a Device kind. MmPlugin claims a marked WLED-valid packet as projectMM (offered first, so a projectMM peer isn't double-claimed as WLED); WledPlugin claims an unmarked one as WLED. A new system is one new plugin file — no core edit. Out-of-band devices (a Philips Hue bridge found over HTTP by a light-domain driver) register through upsertHueBridge() via the [active()] boot-instance seam, keeping the Hue pairing entirely in the driver.
Age-out + persistence. Each sighting stamps lastSeenMs; a live-confirmed device is kept kStaleMs (24 h) as a durable "devices I've seen" history, while a cached row (restored from persistence, not yet re-heard) gets only a short kCachedGraceMs (60 s) probation so a long-gone persisted device can't survive forever across reboots. The devices List control is persistable, so the last-known list is restored on boot (shown as "N devices (cached)") before the first announcement arrives; the self row is re-added live with the current IP. Storage is a fixed devices_[kMaxDevices] array — bounded, no heap.
Transport boundary. This module does discovery only (lossy-OK presence, never device-to-device commands). Consumers reach a found device over the right transport: must-arrive config rides REST; latency-critical lossy-OK traffic (time sync, live pixels) rides its own UDP stream.
WLED interop. Because the presence broadcast and the mDNS advertise are WLED-shaped, a projectMM device appears in the WLED ecosystem with no projectMM software on the other side: it shows in a real WLED's own instances list (heard on UDP 65506), and in the native WLED iOS/Android app (discovered via the _wled._tcp mDNS advertise, validated via a /json/info shim — see HttpServerModule's WLED-compatibility shim).
Wire shape. The devices List serializes each row's value as {"name","ip","type",["self"]}, with a parallel detail object carrying url and ageSec (seconds since last heard, now − lastSeenMs; omitted on the self row, always current). A row restored from persistence but not yet re-heard live this session carries cached:true instead of ageSec; the UI shows "last seen: cached" until an announcement re-confirms it (clearing cached and emitting a real ageSec, rendered as "last seen 2m ago").
Prior art: the industry-standard mDNS-SD / DNS-SD (Bonjour, Avahi) announce-and-browse pattern, plus MoonLight's UDP presence broadcast carried forward as the 44-byte WLED-compatible packet on UDP 65506.

Public Methods¶
inline void setSelfName(constchar * name)
: Wire this device's own name (deviceName) before setup so the self row matches the status page / router / mDNS.
inline uint8_t listRowCount() const override
: ListSource — rows are produced straight from devices_ (no copy, no alloc).
inline void writeListRow(JsonSink & sink, uint8_t row) const override
inline void writeListRowDetail(JsonSink & sink, uint8_t row) const override
inline bool restoreList(constchar * json, constchar * key) override
: ListSource restore (persistence load): parse the saved devices array with the recursive mm::json reader and rebuild devices_, so the last-known list shows on boot before any announcement arrives.
virtual inline void defineControls() override
: defineControls MUST be idempotent and pure: only controls_.clear() + controls_.addX().
inline void upsertHueBridge(constuint8_t ip, constchar * name, uint8_t color)
: Register a Hue bridge a light-domain driver has connected to.
virtual inline void setup() override
: One-time wiring, enabled-INDEPENDENT: show the cached device list on boot.
virtual inline void prepare() override
: Pure build (see MoonModule::prepare): claim the singleton seat (the Hue-bridge routing target).
virtual inline void release() override
: Close the presence socket so its port is released (the module holds it via the lazy ensureListener bind).
virtual inline void tick1s() override
: Every tick: ensure we're online, drain inbound presence packets through the plugins, broadcast our own presence on a slow cadence, and age out devices unheard for kStaleMs.
virtual inline ModuleRole role() const override
: Role for type identification (no RTTI needed).
inline void injectPacketForTest(constuint8_t * data, size_t len, constuint8_t srcIp)
: Test seam: feed a synthetic presence datagram through the real classify→upsert pipeline, exactly as the live recvFrom loop does.
Public Static Methods¶
static inline DevicesModule * active()
: The boot DevicesModule (exactly one exists).