HttpServerModule
Source:
HttpServerModule.h
LeafHash¶
src/core/HttpServerModule.h:398Public Attributes¶
uint32_t path = 0
uint32_t value = 0
PreviewSend¶
src/core/HttpServerModule.h:330Public Attributes¶
uint8_t hdr = {}
size_t hdrLen = 0
const uint8_t * body = nullptr
size_t bodyLen = 0
size_t sent = {}
bool active = false
StateSend¶
src/core/HttpServerModule.h:348Public Attributes¶
uint8_t hdr = {}
size_t hdrLen = 0
const uint8_t * body = nullptr
size_t bodyLen = 0
size_t sent = {}
bool active = false
HttpServerModule¶
src/core/HttpServerModule.h:122Inherits:
MoonModule,BinaryBroadcaster
Embedded HTTP server plus WebSocket — serves the web UI and the REST API that backs it.
Core infrastructure held to a light-include-free contract with one PO-accepted exception: the WLED-compatibility shim's color path uses light/Palette.h's pure hue/RGB↔palette-index conversions (Palettes::nearestForRgb, Palettes::representativeRgb), the same sanctioned exception MqttModule documents at its top-of-file — routing a HomeKit / HA WLED color to a projectMM palette needs the palette set, which is inherently light-domain, and a format conversion is the least-coupling way to bridge it (this module still drives the palette through Scheduler::setControl, not a light object). No other light-domain include is permitted here. Implementation lives in HttpServerModule.cpp; this header is the interface only. The port control defaults to 8080 on desktop, 80 on ESP32.
REST API:GET / serves index.html and the UI assets (/app.js, /style.css, /moonlight-logo.png). GET /api/state returns the full module-tree JSON (each entry carries name, type, role, enabled, tickTimeUs, classSize, dynamicBytes, controls[], status + severity when set, and userEditable:false only when the module opts out of UI delete/replace). GET /api/system returns fps, tickTimeUs, freeHeap, freeInternal, maxBlock, uptime. GET /api/types returns the type catalog (stable factory name, role-suffix-stripped displayName, acceptsChildRoles, and per-type defaults captured from a fresh probe instance). Mutations: POST /api/control``{module,control,value}, POST /api/modules create, POST /api/modules/{name}/move reorder, .../replace swap, POST /api/reboot, DELETE /api/modules/{name}. File Manager: GET /api/dir?path= lists a directory, POST /api/dir?path= creates a folder, DELETE /api/dir?path= removes a file or empty folder, GET|POST /api/file?path= reads / writes a file body (the path rides the query, so a filesystem op carries its target in the request, not a stored control). All JSON responses stream through a JsonSink — no fixed-buffer ceiling, so a tree of any size serializes correctly.
WebSocket:GET /ws with Upgrade: websocket does the RFC 6455 handshake (SHA-1 + base64). Two WS channels by traffic class, with separate caps on one lwIP socket budget: /ws carries the control plane (JSON state and patches, MAX_WS_CLIENTS = 8) and /wsp the lossy binary preview stream (MAX_PREVIEW_CLIENTS = 4). Every binary message takes ONE path: the resumable buffered send (sendBufferedFrame), draining a memory-adaptive chunk per client per tick20ms from a stable caller-owned buffer, so a large frame is delivered over wall-clock ticks without any loop ever waiting on a socket, yet stays one atomic WS message. One buffered send is in flight at a time per slot (newest-wins backpressure: a new offer while one is active is dropped); a client is closed only on a real error or FIN, never for slowness. Inbound /wsp payloads are unmasked and handed opaquely to the registered producer sink; the producer's vocabulary is [0x51][stride][fps] (standing frame request) and [0x52][stride] (one-shot table request). Other mutations go through REST.
State push — diff on the wire (the recognizable snapshot-then-patch model, cf. Redux / Firestore sync, JSON Patch RFC 6902): the state a client needs is the full module tree (~30 KB, mostly unchanging option/detail metadata), but re-serializing all of it every tick1s() — inline on the render thread — stole render budget and stuttered the LEDs at 1 Hz. Instead: a client gets the full{modules:[…]} state ONCE on connect (chunk-drained via the resumable sender, off the render tick), then each second a patch{patch:[{path,value}, …]} of only the values that changed. Change is found by value-compare, not a dirty flag: buildStatePatch serializes each leaf's value, hashes it (FNV-1a), and compares to a cached hash — so a value the device mutates itself (telemetry @tickTimeUs, status, a driver) is caught the same as a setControl write, with no per-write instrumentation. A leaf path is "<module>/<control>" (or "<module>/@<field>" for live per-card header telemetry); module names are unique tree-wide, so the path is stable. The hash cache is one global baseline (not per-client) in a growable ScratchBuffer<LeafHash>; requestFullResync() re-sends the full state + re-baselines on connect and after any structural change (a value patch can't describe a reshaped tree). A schema change (a rebuildControls() from any trigger — a control set, a list mutation, an async WiFi/Hue callback) also forces a resync via a static schema-changed hook (MoonModule::setSchemaChangedHook), since the value patch can't carry changed hidden flags / option sets. A pending resync is fast-pathed on tick20ms (not just tick1s) and preempts an in-flight preview frame, so a freshly-connected client gets its state — and therefore its preview — within a few tens of ms instead of up to a second. Net: the per-second push drops from ~34 KB to ~1–2 KB and the expensive full-tree serialize runs only on connect / schema change. The UI applies a patch in place (no rebuild) and re-renders on a full frame. This is the "sub-hot path
is a hot path" rule (CLAUDE.md) applied: a periodic tick shares the render thread, so its work must be cheap / skipped-when-unchanged.
Hot-path split: the resumable drain runs on tick20ms (the 20 ms transport poll), deliberately NOT the per-render-tick tick(), so pushing preview bytes to the socket is never charged to the LED render hot path. The LED output is never delayed by the preview; the preview frame rate is instead bounded by the 20 ms drain cadence, which is the right trade since the preview is a view and the LEDs are not.
WLED-compatibility shim: a small set of WLED-shaped messages make a projectMM device appear in — and be controlled from — the native WLED apps (iOS / Android) and Home Assistant's WLED integration. Discovery is over mDNS _wled._tcp; validation is a minimal GET /json/info``{name, mac, leds{}, wifi{}, brand:"WLED", product:"MoonModules"} (the app keys on brand:"WLED" to accept it — we interoperate, not impersonate; this is NOT a full WLED emulation). Live state is pushed over /ws as a {state, info} frame; state mirrors the Driversbrightness control and the live first-LED RGB (falling back to projectMM purple [128,0,255] when the first LED is off). Control is bidirectional over the same /ws: the app's slider/toggle send a {on?, bri?} frame, read by pollWledStateFromWebSockets() and applied to Drivers brightness through the shared apply-core (the same applySetControl path REST and Improv use). The color read is the one place this core module reaches output state — MoonModule::firstOutputRgb() is a domain-neutral virtual the light-domain Drivers overrides — keeping this module free of any light-domain include.
Cross-domain wiring: this module exposes the BinaryBroadcaster interface; the light-domain PreviewDriver holds a BinaryBroadcaster* and streams each frame's bytes through it. main.cpp wires PreviewDriver's broadcaster to the HttpServerModule instance — the only file that knows both. The preview's point budget and wire format are PreviewDriver's concern.
The five JsonSink& helpers below are private members rather than free functions because they all read this->wsClients_, this->scheduler_, or other module state, or call other HttpServerModule members. Three pieces of this module's helpers live in their own headers: JsonSink + jsonEscape() in [core/JsonSink.h], sha1() (RFC 3174, WS handshake) in [core/Sha1.h], base64Encode() (WS handshake + Password obfuscation) in [core/Base64.h] — all in namespace mm so the call sites are unchanged.
Prior art: the WLED-compatibility shim's exact field requirements were reverse-engineered from the WLED-Android client by Christophe Gagnier (@Moustachauve, https://github.com/Moustachauve/WLED-Android) — DeviceDiscovery.kt (mDNS browse), DeviceFirstContactService.kt (the /json/info validation + non-empty mac check), the Info/State Moshi models, and WebsocketClient.kt (live state over /ws, the sendState control direction). Knowing precisely what the app reads is why the shim is the minimal accepted object rather than a guessed full WLED emulation.
Public Attributes¶
uint16_t port = 8080
Public Methods¶
inline void setScheduler(Scheduler * s)
inline void setUiPath(const char * path)
bool sendBufferedFrame(const uint8_t * header, size_t headerLen, const uint8_t * body, size_t bodyLen) override
: BinaryBroadcaster — stream one binary WS frame to every connected client, pushed incrementally so no frame-sized buffer is held.
inline bool bufferedSendIdle() const override
inline void cancelBufferedSend() override
inline int subscriberCount() const override
inline void setClientMessageSink(ClientMessageSink * sink) override
: Register the producer that receives this channel's inbound client messages (opaque bytes).
inline bool tryAcquireSend() override
inline void releaseSend() override
virtual inline bool respectsEnabled() const override
: Keep running even when "disabled" via the UI — otherwise the user has no way to re-enable themselves through the same UI.
virtual inline bool appearsInUi() const override
: Non-UI: this IS the server that renders /api/state — it doesn't list itself as a card.
virtual void defineControls() override
: defineControls MUST be idempotent and pure: only controls_.clear() + controls_.addX().
virtual void setup() override
: Default lifecycle propagates to children.
virtual void release() override
virtual void tick20ms() override
virtual void tick1s() override
OpResult applyAddModule(const char * typeName, const char * id, const char * parentId, char * outName = nullptr, size_t outNameLen = 0)
: body is a small JSON object: {"type","id","parent_id"} / {"module","control","value"}.
OpResult applySetControl(const char * moduleName, const char * controlName, const char * valueJson)
OpResult applyClearChildren(const char * parentName)
: Enumerate-then-DELETE every child of parentName (the catalog inject's replaceChildren).
OpResult applyOp(const char * opJson)
: Parse a single REST op object ({"op":"add\|set\|clearChildren", …}) and dispatch to the three above.
void applyFileChanged(const char * path)
: A file at path changed (written or removed), so ask the tree to re-derive whatever was built from it.
void applyWledState(const char * body)
: Apply a WLED {on?, bri?} state body onto the Driverson / brightness controls through the shared apply-core (on and bri independent — off preserves the level).
inline uint16_t buildStatePatchForTest(JsonSink & sink)
: Test seams for the diff-on-the-wire patch: drive buildStatePatch / baseline / resync directly (they're otherwise private, called from tick1s).
inline void baselineLeafHashesForTest()
inline void requestFullResyncForTest()
inline bool fullResyncPendingForTest() const
inline void clearFullResyncForTest()
inline void installSchemaHookForTest()
Public Static Methods¶
static int parsePreviewUplink(const uint8_t * buf, int n, uint8_t out, int * consumed)
: Parse ONE masked client data frame (text/binary, payload up to 8 bytes) from a /wsp read, unmasking the payload into out.
static bool parseFilePath(const char * query, char * out, size_t cap)
: Decode a path=<rel> query value into out (XX + '+' decoding), rooted at the mount.
static const char * findHeaderCI(const char * hay, const char * needle)
: Case-insensitive substring search for a header name in a raw request (RFC 9112: field names are case-insensitive: browsers send "Content-Length:", node's undici sends "content-length:"; the case-sensitive strstr it replaces silently read a length of 0 and committed EMPTY files with a 200).
static const char * replacementName(const char * requested, const char * current, const char * oldDefault)
: What a replaced module should be called: requested name, else a custom one, else null ("keep the fresh module's own default").
static inline uint16_t servedPort()
: Install the schema-changed hook WITHOUT opening the TCP listener ([setup()] does both).
static bool removeRecursive(const char * path, uint8_t depth = 0)
: Delete path, and everything under it when it is a directory.
Public Types¶
enum OpResult
: The add/set/clear-children operations the HTTP handlers do, factored out of the TcpConnection so any transport can drive them.
| Value | Description |
|---|---|
Ok |
|
AlreadyExists |
add is a no-op: a module with this id is already in the tree (still success) |
ModuleNotFound |
module / parent name not in the tree |
ControlNotFound |
module exists but has no such control (a distinct 404) |
UnknownType |
factory doesn't know the type |
BadRequest |
missing field, top-level add, parent rejected child |
OutOfRange |
numeric value outside bounds |
Malformed |
value didn't parse (such as an IPv4) |
ReadOnly |
tried to write a display-only control |