HttpServerModule
Source:
HttpServerModule.h
LeafHash¶
src/core/HttpServerModule.h:319Public Attributes¶
uint32_t path = 0
uint32_t value = 0
PreviewSend¶
src/core/HttpServerModule.h:268Public Attributes¶
uint8_t hdr = {}
size_t hdrLen = 0
constuint8_t * body =
size_t bodyLen = 0
size_t sent = {}
bool active =
bool ownsBody =
HttpServerModule¶
src/core/HttpServerModule.h:118Inherits:
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), up to MAX_WS_CLIENTS (8) concurrent clients. Binary frames take two paths, both without a frame-sized buffer: a synchronous stream (beginBinaryFrame / pushBinaryFrame / endBinaryFrame) for a forward-only producer, and a resumable buffered send (sendBufferedFrame) that drains 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 spinning any loop, yet stays one atomic WS message. One buffered send is in flight at a time (newest-wins backpressure: a new offer while one is active is dropped). Clients send nothing back over WS; 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(constchar * path)
void beginBinaryFrame(size_t totalLen) override
: BinaryBroadcaster — stream one binary WS frame to every connected client, pushed incrementally so no frame-sized buffer is held.
void pushBinaryFrame(constuint8_t * data, size_t len) override
bool endBinaryFrame() override
bool sendBufferedFrame(constuint8_t * header, size_t headerLen, constuint8_t * body, size_t bodyLen) override
: Resumable one-frame send from a stable caller-owned buffer (no copy), drained a bounded chunk per client per tick20ms (drainPreviewSend) so a large frame stays off this module's hot path; a would-block socket resumes next tick.
inline bool bufferedSendIdle() const override
inline void cancelBufferedSend() override
inline uint32_t clientGeneration() const override
: Bumped on each new WS client (see handleWebSocketUpgrade).
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(constchar * typeName, constchar * id, constchar * parentId)
: body is a small JSON object: {"type","id","parent_id"} / {"module","control","value"}.
OpResult applySetControl(constchar * moduleName, constchar * controlName, constchar * valueJson)
OpResult applyClearChildren(constchar * parentName)
: Enumerate-then-DELETE every child of parentName (the catalog inject's replaceChildren).
OpResult applyOp(constchar * opJson)
: Parse a single REST op object ({"op":"add\|set\|clearChildren", …}) and dispatch to the three above.
void applyWledState(constchar * 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()
: Install the schema-changed hook WITHOUT opening the TCP listener ([setup()] does both).
Public Static Methods¶
static bool parseFilePath(constchar * query, char * out, size_t cap)
: Decode a path=<rel> query value into out (XX + '+' decoding), rooted at the mount.
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 |