feat(01-02): wifi_server.h/.cpp — AsyncUDP listener with FreeRTOS queue

- 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
This commit is contained in:
Claude
2026-04-03 13:25:15 +02:00
parent 3323a87ce0
commit 230be9994e
6 changed files with 402 additions and 2 deletions

View File

@@ -0,0 +1,194 @@
#include "animation_engine.h"
#include "led_driver.h"
#include "config.h"
#include "wifi_server.h"
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <algorithm>
#include <string.h>
// ---------------------------------------------------------------------------
// 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 0100 (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)");
}

View File

@@ -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.0BRIGHTNESS_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();

View File

@@ -0,0 +1,40 @@
#pragma once
#include <stdint.h>
#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;

View File

@@ -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();

View File

@@ -0,0 +1,109 @@
#include "wifi_server.h"
#include "config.h"
#include "protocol.h"
#include <AsyncUDP.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
// ---------------------------------------------------------------------------
// 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));
}

View File

@@ -0,0 +1,18 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#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();