diff --git a/firmware/src/protocol.cpp b/firmware/src/protocol.cpp new file mode 100644 index 0000000..11ec416 --- /dev/null +++ b/firmware/src/protocol.cpp @@ -0,0 +1,148 @@ +#include "protocol.h" +#include + +// --------------------------------------------------------------------------- +// parseCommand — deserialize raw JSON bytes into a LedCommand struct +// +// Protocol spec (D-05 through D-08): +// - Every command must include {"v":1, ...} version field +// - zone field is mandatory ("schrank", "wand", "all") — except STATUS +// - Either "animation" field or "cmd" field must be present +// - Unknown extra fields are silently ignored (ArduinoJson default) +// - Errors: logged to Serial, return false, no UDP response sent +// --------------------------------------------------------------------------- +bool parseCommand(const uint8_t* data, size_t len, LedCommand& out) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, data, len); + + if (err) { + Serial.print("JSON parse error: "); + Serial.println(err.c_str()); + return false; + } + + // Check protocol version (D-08) + if ((doc["v"] | 0) != 1) { + Serial.println("Unknown protocol version — expected v:1"); + return false; + } + + // Determine zone (mandatory for all commands except STATUS) + const char* zoneStr = doc["zone"] | ""; + if (strcmp(zoneStr, "schrank") == 0) { + out.zone = Zone::SCHRANK; + } else if (strcmp(zoneStr, "wand") == 0) { + out.zone = Zone::WAND; + } else if (strcmp(zoneStr, "all") == 0) { + out.zone = Zone::ALL; + } else { + // Zone may be absent for STATUS commands — handle below + out.zone = Zone::UNKNOWN; + } + + // Determine command type from "cmd" field or "animation" field (D-06) + const char* cmdStr = doc["cmd"] | ""; + + if (strcmp(cmdStr, "stop") == 0) { + // STOP command — zone required + if (out.zone == Zone::UNKNOWN) { + Serial.println("STOP command missing valid zone field"); + return false; + } + out.type = CmdType::STOP; + return true; + } + + if (strcmp(cmdStr, "brightness") == 0) { + // BRIGHTNESS command — zone required, value 0-60 (D-09) + if (out.zone == Zone::UNKNOWN) { + Serial.println("BRIGHTNESS command missing valid zone field"); + return false; + } + out.type = CmdType::BRIGHTNESS; + uint8_t val = doc["value"] | 40; + // Clamp to firmware-enforced max (D-09: max 60) + out.brightness = (val > 60) ? 60 : val; + return true; + } + + if (strcmp(cmdStr, "status") == 0) { + // STATUS command — zone not required, sender IP/port already set by caller + out.type = CmdType::STATUS; + out.zone = Zone::ALL; // Status responds for all zones + return true; + } + + // Check for ANIMATION command (presence of "animation" field) + const char* animStr = doc["animation"] | ""; + if (strlen(animStr) > 0) { + // Animation command — zone required (D-05) + if (out.zone == Zone::UNKNOWN) { + Serial.println("ANIMATION command missing valid zone field"); + return false; + } + + out.type = CmdType::ANIMATION; + + // Copy animation name (max 31 chars + null terminator) + strncpy(out.animName, animStr, 31); + out.animName[31] = '\0'; + + // Parse params object (D-01, D-04) + JsonObject params = doc["params"]; + + // Speed, intensity, direction with sensible defaults + out.params.speed = params["speed"] | 0.5f; + out.params.intensity = params["intensity"] | 1.0f; + out.params.direction = params["direction"] | (uint8_t)0; + + // Parse colors array — each element is [R, G, B, W] + JsonArray colorsArr = params["colors"]; + out.params.colorCount = 0; + + if (colorsArr) { + for (JsonArray color : colorsArr) { + if (out.params.colorCount >= 8) break; + uint8_t i = out.params.colorCount; + out.params.colors[i][0] = color[0] | (uint8_t)0; // R + out.params.colors[i][1] = color[1] | (uint8_t)0; // G + out.params.colors[i][2] = color[2] | (uint8_t)0; // B + out.params.colors[i][3] = color[3] | (uint8_t)0; // W + out.params.colorCount++; + } + } + + // Default to single warm white if no colors provided + if (out.params.colorCount == 0) { + out.params.colors[0][0] = 255; // R + out.params.colors[0][1] = 255; // G + out.params.colors[0][2] = 255; // B + out.params.colors[0][3] = 255; // W + out.params.colorCount = 1; + } + + return true; + } + + // Neither a known "cmd" nor an "animation" field + Serial.println("No cmd or animation field in command — dropping packet"); + return false; +} + +// --------------------------------------------------------------------------- +// buildStatusResponse — serialize current state as JSON for STATUS reply +// +// Returns: {"v":1,"status":"ok","wand":"chase","schrank":"breathe","brightness":40} +// --------------------------------------------------------------------------- +String buildStatusResponse(const char* wandAnim, const char* schrankAnim, uint8_t brightness) { + JsonDocument doc; + doc["v"] = 1; + doc["status"] = "ok"; + doc["wand"] = wandAnim; + doc["schrank"] = schrankAnim; + doc["brightness"] = brightness; + + String out; + serializeJson(doc, out); + return out; +} diff --git a/firmware/src/protocol.h b/firmware/src/protocol.h new file mode 100644 index 0000000..94a4c31 --- /dev/null +++ b/firmware/src/protocol.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Command types — determined by JSON "cmd" field or presence of "animation" +// Per D-06: stop, brightness, status commands; animation command via "animation" field +// --------------------------------------------------------------------------- +enum class CmdType : uint8_t { + ANIMATION = 0, // "animation" field present — run named animation + STOP = 1, // {"cmd":"stop","zone":...} + BRIGHTNESS = 2, // {"cmd":"brightness","zone":...,"value":0-100} + STATUS = 3, // {"cmd":"status"} — respond with JSON + UNKNOWN = 255 +}; + +// --------------------------------------------------------------------------- +// Animation parameters — per D-01, D-04 +// --------------------------------------------------------------------------- +struct AnimParams { + uint8_t colors[8][4]; // up to 8 RGBW colors [R,G,B,W] + uint8_t colorCount; // how many colors[] entries are valid + float speed; // 0.0–1.0 (default 0.5) + float intensity; // 0.0–1.0 (default 1.0) + uint8_t direction; // 0=forward, 1=reverse +}; + +// --------------------------------------------------------------------------- +// Zone selector — per D-05: schrank, wand, all +// --------------------------------------------------------------------------- +enum class Zone : uint8_t { + SCHRANK = 0, + WAND = 1, + ALL = 2, + UNKNOWN = 255 +}; + +// --------------------------------------------------------------------------- +// Parsed command struct — what commandQueue carries to animation engine +// --------------------------------------------------------------------------- +struct LedCommand { + CmdType type; + Zone zone; + char animName[32]; // animation name string (e.g. "chase"), null-terminated + AnimParams params; + uint8_t brightness; // 0–100, only valid for BRIGHTNESS cmd + IPAddress senderIP; // for STATUS response + uint16_t senderPort; +}; + +// --------------------------------------------------------------------------- +// Parses raw JSON bytes into LedCommand. +// Returns false if malformed, version mismatch, or missing required fields. +// Errors are logged to Serial; no UDP error response is sent (per D-07). +// --------------------------------------------------------------------------- +bool parseCommand(const uint8_t* data, size_t len, LedCommand& out); + +// --------------------------------------------------------------------------- +// Builds a JSON status response string (for STATUS cmd reply). +// Returns JSON: {"v":1,"status":"ok","wand":"chase","schrank":"breathe","brightness":40} +// Caller provides current animation names and brightness. +// --------------------------------------------------------------------------- +String buildStatusResponse(const char* wandAnim, const char* schrankAnim, uint8_t brightness);