Skip to content

HttpServerModule

Source: HttpServerModule.h

LeafHash

struct LeafHash
src/core/HttpServerModule.h:319

Public Attributes

uint32_t path = 0

uint32_t value = 0

PreviewSend

struct PreviewSend
src/core/HttpServerModule.h:268

Public Attributes

uint8_t hdr = {}

size_t hdrLen = 0

constuint8_t * body =

size_t bodyLen = 0

size_t sent = {}

bool active =

bool ownsBody =

HttpServerModule

class HttpServerModule
src/core/HttpServerModule.h:118

Inherits: 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