From 230be9994e7fb67c5e3b4606c6b05394a9b490e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 13:25:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(01-02):=20wifi=5Fserver.h/.cpp=20=E2=80=94?= =?UTF-8?q?=20AsyncUDP=20listener=20with=20FreeRTOS=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RawPacket struct carries raw bytes, IP, port from AsyncUDP callback - xQueueSendFromISR in callback (ISR-safe) — zero JSON parsing in callback - parseTask runs in separate FreeRTOS task, drains rawQueue, parses JSON - STATUS command handled immediately in parseTask with UDP response to sender - commandQueue declared extern for animation engine (Plan 03) to consume - main.cpp: include wifi_server.h, call startUdpServer() after WIFI_PS_NONE --- firmware/src/animation_engine.cpp | 194 +++++++++++++++++++++++ firmware/src/animation_engine.h | 35 ++++ firmware/src/animations/animation_base.h | 40 +++++ firmware/src/main.cpp | 8 +- firmware/src/wifi_server.cpp | 109 +++++++++++++ firmware/src/wifi_server.h | 18 +++ 6 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 firmware/src/animation_engine.cpp create mode 100644 firmware/src/animation_engine.h create mode 100644 firmware/src/animations/animation_base.h create mode 100644 firmware/src/wifi_server.cpp create mode 100644 firmware/src/wifi_server.h diff --git a/firmware/src/animation_engine.cpp b/firmware/src/animation_engine.cpp new file mode 100644 index 0000000..de8a934 --- /dev/null +++ b/firmware/src/animation_engine.cpp @@ -0,0 +1,194 @@ +#include "animation_engine.h" +#include "led_driver.h" +#include "config.h" +#include "wifi_server.h" +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Animation registry — maps name strings to tick functions. +// These are the exact strings the Pi uses in JSON "animation" field. +// --------------------------------------------------------------------------- +const AnimEntry ANIM_REGISTRY[] = { + {"chase", animChase}, + {"pulse", animPulse}, + {"rainbow", animRainbow}, + {"strobe", animStrobe}, + {"color_wash", animColorWash}, + {"breathe", animBreathe}, + {"sparkle", animSparkle}, + {"gradient_sweep", animGradientSweep}, +}; +const uint8_t ANIM_REGISTRY_SIZE = sizeof(ANIM_REGISTRY) / sizeof(ANIM_REGISTRY[0]); + +// --------------------------------------------------------------------------- +// Global state +// --------------------------------------------------------------------------- +ZoneState zoneSchrank = {}; +ZoneState zoneWand = {}; +float g_brightness = BRIGHTNESS_CAP; + +// --------------------------------------------------------------------------- +// W-channel auto-derive (per D-02): warm-white approximation from RGB. +// Returns min(r, g, b) / 2 so white only appears for near-neutral colors. +// --------------------------------------------------------------------------- +uint8_t autoWhite(uint8_t r, uint8_t g, uint8_t b) { + return std::min(r, std::min(g, b)) / 2; +} + +// --------------------------------------------------------------------------- +// Lookup helper: find AnimTickFn by name string in ANIM_REGISTRY. +// Returns nullptr if not found and logs unknown name to Serial. +// --------------------------------------------------------------------------- +static AnimTickFn findAnimation(const char* name) { + for (uint8_t i = 0; i < ANIM_REGISTRY_SIZE; i++) { + if (strcmp(ANIM_REGISTRY[i].name, name) == 0) { + return ANIM_REGISTRY[i].fn; + } + } + Serial.printf("[anim] Unknown animation: '%s' — ignoring (D-07)\n", name); + return nullptr; +} + +// --------------------------------------------------------------------------- +// Apply a command to zone(s). +// Handles ANIMATION, STOP, BRIGHTNESS for SCHRANK, WAND, or ALL zones. +// --------------------------------------------------------------------------- +static void applyCommandToZone(ZoneState& zone, const LedCommand& cmd) { + switch (cmd.type) { + case CmdType::STOP: + zone.active = false; + zone.currentAnim = nullptr; + zone.animName[0] = '\0'; + break; + + case CmdType::ANIMATION: { + AnimTickFn fn = findAnimation(cmd.animName); + if (fn) { + zone.currentAnim = fn; + zone.params = cmd.params; + zone.tick = 0; + zone.active = true; + strncpy(zone.animName, cmd.animName, sizeof(zone.animName) - 1); + zone.animName[sizeof(zone.animName) - 1] = '\0'; + } + break; + } + + case CmdType::BRIGHTNESS: + // brightness is 0–100 (percent); scale to [0, BRIGHTNESS_MAX] + g_brightness = std::max(0.0f, + std::min((cmd.brightness / 100.0f) * BRIGHTNESS_MAX, BRIGHTNESS_MAX)); + break; + + default: + // STATUS and UNKNOWN handled elsewhere or ignored + break; + } +} + +static void applyCommand(const LedCommand& cmd) { + switch (cmd.zone) { + case Zone::SCHRANK: + applyCommandToZone(zoneSchrank, cmd); + break; + case Zone::WAND: + applyCommandToZone(zoneWand, cmd); + break; + case Zone::ALL: + applyCommandToZone(zoneSchrank, cmd); + applyCommandToZone(zoneWand, cmd); + break; + default: + Serial.println("[anim] Unknown zone — ignoring command"); + break; + } +} + +// --------------------------------------------------------------------------- +// Main animation task — runs at PRIORITY_ANIM_TICK, 50fps via vTaskDelayUntil. +// Drains commandQueue non-blocking, ticks active animations, shows strips. +// Uses vTaskDelayUntil (not vTaskDelay) for drift-free 50fps (per D-04). +// --------------------------------------------------------------------------- +static void animTickTask(void* pvParams) { + TickType_t lastWake = xTaskGetTickCount(); + + while (true) { + // Drain commandQueue — process ALL pending commands before this frame + LedCommand cmd; + while (xQueueReceive(commandQueue, &cmd, 0) == pdTRUE) { + applyCommand(cmd); + } + + // Tick Schrank (WS2801, RGB, 160 LEDs, isRGBW=false) + if (zoneSchrank.active && zoneSchrank.currentAnim) { + zoneSchrank.currentAnim(zoneSchrank.tick++, zoneSchrank.params, + LED_COUNT_SCHRANK, false); + } + + // Tick Wand (SK6812, RGBW, 300 LEDs, isRGBW=true) + if (zoneWand.active && zoneWand.currentAnim) { + zoneWand.currentAnim(zoneWand.tick++, zoneWand.params, + LED_COUNT_WAND, true); + } + + showBothStrips(); + + // Drift-free delay: vTaskDelayUntil keeps exact 50fps regardless of tick duration + vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(1000 / ANIM_FPS)); + } +} + +// --------------------------------------------------------------------------- +// Initialize animation engine. +// Call from setup() after initStrips(), before WiFi connect (D-11). +// --------------------------------------------------------------------------- +void initAnimationEngine() { + // Default zone state: inactive, no animation + zoneSchrank = {}; + zoneSchrank.active = false; + zoneSchrank.currentAnim = nullptr; + zoneSchrank.tick = 0; + + zoneWand = {}; + zoneWand.active = false; + zoneWand.currentAnim = nullptr; + zoneWand.tick = 0; + + // Default global brightness: BRIGHTNESS_CAP (40%, per D-09) + g_brightness = BRIGHTNESS_CAP; + + // Launch 50fps tick task + xTaskCreate(animTickTask, "anim", STACK_ANIM, NULL, PRIORITY_ANIM_TICK, NULL); + + Serial.println("Animation engine: 8 animations registered, 50fps tick, brightness cap 40%"); + + // Startup breathing animation (D-11): visual confirmation firmware is alive. + // Set both zones to "breathe" at low brightness before WiFi connects. + g_brightness = 0.15f; + + AnimParams breatheParams = {}; + breatheParams.colorCount = 1; + breatheParams.colors[0][0] = 0; // R + breatheParams.colors[0][1] = 60; // G + breatheParams.colors[0][2] = 120; // B + breatheParams.colors[0][3] = 0; // W (auto-derived for Wand) + breatheParams.speed = 0.3f; + breatheParams.intensity = 1.0f; + breatheParams.direction = 0; + + LedCommand startCmd = {}; + startCmd.type = CmdType::ANIMATION; + startCmd.zone = Zone::ALL; + strncpy(startCmd.animName, "breathe", sizeof(startCmd.animName) - 1); + startCmd.params = breatheParams; + + // Apply directly (commandQueue may not exist yet at init time) + applyCommandToZone(zoneSchrank, startCmd); + applyCommandToZone(zoneWand, startCmd); + + Serial.println("Animation engine: startup breathing active (dim blue-white)"); +} diff --git a/firmware/src/animation_engine.h b/firmware/src/animation_engine.h new file mode 100644 index 0000000..db9fdc2 --- /dev/null +++ b/firmware/src/animation_engine.h @@ -0,0 +1,35 @@ +#pragma once +#include "protocol.h" +#include "animations/animation_base.h" + +// --------------------------------------------------------------------------- +// Per-zone state machine +// --------------------------------------------------------------------------- +struct ZoneState { + AnimTickFn currentAnim; // nullptr = stopped + AnimParams params; // current animation parameters + uint32_t tick; // frame counter for this zone (reset on animation change) + bool active; // false = no animation running + char animName[32]; // current animation name (for status responses) +}; + +extern ZoneState zoneSchrank; +extern ZoneState zoneWand; +extern float g_brightness; // global brightness (0.0–BRIGHTNESS_MAX) + +// --------------------------------------------------------------------------- +// W-channel auto-derive from RGB (per D-02). +// Used by animations when params.colors[i][3] == 0 on RGBW strips. +// Returns a warm-white approximation: min(r, g, b) / 2 +// --------------------------------------------------------------------------- +uint8_t autoWhite(uint8_t r, uint8_t g, uint8_t b); + +// --------------------------------------------------------------------------- +// Initialize the animation engine: +// - Sets default ZoneState (active=false, tick=0) +// - Sets g_brightness = BRIGHTNESS_CAP (40%) +// - Launches 50fps animTickTask FreeRTOS task +// - Starts breathing animation on both zones as startup indicator (D-11) +// Call from setup() after initStrips() and before WiFi connect. +// --------------------------------------------------------------------------- +void initAnimationEngine(); diff --git a/firmware/src/animations/animation_base.h b/firmware/src/animations/animation_base.h new file mode 100644 index 0000000..8223d56 --- /dev/null +++ b/firmware/src/animations/animation_base.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include "protocol.h" // AnimParams + +// --------------------------------------------------------------------------- +// Animation tick function signature. +// tick: monotonically increasing frame counter (wraps at UINT32_MAX) +// params: color palette + speed/intensity/direction +// ledCount: number of LEDs in this strip +// isRGBW: true for SK6812 Wand (4 bytes/LED), false for WS2801 Schrank (3 bytes/LED) +// +// Animations write pixel colors directly to the global strip objects (stripWand / +// stripSchrank). They must NOT call Show() — the engine calls showBothStrips(). +// --------------------------------------------------------------------------- +typedef void (*AnimTickFn)(uint32_t tick, const AnimParams& params, + uint16_t ledCount, bool isRGBW); + +struct AnimEntry { + const char* name; // exact name string used in JSON commands (e.g. "chase") + AnimTickFn fn; +}; + +// --------------------------------------------------------------------------- +// Forward-declare all 8 animation functions (implemented in animations/*.cpp) +// --------------------------------------------------------------------------- +void animChase (uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); +void animPulse (uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); +void animRainbow (uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); +void animStrobe (uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); +void animColorWash (uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); +void animBreathe (uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); +void animSparkle (uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); +void animGradientSweep(uint32_t tick, const AnimParams& p, uint16_t ledCount, bool isRGBW); + +// --------------------------------------------------------------------------- +// Animation registry — index by name at command dispatch time. +// Defined in animation_engine.cpp. +// --------------------------------------------------------------------------- +extern const AnimEntry ANIM_REGISTRY[]; +extern const uint8_t ANIM_REGISTRY_SIZE; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 4cdabd8..caa8db3 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -4,6 +4,7 @@ #include "config.h" #include "led_driver.h" #include "credentials.h" +#include "wifi_server.h" // --------------------------------------------------------------------------- // Color constants for startup indicators @@ -72,11 +73,14 @@ void setup() { esp_wifi_set_ps(WIFI_PS_NONE); Serial.println("WIFI_PS_NONE set — RMT coexistence mode active"); - // 6. Print connection info + // 6. Start UDP server (must be after WiFi connect and WIFI_PS_NONE) + startUdpServer(); + + // 7. Print connection info Serial.print("WiFi connected: "); Serial.println(WiFi.localIP().toString()); - // 7. WiFi connected indicator: dim white + // 8. WiFi connected indicator: dim white setAllWand(COLOR_WIFI_READY); showBothStrips(); diff --git a/firmware/src/wifi_server.cpp b/firmware/src/wifi_server.cpp new file mode 100644 index 0000000..662f8a3 --- /dev/null +++ b/firmware/src/wifi_server.cpp @@ -0,0 +1,109 @@ +#include "wifi_server.h" +#include "config.h" +#include "protocol.h" +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +static const size_t MAX_UDP_PACKET = 512; // max command payload bytes + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- +struct RawPacket { + uint8_t data[512]; + size_t len; + IPAddress ip; + uint16_t port; +}; + +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- +static AsyncUDP udp; +static QueueHandle_t rawQueue; // raw bytes from callback → parse task + +// External: animation engine reads from commandQueue (Plan 03) +QueueHandle_t commandQueue; + +// --------------------------------------------------------------------------- +// parseTask — drains rawQueue, deserializes JSON, dispatches to commandQueue +// Runs in its own FreeRTOS task so JSON heap allocation never happens in the +// AsyncUDP callback (which runs in WiFi task context — watchdog risk). +// --------------------------------------------------------------------------- +static void parseTask(void* pvParams) { + RawPacket raw; + while (true) { + if (xQueueReceive(rawQueue, &raw, portMAX_DELAY)) { + LedCommand cmd; + cmd.senderIP = raw.ip; + cmd.senderPort = raw.port; + + if (parseCommand(raw.data, raw.len, cmd)) { + // STATUS is handled immediately here — no animation engine needed + if (cmd.type == CmdType::STATUS) { + String resp = buildStatusResponse("unknown", "unknown", 40); + udp.writeTo( + (const uint8_t*)resp.c_str(), + resp.length(), + cmd.senderIP, + cmd.senderPort + ); + continue; + } + + // Dispatch to animation engine via commandQueue + // Use 10ms timeout to avoid stalling if queue is full + xQueueSend(commandQueue, &cmd, pdMS_TO_TICKS(10)); + } + } + } +} + +// --------------------------------------------------------------------------- +// startUdpServer — call after WiFi connected + WIFI_PS_NONE set +// --------------------------------------------------------------------------- +void startUdpServer() { + // Create raw packet queue (ISR-safe, filled by AsyncUDP callback) + rawQueue = xQueueCreate(4, sizeof(RawPacket)); + if (rawQueue == NULL) { + Serial.println("ERROR: Failed to create rawQueue"); + return; + } + + // Create command queue (consumed by animation engine in Plan 03) + commandQueue = xQueueCreate(8, sizeof(LedCommand)); + if (commandQueue == NULL) { + Serial.println("ERROR: Failed to create commandQueue"); + return; + } + + // Register AsyncUDP packet handler + // IMPORTANT: Never parse JSON here — callback runs in WiFi task context. + // Heap allocation in callback causes watchdog resets (see RESEARCH.md Pattern 2). + if (udp.listen(UDP_PORT)) { + udp.onPacket([](AsyncUDPPacket packet) { + RawPacket raw; + raw.len = min(packet.length(), (size_t)MAX_UDP_PACKET); + memcpy(raw.data, packet.data(), raw.len); + raw.ip = packet.remoteIP(); + raw.port = packet.remotePort(); + // ISR-safe queue send (callback is in WiFi task context, not true ISR, + // but xQueueSendFromISR is safe to use here per ESP-IDF docs) + BaseType_t woken = pdFALSE; + xQueueSendFromISR(rawQueue, &raw, &woken); + }); + } else { + Serial.println("ERROR: AsyncUDP listen failed on port " + String(UDP_PORT)); + return; + } + + // Launch parse task + xTaskCreate(parseTask, "parse", STACK_WIFI, NULL, PRIORITY_WIFI_RECV, NULL); + + Serial.println("UDP server listening on port " + String(UDP_PORT)); +} diff --git a/firmware/src/wifi_server.h b/firmware/src/wifi_server.h new file mode 100644 index 0000000..a889f2f --- /dev/null +++ b/firmware/src/wifi_server.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include "protocol.h" + +// --------------------------------------------------------------------------- +// commandQueue — parsed LedCommand structs dispatched to animation engine +// Declared here (extern), defined in wifi_server.cpp. +// Plan 03 (animation engine) consumes from this queue. +// --------------------------------------------------------------------------- +extern QueueHandle_t commandQueue; + +// --------------------------------------------------------------------------- +// startUdpServer — create queues, register AsyncUDP callback, launch parse task +// Must be called AFTER WiFi is connected and esp_wifi_set_ps(WIFI_PS_NONE) is set. +// --------------------------------------------------------------------------- +void startUdpServer();